Added password reset links and profile, Support email
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-08-11 14:07:43 +00:00
parent bea86712cb
commit ed1fdbbba6
17 changed files with 1047 additions and 71 deletions

2
.env
View File

@ -2,7 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
SERVER1_PORT=5000 SERVER1_PORT=5000
SERVER2_PORT=5001 SERVER2_PORT=5001
SERVER3_PORT=5002 SERVER3_PORT=5002
IMG_TAG=b0cbb65-202508101532 IMG_TAG=bea8671-202508111402
ENV_NAME=dev ENV_NAME=dev
PROJECT=aptivaai-dev PROJECT=aptivaai-dev

View File

@ -108,6 +108,8 @@ steps:
export KMS_KEY_NAME; \ export KMS_KEY_NAME; \
DEK_PATH=$(gcloud secrets versions access latest --secret=DEK_PATH_$ENV --project=$PROJECT); \ DEK_PATH=$(gcloud secrets versions access latest --secret=DEK_PATH_$ENV --project=$PROJECT); \
export DEK_PATH; \ export DEK_PATH; \
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
export SUPPORT_SENDGRID_API_KEY; \
export FROM_SECRETS_MANAGER=true; \ export FROM_SECRETS_MANAGER=true; \
\ \
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \ # ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -125,9 +127,9 @@ steps:
fi; \ fi; \
\ \
cd /home/jcoakley/aptiva-staging-app; \ cd /home/jcoakley/aptiva-staging-app; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \
docker compose pull; \ docker compose pull; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \
docker compose up -d --force-recreate --remove-orphans; \ docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"' echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

@ -9,7 +9,11 @@ import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
import pool from './config/mysqlPool.js'; import pool from './config/mysqlPool.js';
// import sqlite3 from 'sqlite3'; // (unused here safe to remove) import sqlite3 from 'sqlite3';
import crypto from 'crypto';
import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz
const CANARY_SQL = ` const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary ( CREATE TABLE IF NOT EXISTS encryption_canary (
@ -167,6 +171,29 @@ app.get('/healthz', async (_req, res) => {
return res.status(ready ? 200 : 503).json(out); return res.status(ready ? 200 : 503).json(out);
}); });
// Password reset token table (MySQL)
try {
const db = pool.raw || pool;
await db.query(`
CREATE TABLE IF NOT EXISTS password_resets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT NULL,
created_at BIGINT NOT NULL,
ip VARCHAR(64) NULL,
KEY (email),
KEY (token_hash),
KEY (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('[AUTH] password_resets table ready');
} catch (e) {
console.error('FATAL creating password_resets table:', e?.message || e);
process.exit(1);
}
// Enable CORS with dynamic origin checking // Enable CORS with dynamic origin checking
app.use( app.use(
cors({ cors({
@ -220,6 +247,220 @@ app.use((req, res, next) => {
next(); next();
}); });
const pwBurstLimiter = rateLimit({
windowMs: 30 * 1000, // 1 every 30s
max: 1,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const pwDailyLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // per day
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
async function setPasswordByEmail(email, bcryptHash) {
// Update via join to the profiles email
const sql = `
UPDATE user_auth ua
JOIN user_profile up ON up.id = ua.user_id
SET ua.hashed_password = ?
WHERE up.email = ?
LIMIT 1
`;
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, email]);
return !!r?.affectedRows;
}
// ----- Password reset config (zero-config dev mode) -----
const RESET_CONFIG = {
// accept both spellings just in case
BASE_URL: process.env.APTIVA_API_BASE || process.env.APTIV_API_BASE || 'http://localhost:3000',
FROM: 'no-reply@aptivaai.com', // edit here if you want
TTL_MIN: 60, // edit here if you want
};
// --- SendGrid config (safe + simple) ---
const SENDGRID_KEY = String(
process.env.SUPPORT_SENDGRID_API_KEY ||
process.env.SENDGRID_API_KEY || // optional fallback
process.env.SUPPORT_SENDGRID_API_KEY_dev || // if you exported _dev
''
).trim();
const SENDGRID_ENABLED = SENDGRID_KEY.length > 0;
if (SENDGRID_ENABLED) {
sgMail.setApiKey(SENDGRID_KEY);
console.log('[MAIL] SendGrid enabled');
} else {
console.log('[MAIL] SendGrid disabled — will log reset links only');
}
// Change password (must be logged in)
app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Auth required' });
let userId;
try {
({ id: userId } = jwt.verify(token, JWT_SECRET));
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
const { currentPassword, newPassword } = req.body || {};
if (
typeof currentPassword !== 'string' ||
typeof newPassword !== 'string' ||
newPassword.length < 8
) {
return res.status(400).json({ error: 'New password must be at least 8 characters' });
}
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'New password must be different' });
}
// fetch existing hash
const [rows] = await (pool.raw || pool).query(
'SELECT hashed_password FROM user_auth WHERE user_id = ? LIMIT 1',
[userId]
);
const existing = rows?.[0];
if (!existing) return res.status(404).json({ error: 'Account not found' });
// verify old password
const ok = await bcrypt.compare(currentPassword, existing.hashed_password);
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
// write new hash
const newHash = await bcrypt.hash(newPassword, 10);
await (pool.raw || pool).query(
'UPDATE user_auth SET hashed_password = ? WHERE user_id = ? LIMIT 1',
[newHash, userId]
);
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[password-change]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
});
/*Password reset request (MySQL)*/
app.post('/api/auth/password-reset/request', pwBurstLimiter, pwDailyLimiter, async (req, res) => {
try {
const email = String(req.body?.email || '').trim().toLowerCase();
// Always respond generically to avoid enumeration
const generic = () => res.status(200).json({ ok: true });
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return generic();
// Check if email exists (generic response regardless)
let exists = false;
try {
const q = `
SELECT ua.user_id
FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id
WHERE up.email = ?
LIMIT 1
`;
const [rows] = await (pool.raw || pool).query(q, [email]);
exists = !!rows?.length;
} catch { /* ignore */ }
// Only send if (a) we have SendGrid configured AND (b) email exists
if (exists) {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const now = Date.now();
const expiresAt = now + RESET_CONFIG.TTL_MIN * 60 * 1000;
await (pool.raw || pool).query(
`INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip)
VALUES (?, ?, ?, ?, ?)`,
[email, tokenHash, expiresAt, now, req.ip]
);
const base = RESET_CONFIG.BASE_URL.replace(/\/+$/, '');
const link = `${base}/reset-password/${token}`;
const text =
`We received a request to reset your Aptiva password.
If you requested this, use the link below (valid for ${RESET_CONFIG.TTL_MIN} minutes):
${link}
If you didnt request it, you can ignore this email.`;
if (SENDGRID_ENABLED) {
await sgMail.send({
to: email,
from: RESET_CONFIG.FROM,
subject: 'Reset your Aptiva password',
text,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
});
} else {
// Zero-config dev mode: just log the link so you can click it
console.log(`[DEV] Password reset link for ${email}: ${link}`);
}
}
return generic();
} catch (e) {
console.error('[password-reset/request]', e?.message || e);
// Still generic
return res.status(200).json({ ok: true });
}
});
app.post('/api/auth/password-reset/confirm', pwBurstLimiter, async (req, res) => {
try {
const { token, password } = req.body || {};
const t = String(token || '');
const p = String(password || '');
if (!t || p.length < 8) {
return res.status(400).json({ error: 'Invalid request' });
}
const tokenHash = crypto.createHash('sha256').update(t).digest('hex');
const now = Date.now();
const [rows] = await (pool.raw || pool).query(
`SELECT * FROM password_resets
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
ORDER BY id DESC LIMIT 1`,
[tokenHash, now]
);
const row = rows?.[0];
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
const hashed = await bcrypt.hash(p, 10); // matches your registration cost
const ok = await setPasswordByEmail(row.email, hashed);
if (!ok) return res.status(500).json({ error: 'Password update failed' });
await (pool.raw || pool).query(
`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`,
[now, row.id]
);
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[password-reset/confirm]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
USER REGISTRATION (MySQL) USER REGISTRATION (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -417,6 +658,8 @@ app.post('/api/user-profile', async (req, res) => {
riasec: riasec_scores, riasec: riasec_scores,
career_priorities, career_priorities,
career_list, career_list,
phone_e164,
sms_opt_in
} = req.body; } = req.body;
try { try {
@ -488,7 +731,10 @@ app.post('/api/user-profile', async (req, res) => {
finalRiasec, finalRiasec,
finalCareerPriorities, finalCareerPriorities,
finalCareerList, finalCareerList,
profileId, finalCareerList,
phone_e164 ?? existingRow.phone_e164 ?? null,
typeof sms_opt_in === 'boolean' ? (sms_opt_in ? 1 : 0) : existingRow.sms_opt_in ?? 0,
profileId
]; ];
await pool.query(updateQuery, params); await pool.query(updateQuery, params);
@ -517,7 +763,9 @@ app.post('/api/user-profile', async (req, res) => {
finalRiasec, finalRiasec,
finalCareerPriorities, finalCareerPriorities,
finalCareerList, finalCareerList,
]; phone_e164 || null,
sms_opt_in ? 1 : 0
];
await pool.query(insertQuery, params); await pool.query(insertQuery, params);
return res return res

View File

@ -4,7 +4,6 @@
import express from 'express'; import express from 'express';
import axios from 'axios'; import axios from 'axios';
import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import xlsx from 'xlsx'; import xlsx from 'xlsx';
@ -22,6 +21,8 @@ import rateLimit from 'express-rate-limit';
import authenticateUser from './utils/authenticateUser.js'; import authenticateUser from './utils/authenticateUser.js';
import { vectorSearch } from "./utils/vectorSearch.js"; import { vectorSearch } from "./utils/vectorSearch.js";
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
import crypto from 'crypto';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -141,6 +142,57 @@ app.get('/healthz', async (_req, res) => {
return res.status(ready ? 200 : 503).json(out); return res.status(ready ? 200 : 503).json(out);
}); });
// ── Support mail config (quotesafe) ────────────────────────────────
const SENDGRID_KEY = (process.env.SUPPORT_SENDGRID_API_KEY || '')
.trim()
.replace(/^['"]+|['"]+$/g, ''); // strip leading/trailing quotes if GCP injects them
if (SENDGRID_KEY) {
sgMail.setApiKey(SENDGRID_KEY);
} else {
console.warn('[support] SUPPORT_SENDGRID_API_KEY missing/empty; support email disabled');
}
// Small inmemory dedupe: (userId|subject|message) hash → expiresAt
const supportDedupe = new Map();
const DEDUPE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function normalize(s = '') {
return String(s).toLowerCase().replace(/\s+/g, ' ').trim();
}
function makeKey(userId, subject, message) {
const h = crypto.createHash('sha256');
h.update(`${userId}|${normalize(subject)}|${normalize(message)}`);
return h.digest('hex');
}
function isDuplicateAndRemember(key) {
const now = Date.now();
// prune expired
for (const [k, exp] of supportDedupe.entries()) {
if (exp <= now) supportDedupe.delete(k);
}
if (supportDedupe.has(key)) return true;
supportDedupe.set(key, now + DEDUPE_TTL_MS);
return false;
}
const supportBurstLimiter = rateLimit({
windowMs: 30 * 1000, // 1 every 30s
max: 1,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip
});
const supportDailyLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // per day
max: 3, // at most 3 per day
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip
});
/************************************************** /**************************************************
* DB connections (SQLite) * DB connections (SQLite)
**************************************************/ **************************************************/
@ -1098,6 +1150,102 @@ chatFreeEndpoint(app, {
userProfileDb userProfileDb
}); });
/**************************************************
* Support email endpoint
* Uses rate limiting to prevent abuse
* Supports deduplication
* Uses burst and daily limits
* Returns 429 Too Many Requests if limits exceeded
* Supports deduplication for 10 minutes
* *************************************************/
app.post(
'/api/support',
authenticateUser, // logged-in only
supportBurstLimiter,
supportDailyLimiter,
async (req, res) => {
try {
const user = req.user || {};
const userId = user.id || user.user_id || user.sub; // depends on your token
if (!userId) {
return res.status(401).json({ error: 'Auth required' });
}
// Prefer token email; fall back to DB; last resort: body.email
let accountEmail = user.email || user.mail || null;
if (!accountEmail) {
try {
const row = await userProfileDb.get(
'SELECT email FROM user_profile WHERE id = ?',
[userId]
);
accountEmail = row?.email || null;
} catch {}
}
if (!accountEmail) {
accountEmail = (req.body && req.body.email) || null;
}
if (!accountEmail) {
return res.status(400).json({ error: 'No email on file for this user' });
}
const { subject = '', category = 'general', message = '' } = req.body || {};
// Basic validation
const allowedCats = new Set(['general','billing','technical','data','ux']);
const subj = subject.toString().slice(0, 120).trim();
const body = message.toString().trim();
if (!allowedCats.has(String(category))) {
return res.status(400).json({ error: 'Invalid category' });
}
if (body.length < 5) {
return res.status(400).json({ error: 'Message too short' });
}
// Dedupe
const key = makeKey(userId, subj || '(no subject)', body);
if (isDuplicateAndRemember(key)) {
return res.status(202).json({ ok: true, deduped: true });
}
// Require mail config
const FROM = 'support@aptivaai.com';
const TO = 'support@aptivaai.com';
if (!SENDGRID_KEY) {
return res.status(503).json({ error: 'Support email not configured' });
}
const humanSubject =
`[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`;
const textBody =
`User: ${userId}
Email: ${accountEmail}
Category: ${category}
${body}`;
await sgMail.send({
to: TO,
from: FROM,
replyTo: accountEmail,
subject: humanSubject,
text: textBody,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${textBody}</pre>`,
categories: ['support', String(category || 'general')]
});
return res.status(200).json({ ok: true });
} catch (err) {
console.error('[support] error:', err?.message || err);
return res.status(500).json({ error: 'Failed to send support message' });
}
}
);
/************************************************** /**************************************************
* Start the Express server * Start the Express server
**************************************************/ **************************************************/

View File

@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename);
import express from 'express'; import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import { readFile } from 'fs/promises'; // <-- add this import fs, { readFile } from 'fs/promises'; // <-- add this
import multer from 'multer'; import multer from 'multer';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import mammoth from 'mammoth'; import mammoth from 'mammoth';
@ -426,6 +426,7 @@ async function applyOps(opsObj, req) {
if (op === "DELETE" && m.id) { if (op === "DELETE" && m.id) {
const cleanId = m.id.trim(); const cleanId = m.id.trim();
const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" }); const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" });
if (res.ok) confirmations.push(`Deleted milestone ${cleanId}`);
} }
/* ---------- UPDATE ---------- */ /* ---------- UPDATE ---------- */
@ -1278,7 +1279,7 @@ Our mission is to help people grow *with* AI rather than be displaced by it.
Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation. Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation.
Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator. Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator.
+Finish every reply with **one concrete suggestion or question** that moves the plan forward. Finish every reply with **one concrete suggestion or question** that moves the plan forward.
Never ask for info you already have unless you truly need clarification. Never ask for info you already have unless you truly need clarification.
`.trim(); `.trim();
@ -1476,7 +1477,7 @@ if (NEEDS_OPS_CARD) {
} }
if (NEEDS_CTX_CARD || SEND_CTX_CARD) if (NEEDS_CTX_CARD || SEND_CTX_CARD)
+ messagesToSend.push({ role:"system", content: summaryText }); messagesToSend.push({ role:"system", content: summaryText });
// ② Per-turn contextual helpers (small!) // ② Per-turn contextual helpers (small!)
messagesToSend.push( messagesToSend.push(
@ -2062,13 +2063,13 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
} }
// 1) Check if we already have it // 1) Check if we already have it
const cached = await getCachedRiskAnalysis(socCode); const cached = await getRiskAnalysisFromDB(socCode);
if (cached) { if (cached) {
return res.json({ return res.json({
socCode: cached.soc_code, socCode: cached.soc_code,
careerName: cached.career_name, careerName: cached.career_name,
jobDescription: cached.job_description, jobDescription: cached.job_description,
tasks: cached.tasks ? JSON.parse(cached.tasks) : [], tasks: Array.isArray(cached.tasks) ? cached.tasks : (cached.tasks ? String(cached.tasks).split(';') : []),
riskLevel: cached.risk_level, riskLevel: cached.risk_level,
reasoning: cached.reasoning reasoning: cached.reasoning
}); });
@ -2089,6 +2090,7 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
} }
`; `;
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", model: "gpt-4o-mini",
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
@ -2107,13 +2109,14 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
const { riskLevel, reasoning } = parsed; const { riskLevel, reasoning } = parsed;
// 3) Store in DB // 3) Store in DB
await cacheRiskAnalysis({ await storeRiskAnalysisInDB({
socCode, socCode,
careerName, careerName,
jobDescription, jobDescription,
tasks, tasks,
riskLevel, riskLevel: parsed.riskLevel,
reasoning reasoning: parsed.reasoning
}); });
// 4) Return the new analysis // 4) Return the new analysis
@ -3396,8 +3399,8 @@ app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
cp.career_name cp.career_name
FROM tasks t FROM tasks t
JOIN milestones m ON m.id = t.milestone_id JOIN milestones m ON m.id = t.milestone_id
JOIN career_paths cp ON cp.id = m.career_path_id JOIN career_profiles cp ON cp.id = m.career_profile_id
WHERE cp.user_id = ? WHERE t.user_id = ?
`; `;
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); } if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }

View File

@ -24,6 +24,7 @@ SECRETS=(
STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR \ STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR \
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \ DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \ DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
SUPPORT_SENDGRID_API_KEY \
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \ TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
KMS_KEY_NAME DEK_PATH KMS_KEY_NAME DEK_PATH
) )

View File

@ -44,6 +44,7 @@ services:
DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA} DB_SSL_CA: ${DB_SSL_CA}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
SALARY_DB_PATH: /app/salary_info.db SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes: volumes:
@ -89,6 +90,7 @@ services:
DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA} DB_SSL_CA: ${DB_SSL_CA}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
SALARY_DB_PATH: /app/salary_info.db SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes: volumes:
@ -143,6 +145,7 @@ services:
DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA} DB_SSL_CA: ${DB_SSL_CA}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
SALARY_DB_PATH: /app/salary_info.db SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes: volumes:

View File

@ -57,6 +57,7 @@ http {
location ^~ /api/ai-risk { proxy_pass http://backend5002; } location ^~ /api/ai-risk { proxy_pass http://backend5002; }
location ^~ /api/maps/distance { proxy_pass http://backend5001; } location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { proxy_pass http://backend5001; } location ^~ /api/schools { proxy_pass http://backend5001; }
location ^~ /api/support { proxy_pass http://backend5001; }
location ^~ /api/premium/ { proxy_pass http://backend5002; } location ^~ /api/premium/ { proxy_pass http://backend5002; }
location ^~ /api/public/ { proxy_pass http://backend5002; } location ^~ /api/public/ { proxy_pass http://backend5002; }

39
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tabs": "^1.1.9",
"@sendgrid/mail": "^8.1.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
@ -4003,6 +4004,44 @@
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sendgrid/client": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz",
"integrity": "sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==",
"license": "MIT",
"dependencies": {
"@sendgrid/helpers": "^8.0.0",
"axios": "^1.8.2"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sendgrid/helpers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/@sendgrid/mail": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.5.tgz",
"integrity": "sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==",
"license": "MIT",
"dependencies": {
"@sendgrid/client": "^8.1.5",
"@sendgrid/helpers": "^8.0.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sinclair/typebox": { "node_modules/@sinclair/typebox": {
"version": "0.24.51", "version": "0.24.51",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",

View File

@ -10,6 +10,7 @@
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-tabs": "^1.1.9",
"@sendgrid/mail": "^8.1.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
@ -59,12 +60,12 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"overrides": { "overrides": {
"@types/request": { "@types/request": {
"form-data": "^4.0.4" "form-data": "^4.0.4"
}, },
"jsdom": { "jsdom": {
"form-data": "^4.0.4" "form-data": "^4.0.4"
} }
}, },
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint .",

View File

@ -38,6 +38,10 @@ import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js'; import ChatDrawer from './components/ChatDrawer.js';
import ChatCtx from './contexts/ChatCtx.js'; import ChatCtx from './contexts/ChatCtx.js';
import BillingResult from './components/BillingResult.js'; import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js';
@ -51,6 +55,11 @@ function App() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerPane, setDrawerPane] = useState('support'); const [drawerPane, setDrawerPane] = useState('support');
const [retireProps, setRetireProps] = useState(null); const [retireProps, setRetireProps] = useState(null);
const [supportOpen, setSupportOpen] = useState(false);
const [userEmail, setUserEmail] = useState('');
const AUTH_HOME = '/signin-landing';
/* ------------------------------------------ /* ------------------------------------------
ChatDrawer route-aware tool handlers ChatDrawer route-aware tool handlers
@ -101,18 +110,34 @@ const canShowRetireBot =
// Check if user can access premium // Check if user can access premium
const canAccessPremium = user?.is_premium || user?.is_pro_premium; const canAccessPremium = user?.is_premium || user?.is_pro_premium;
const isAuthScreen = React.useMemo(() => {
const p = location.pathname;
return (
p === '/signin' ||
p === '/signup' ||
p === '/forgot-password' ||
p.startsWith('/reset-password')
);
}, [location.pathname]);
const showAuthedNav = isAuthenticated && !isAuthScreen;
// List of premium paths for your CTA logic // List of premium paths for your CTA logic
const premiumPaths = [ const premiumPaths = [
'/career-roadmap', '/career-roadmap',
'/paywall', '/paywall',
'/financial-profile', '/financial-profile',
'/retirement-planner', '/retirement-planner',
'/premium-onboarding', '/premium-onboarding',
'/enhancing', '/enhancing',
'/retirement', '/retirement',
'/resume-optimizer', '/resume-optimizer',
]; ];
const showPremiumCTA = !premiumPaths.includes(location.pathname);
const showPremiumCTA = !premiumPaths.some(p =>
location.pathname.startsWith(p)
);
// Helper to see if user is midpremium-onboarding // Helper to see if user is midpremium-onboarding
function isOnboardingInProgress() { function isOnboardingInProgress() {
@ -125,6 +150,13 @@ const canShowRetireBot =
} }
} }
/* =====================
Support Modal Email
===================== */
useEffect(() => {
setUserEmail(user?.email || '');
}, [user]);
// ============================== // ==============================
// 1) Single Rehydrate UseEffect // 1) Single Rehydrate UseEffect
// ============================== // ==============================
@ -221,6 +253,8 @@ const canShowRetireBot =
); );
} }
// ===================== // =====================
// Main Render / Layout // Main Render / Layout
// ===================== // =====================
@ -250,7 +284,7 @@ const canShowRetireBot =
AptivaAI - Career Guidance Platform AptivaAI - Career Guidance Platform
</h1> </h1>
{isAuthenticated && ( {showAuthedNav && (
<> <>
{/* NAV MENU */} {/* NAV MENU */}
<nav className="flex space-x-6"> <nav className="flex space-x-6">
@ -466,6 +500,20 @@ const canShowRetireBot =
</Button> </Button>
)} )}
<button
type="button"
onClick={() => setSupportOpen(true)}
className="px-3 py-1 rounded hover:bg-gray-100"
>
Support
</button>
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
userEmail={userEmail}
/>
{/* LOGOUT BUTTON */} {/* LOGOUT BUTTON */}
<button <button
className="text-red-600 hover:text-red-800 bg-transparent border-none" className="text-red-600 hover:text-red-800 bg-transparent border-none"
@ -494,41 +542,61 @@ const canShowRetireBot =
{/* MAIN CONTENT */} {/* MAIN CONTENT */}
<main className="flex-1 p-6"> <main className="flex-1 p-6">
<Routes> <Routes>
{/* Default to /signin */} {/* Default */}
<Route path="/" element={<Navigate to="/signin" />} /> <Route
path="/"
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/>
{/* Public (guest-only) routes */}
<Route
path="/signin"
element={
isAuthenticated ? (
<Navigate to={AUTH_HOME} replace />
) : (
<SignIn
setIsAuthenticated={setIsAuthenticated}
setUser={setUser}
setFinancialProfile={setFinancialProfile}
setScenario={setScenario}
/>
)
}
/>
<Route
path="/signup"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <SignUp setUser={setUser} />}
/>
<Route
path="/forgot-password"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />}
/>
<Route
path="/reset-password/:token"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ResetPassword />}
/>
{/* Public routes */}
<Route
path="/signin"
element={
<SignIn
setIsAuthenticated={setIsAuthenticated}
setUser={setUser}
setFinancialProfile={setFinancialProfile}
setScenario={setScenario}
/>
}
/>
<Route
path="/signup"
element={<SignUp setUser={setUser} />}
/>
<Route path="/paywall" element={<Paywall />} /> <Route path="/paywall" element={<Paywall />} />
{/* Authenticated routes */} {/* Authenticated routes */}
{isAuthenticated && ( {isAuthenticated && (
<> <>
<Route path="/signin-landing" element={<SignInLanding user={user} />}/> <Route path="/signin-landing" element={<SignInLanding user={user} />} />
<Route path="/interest-inventory" element={<InterestInventory />} /> <Route path="/interest-inventory" element={<InterestInventory />} />
<Route path="/profile" element={<UserProfile />} /> <Route path="/profile" element={<UserProfile />} />
<Route path="/planning" element={<PlanningLanding />} /> <Route path="/planning" element={<PlanningLanding />} />
<Route path="/career-explorer" element={<CareerExplorer />} /> <Route path="/career-explorer" element={<CareerExplorer />} />
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/> <Route path="/loan-repayment" element={<LoanRepaymentPage />} />
<Route path="/educational-programs" element={<EducationalProgramsPage />} /> <Route path="/educational-programs" element={<EducationalProgramsPage />} />
<Route path="/preparing" element={<PreparingLanding />} /> <Route path="/preparing" element={<PreparingLanding />} />
<Route path="/billing" element={<BillingResult />} /> <Route path="/billing" element={<BillingResult />} />
{/* Premium-only routes */} {/* Premium-wrapped */}
<Route <Route
path="/enhancing" path="/enhancing"
element={ element={
@ -553,11 +621,10 @@ const canShowRetireBot =
</PremiumRoute> </PremiumRoute>
} }
/> />
<Route path="/profile/careers" element={<CareerProfileList />} /> <Route path="/profile/careers" element={<CareerProfileList />} />
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} /> <Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
<Route path="/profile/college" element={<CollegeProfileList />} />
<Route path="/profile/college/" element={<CollegeProfileList />} /> <Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
<Route <Route
path="/financial-profile" path="/financial-profile"
element={ element={
@ -593,8 +660,12 @@ const canShowRetireBot =
</> </>
)} )}
{/* 404 / Fallback */} {/* 404 / Fallback */}
<Route path="*" element={<Navigate to="/signin" />} /> <Route
path="*"
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/>
</Routes> </Routes>
</main> </main>

View File

@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { Button } from './ui/button.js';
function ChangePasswordForm() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(null); // { type: 'ok' | 'err', text: string }
function validate() {
if (!currentPassword || !newPassword || !confirmPassword) {
return 'All fields are required.';
}
if (newPassword.length < 8) {
return 'New password must be at least 8 characters.';
}
if (newPassword === currentPassword) {
return 'New password must be different from current password.';
}
if (newPassword !== confirmPassword) {
return 'New password and confirmation do not match.';
}
return null;
}
async function handleSubmit(e) {
e.preventDefault();
setMsg(null);
const err = validate();
if (err) {
setMsg({ type: 'err', text: err });
return;
}
setLoading(true);
try {
const token = localStorage.getItem('token') || '';
const res = await fetch('/api/auth/password-change', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({ currentPassword, newPassword })
});
if (res.ok) {
setMsg({ type: 'ok', text: 'Password updated successfully.' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
let detail = 'Failed to change password.';
try {
const j = await res.json();
if (j?.error) detail = j.error;
} catch {}
if (res.status === 401) detail = 'Session expired. Please sign in again.';
if (res.status === 403) detail = 'Current password is incorrect.';
if (res.status === 429) detail = 'Too many attempts. Please wait a bit and try again.';
setMsg({ type: 'err', text: detail });
}
} catch (e) {
setMsg({ type: 'err', text: 'Network error. Please try again.' });
} finally {
setLoading(false);
}
}
return (
<div className="bg-white border rounded shadow-sm p-4 md:p-6">
<h2 className="text-lg font-semibold mb-4">Change Password</h2>
{msg && (
<div
className={
msg.type === 'ok'
? 'mb-4 rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800'
: 'mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800'
}
>
{msg.text}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="current-password"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="new-password"
required
minLength={8}
/>
<p className="mt-1 text-xs text-gray-500">At least 8 characters.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="new-password"
required
minLength={8}
/>
</div>
<div className="pt-2">
<Button
type="submit"
disabled={loading}
className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? 'Updating…' : 'Update Password'}
</Button>
</div>
</form>
</div>
);
}
export default ChangePasswordForm;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Button } from './ui/button.js';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
async function onSubmit(e) {
e.preventDefault();
setError('');
const val = email.trim().toLowerCase();
if (!val || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
setError('Enter a valid email.');
return;
}
setSubmitting(true);
try {
await fetch('/api/auth/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: val })
});
// Always shows success (endpoint is generic on purpose)
setDone(true);
} catch (err) {
// Still show success message to avoid account enumeration
setDone(true);
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
<h2 className="text-xl font-semibold mb-2">Check your email</h2>
<p className="text-sm text-gray-700">
If theres an account for <strong>{email}</strong>, youll get a link to reset your password.
</p>
<div className="mt-4 flex gap-2">
<Button onClick={() => navigate('/signin')}>Back to Sign In</Button>
<Link className="text-blue-600 underline text-sm" to="/signup">Create an account</Link>
</div>
</div>
);
}
return (
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
<h2 className="text-xl font-semibold mb-3">Forgot your password?</h2>
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="block text-sm mb-1">Email</label>
<input
type="email"
className="w-full border rounded px-3 py-2"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
{error && <p className="text-red-600 text-xs mt-1">{error}</p>}
</div>
<Button type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send reset link'}
</Button>
</form>
<div className="mt-3">
<Link className="text-blue-600 underline text-sm" to="/signin">Back to Sign In</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Button } from './ui/button.js';
export default function ResetPassword() {
const { token } = useParams();
const navigate = useNavigate();
const [pw, setPw] = useState('');
const [pw2, setPw2] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [ok, setOk] = useState(false);
async function onSubmit(e) {
e.preventDefault();
setError('');
if (!pw || pw.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
if (pw !== pw2) {
setError('Passwords do not match.');
return;
}
setSubmitting(true);
try {
const r = await fetch('/api/auth/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password: pw })
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || 'Reset failed');
}
setOk(true);
} catch (err) {
setError(err.message || 'Reset failed');
} finally {
setSubmitting(false);
}
}
if (ok) {
return (
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
<h2 className="text-xl font-semibold mb-2">Password updated</h2>
<p className="text-sm text-gray-700">You can now sign in with your new password.</p>
<div className="mt-4">
<Button onClick={() => navigate('/signin')}>Go to Sign In</Button>
</div>
</div>
);
}
if (!token) {
return (
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
<h2 className="text-xl font-semibold mb-2">Invalid link</h2>
<p className="text-sm text-gray-700">This reset link is missing or malformed.</p>
<Link className="text-blue-600 underline text-sm" to="/forgot-password">Request a new link</Link>
</div>
);
}
return (
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
<h2 className="text-xl font-semibold mb-3">Set a new password</h2>
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="block text-sm mb-1">New password</label>
<input
type="password"
className="w-full border rounded px-3 py-2"
value={pw}
onChange={(e) => setPw(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div>
<label className="block text-sm mb-1">Confirm password</label>
<input
type="password"
className="w-full border rounded px-3 py-2"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
autoComplete="new-password"
required
/>
</div>
{error && <p className="text-red-600 text-xs">{error}</p>}
<Button type="submit" disabled={submitting}>
{submitting ? 'Saving…' : 'Update password'}
</Button>
</form>
<div className="mt-3">
<Link className="text-blue-600 underline text-sm" to="/forgot-password">Need a new link?</Link>
</div>
</div>
);
}

View File

@ -124,15 +124,23 @@ function SignIn({ setIsAuthenticated, setUser }) {
</button> </button>
</form> </form>
<p className="mt-4 text-center text-sm text-gray-600"> <div className="mt-4 space-y-2">
Dont have an account?{' '} <p className="text-center text-sm text-gray-600">
<Link Dont have an account?{' '}
to="/signup" // <- here <Link to="/signup" className="text-blue-600 hover:underline">
className="font-medium text-blue-600 hover:text-blue-500" Sign Up
> </Link>
Sign Up </p>
</Link>
</p> <div className="text-center">
<Link
to="/forgot-password"
className="inline-block text-sm text-blue-600 hover:underline"
>
Forgot your password?
</Link>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import authFetch from '../utils/authFetch.js';
import { Button } from './ui/button.js';
export default function SupportModal({ open, onClose, userEmail }) {
const [subject, setSubject] = useState('');
const [category, setCategory] = useState('general');
const [message, setMessage] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const [ok, setOk] = useState(false);
if (!open) return null;
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (!message || message.trim().length < 5) {
setError('Please enter at least 5 characters.');
setOk(false);
return;
}
setSending(true);
try {
const res = await authFetch('/api/support', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, subject, category, message })
});
if (!res) {
// authFetch returns null on 401/403
throw new Error('Your session expired. Please sign in again.');
}
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `Request failed (${res.status})`);
}
setOk(true);
setSubject(''); // reset to empty strings, not null
setCategory('general');
setMessage('');
setTimeout(() => onClose(), 1200);
} catch (err) {
setError(err.message || 'Failed to send');
} finally {
setSending(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-lg rounded bg-white p-4 shadow">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold">Contact Support</h3>
<button onClick={onClose} className="text-sm underline">Close</button>
</div>
{!userEmail && (
<p className="text-sm text-red-600 mb-2">
You must be signed in to contact support.
</p>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-sm block mb-1">Well reply to</label>
<input
className="w-full border rounded px-2 py-1 bg-gray-100"
value={userEmail || ''}
readOnly
/>
</div>
<div>
<label className="text-sm block mb-1">Subject (optional)</label>
<input
className="w-full border rounded px-2 py-1"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Brief subject"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm block mb-1">Category</label>
<select
className="w-full border rounded px-2 py-1"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="general">General</option>
<option value="billing">Billing</option>
<option value="technical">Technical</option>
<option value="data">Data issue</option>
<option value="ux">UX/feedback</option>
</select>
</div>
</div>
<div>
<label className="text-sm block mb-1">Message</label>
<textarea
className="w-full border rounded px-2 py-1 h-28"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="How can we help?"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{ok && <p className="text-sm text-green-600">Sent. Well be in touch.</p>}
<div className="flex justify-end gap-2">
<Button type="button" className="bg-gray-200 text-black" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={sending || !userEmail}>
{sending ? 'Sending…' : 'Send'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from './ChangePasswordForm.js';
function UserProfile() { function UserProfile() {
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
@ -325,6 +326,8 @@ function UserProfile() {
</select> </select>
</div> </div>
<ChangePasswordForm />
{/* Form Buttons */} {/* Form Buttons */}
<div className="mt-6 flex items-center justify-end space-x-3"> <div className="mt-6 flex items-center justify-end space-x-3">
<button <button