Password resets - Signin and UserProfile

This commit is contained in:
Josh 2025-08-12 16:57:16 +00:00
parent 974585ea6e
commit 2d9e63af32
26 changed files with 893 additions and 476 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=bea8671-202508111402 IMG_TAG=ed1fdbb-202508121553
ENV_NAME=dev ENV_NAME=dev
PROJECT=aptivaai-dev PROJECT=aptivaai-dev

View File

@ -14,6 +14,7 @@ import crypto from 'crypto';
import sgMail from '@sendgrid/mail'; import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
const CANARY_SQL = ` const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary ( CREATE TABLE IF NOT EXISTS encryption_canary (
@ -171,6 +172,7 @@ 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) // Password reset token table (MySQL)
try { try {
const db = pool.raw || pool; const db = pool.raw || pool;
@ -247,13 +249,33 @@ app.use((req, res, next) => {
next(); next();
}); });
const pwBurstLimiter = rateLimit({ // keep tight on request
windowMs: 30 * 1000, // 1 every 30s const pwRequestLimiter = rateLimit({
windowMs: 30 * 1000,
max: 1, max: 1,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req) => req.ip, keyGenerator: (req) => req.ip,
}); });
// allow a couple retries on confirm
const pwConfirmLimiter = rateLimit({
windowMs: 30 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// change-password inside the app
const pwChangeLimiter = rateLimit({
windowMs: 30 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const pwDailyLimiter = rateLimit({ const pwDailyLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // per day windowMs: 24 * 60 * 60 * 1000, // per day
max: 5, max: 5,
@ -263,21 +285,33 @@ const pwDailyLimiter = rateLimit({
}); });
async function setPasswordByEmail(email, bcryptHash) { async function setPasswordByEmail(email, bcryptHash) {
// Update via join to the profiles email
const sql = ` const sql = `
UPDATE user_auth ua UPDATE user_auth ua
JOIN user_profile up ON up.id = ua.user_id JOIN user_profile up ON up.id = ua.user_id
SET ua.hashed_password = ? SET ua.hashed_password = ?, ua.password_changed_at = ?
WHERE up.email = ? WHERE up.email_lookup = ?
LIMIT 1
`; `;
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, email]); const now = Date.now();
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, now, emailLookup(email)]);
return !!r?.affectedRows; return !!r?.affectedRows;
} }
// ---- Email index helper (HMAC-SHA256 of normalized email) ----
const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET;
function emailLookup(email) {
return crypto
.createHmac('sha256', EMAIL_INDEX_KEY)
.update(String(email).trim().toLowerCase())
.digest('hex');
}
// ----- Password reset config (zero-config dev mode) ----- // ----- Password reset config (zero-config dev mode) -----
const RESET_CONFIG = { const RESET_CONFIG = {
// accept both spellings just in case // accept both spellings just in case
BASE_URL: process.env.APTIVA_API_BASE || process.env.APTIV_API_BASE || 'http://localhost:3000', BASE_URL: process.env.APTIVA_API_BASE || 'http://localhost:5173',
FROM: 'no-reply@aptivaai.com', // edit here if you want FROM: 'no-reply@aptivaai.com', // edit here if you want
TTL_MIN: 60, // edit here if you want TTL_MIN: 60, // edit here if you want
}; };
@ -299,50 +333,51 @@ if (SENDGRID_ENABLED) {
console.log('[MAIL] SendGrid disabled — will log reset links only'); console.log('[MAIL] SendGrid disabled — will log reset links only');
} }
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
const PASSWORD_HELP =
'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).';
// Change password (must be logged in) // Change password (must be logged in)
app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => { app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => {
try { try {
const token = req.headers.authorization?.split(' ')[1]; const now = Date.now();
if (!token) return res.status(401).json({ error: 'Auth required' }); const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
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 || {}; const { currentPassword, newPassword } = req.body || {};
if ( if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
typeof currentPassword !== 'string' || return res.status(400).json({ error: 'Invalid payload' });
typeof newPassword !== 'string' || }
newPassword.length < 8
) { if (!PASSWORD_REGEX.test(newPassword)) {
return res.status(400).json({ error: 'New password must be at least 8 characters' }); return res.status(400).json({ error: PASSWORD_HELP });
} }
if (newPassword === currentPassword) { if (newPassword === currentPassword) {
return res.status(400).json({ error: 'New password must be different' }); return res.status(400).json({ error: 'New password must be different' });
} }
// fetch existing hash const db = pool.raw || pool;
const [rows] = await (pool.raw || pool).query( const [rows] = await db.query(
'SELECT hashed_password FROM user_auth WHERE user_id = ? LIMIT 1', 'SELECT hashed_password FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId] [userId]
); );
const existing = rows?.[0]; const existing = rows?.[0];
if (!existing) return res.status(404).json({ error: 'Account not found' }); if (!existing) return res.status(404).json({ error: 'Account not found' });
// verify old password
const ok = await bcrypt.compare(currentPassword, existing.hashed_password); const ok = await bcrypt.compare(currentPassword, existing.hashed_password);
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' }); if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
// write new hash
const newHash = await bcrypt.hash(newPassword, 10); const newHash = await bcrypt.hash(newPassword, 10);
await (pool.raw || pool).query(
'UPDATE user_auth SET hashed_password = ? WHERE user_id = ? LIMIT 1', // 🔧 store epoch ms to match requireAuths comparison
[newHash, userId] const [upd] = await db.query(
'UPDATE user_auth SET hashed_password = ?, password_changed_at = ? WHERE user_id = ?',
[newHash, now, userId]
); );
if (!upd?.affectedRows) return res.status(500).json({ error: 'Password update failed' });
// Optional: revoke all refresh tokens for this user on password change
// await db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} catch (e) { } catch (e) {
@ -353,7 +388,7 @@ app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
/*Password reset request (MySQL)*/ /*Password reset request (MySQL)*/
app.post('/api/auth/password-reset/request', pwBurstLimiter, pwDailyLimiter, async (req, res) => { app.post('/api/auth/password-reset/request', pwRequestLimiter, pwDailyLimiter, async (req, res) => {
try { try {
const email = String(req.body?.email || '').trim().toLowerCase(); const email = String(req.body?.email || '').trim().toLowerCase();
// Always respond generically to avoid enumeration // Always respond generically to avoid enumeration
@ -364,15 +399,16 @@ app.post('/api/auth/password-reset/request', pwBurstLimiter, pwDailyLimiter, asy
// Check if email exists (generic response regardless) // Check if email exists (generic response regardless)
let exists = false; let exists = false;
try { try {
const q = ` const [rows] = await (pool.raw || pool).query(
SELECT ua.user_id `SELECT ua.user_id
FROM user_auth ua FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id JOIN user_profile up ON up.id = ua.user_id
WHERE up.email = ? WHERE up.email_lookup = ?
LIMIT 1 LIMIT 1`,
`; [emailLookup(email)]
const [rows] = await (pool.raw || pool).query(q, [email]); );
exists = !!rows?.length; exists = !!rows?.length;
} catch { /* ignore */ } } catch { /* ignore */ }
// Only send if (a) we have SendGrid configured AND (b) email exists // Only send if (a) we have SendGrid configured AND (b) email exists
@ -421,54 +457,89 @@ return generic();
} }
}); });
app.post('/api/auth/password-reset/confirm', pwBurstLimiter, async (req, res) => { app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res) => {
try { try {
const { token, password } = req.body || {}; const { token, password } = req.body || {};
const t = String(token || '');
const p = String(password || '');
if (!t || p.length < 8) { // 1) Validate input + password policy
return res.status(400).json({ error: 'Invalid request' }); if (typeof password !== 'string' || !PASSWORD_REGEX.test(password)) {
return res.status(400).json({ error: PASSWORD_HELP });
}
if (typeof token !== 'string' || !token) {
return res.status(400).json({ error: 'Invalid or expired token' });
} }
const tokenHash = crypto.createHash('sha256').update(t).digest('hex');
const now = Date.now(); const now = Date.now();
const db = pool.raw || pool;
const [rows] = await (pool.raw || pool).query( // 2) Lookup token row (not used, not expired)
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const [tokRows] = await db.query(
`SELECT * FROM password_resets `SELECT * FROM password_resets
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
ORDER BY id DESC LIMIT 1`, ORDER BY id DESC LIMIT 1`,
[tokenHash, now] [tokenHash, now]
); );
const tok = tokRows?.[0];
if (!tok) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const row = rows?.[0]; // 3) Find user by email_lookup (HMAC of normalized email)
if (!row) return res.status(400).json({ error: 'Invalid or expired token' }); const emailNorm = String(tok.email).trim().toLowerCase();
const emailLookupVal = emailLookup(emailNorm);
const hashed = await bcrypt.hash(p, 10); // matches your registration cost
const ok = await setPasswordByEmail(row.email, hashed); const [userRows] = await db.query(
if (!ok) return res.status(500).json({ error: 'Password update failed' }); `SELECT ua.user_id, ua.hashed_password
FROM user_auth ua
await (pool.raw || pool).query( JOIN user_profile up ON up.id = ua.user_id
`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, WHERE up.email_lookup = ?
[now, row.id] ORDER BY ua.id DESC
LIMIT 1`,
[emailLookupVal]
); );
const user = userRows?.[0];
if (!user) {
// burn token anyway
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(400).json({ error: 'Account not found for this link' });
}
// 4) Prevent same-password reset
const same = await bcrypt.compare(password, user.hashed_password);
if (same) {
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(400).json({ error: 'New password must be different from the current password.' });
}
// 5) Update password (stamp epoch ms to match requireAuth)
const newHash = await bcrypt.hash(password, 10);
const [upd] = await db.query(
`UPDATE user_auth
SET hashed_password = ?, password_changed_at = ?
WHERE user_id = ?`,
[newHash, now, user.user_id]
);
if (!upd?.affectedRows) {
return res.status(500).json({ error: 'Password update failed' });
}
// 6) Burn the token
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
} catch (e) { } catch (e) {
console.error('[password-reset/confirm]', e?.message || e); console.error('[password-reset/confirm]', e?.message || e);
if (e?.stack) console.error(e.stack);
return res.status(500).json({ error: 'Server error' }); return res.status(500).json({ error: 'Server error' });
} }
}); });
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
USER REGISTRATION (MySQL) USER REGISTRATION (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
/**
* POST /api/register
* Body:
* username, password, firstname, lastname, email, zipcode, state, area, career_situation
*/
app.post('/api/register', async (req, res) => { app.post('/api/register', async (req, res) => {
const { const {
username, password, firstname, lastname, email, username, password, firstname, lastname, email,
@ -486,44 +557,50 @@ app.post('/api/register', async (req, res) => {
try { try {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const profileQuery = ` const emailNorm = String(email).trim().toLowerCase();
const encEmail = encrypt(emailNorm); // if encrypt() is async in your lib, use: await encrypt(...)
const emailLookupVal = emailLookup(emailNorm);
const [resultProfile] = await pool.query(`
INSERT INTO user_profile INSERT INTO user_profile
(username, firstname, lastname, email, zipcode, state, area, career_situation, phone_e164, sms_opt_in) (username, firstname, lastname, email, email_lookup, zipcode, state, area,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) career_situation, phone_e164, sms_opt_in)
`; VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
const [resultProfile] = await pool.query(profileQuery, [ `, [
username, firstname, lastname, email, zipcode, state, area, username, firstname, lastname, encEmail, emailLookupVal,
career_situation, phone_e164 || null, sms_opt_in ? 1 : 0 zipcode, state, area, career_situation || null,
phone_e164 || null, sms_opt_in ? 1 : 0
]); ]);
const newProfileId = resultProfile.insertId; const newProfileId = resultProfile.insertId;
const authQuery = ` const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
INSERT INTO user_auth (user_id, username, hashed_password)
VALUES (?, ?, ?)
`;
await pool.query(authQuery, [newProfileId, username, hashedPassword]); await pool.query(authQuery, [newProfileId, username, hashedPassword]);
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' }); const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
const userPayload = {
username, firstname, lastname, email, zipcode, state, area,
career_situation, phone_e164, sms_opt_in: !!sms_opt_in
};
return res.status(201).json({ return res.status(201).json({
message: 'User registered successfully', message: 'User registered successfully',
profileId: newProfileId, profileId: newProfileId,
token, token,
user: userPayload user: {
username, firstname, lastname, email: emailNorm, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
}
}); });
} catch (err) { } catch (err) {
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
console.error('Error during registration:', err.message); console.error('Error during registration:', err.message);
return res.status(500).json({ error: 'Internal server error' }); return res.status(500).json({ error: 'Internal server error' });
} }
}); });
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
SIGN-IN (MySQL) SIGN-IN (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -584,7 +661,11 @@ app.post('/api/signin', async (req, res) => {
FROM user_profile WHERE id = ?', FROM user_profile WHERE id = ?',
[row.userProfileId] [row.userProfileId]
); );
const profile = profileRows[0]; // ← already decrypted const profile = profileRows[0];
if (profile?.email) {
try { profile.email = decrypt(profile.email); } catch {}
}
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
@ -621,29 +702,9 @@ app.get('/api/check-username/:username', async (req, res) => {
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
UPSERT USER PROFILE (MySQL) UPSERT USER PROFILE (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
/** app.post('/api/user-profile', requireAuth, async (req, res) => {
* POST /api/user-profile const profileId = req.userId; // from requireAuth middleware
* Headers: { Authorization: Bearer <token> }
* Body: { userName, firstName, lastName, email, zipCode, state, area, ... }
*
* If user_profile row exists (id = token.id), update
* else insert
*/
app.post('/api/user-profile', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authorization token is required' });
}
let profileId;
try {
const decoded = jwt.verify(token, JWT_SECRET);
profileId = decoded.id;
} catch (error) {
console.error('JWT verification failed:', error);
return res.status(401).json({ error: 'Invalid or expired token' });
}
const { const {
userName, userName,
@ -663,116 +724,126 @@ app.post('/api/user-profile', async (req, res) => {
} = req.body; } = req.body;
try { try {
const [results] = await pool.query( const [rows] = await pool.query(`SELECT * FROM user_profile WHERE id = ?`, [profileId]);
`SELECT * FROM user_profile WHERE id = ?`, const existing = rows[0];
[profileId]
);
const existingRow = results.length > 0 ? results[0] : null;
if ( if (!existing &&
!existingRow && (!firstName || !lastName || !email || !zipCode || !state || !area)) {
(!firstName || !lastName || !email || !zipCode || !state || !area) return res.status(400).json({ error: 'All fields are required for initial profile creation.' });
) {
return res.status(400).json({
error: 'All fields are required for initial profile creation.',
});
} }
const finalAnswers = const finalAnswers = (interest_inventory_answers !== undefined)
interest_inventory_answers !== undefined ? interest_inventory_answers
? interest_inventory_answers : existing?.interest_inventory_answers ?? null;
: existingRow?.interest_inventory_answers || null; const finalCareerPriorities = (career_priorities !== undefined)
? career_priorities
: existing?.career_priorities ?? null;
const finalCareerList = (career_list !== undefined)
? career_list
: existing?.career_list ?? null;
const finalUserName = (userName !== undefined)
? userName
: existing?.username ?? null;
const finalRiasec = riasec_scores
? JSON.stringify(riasec_scores)
: existing?.riasec_scores ?? null;
const finalCareerPriorities = // Normalize email and compute lookup iff email is provided (or keep existing)
career_priorities !== undefined const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } };
? career_priorities
: existingRow?.career_priorities || null;
const finalCareerList = const emailNorm = email
career_list !== undefined ? String(email).trim().toLowerCase()
? career_list : existing?.email ? safeDecrypt(existing.email) : null;
: existingRow?.career_list || null;
const finalUserName = const encEmail = email ? encrypt(emailNorm) : existing?.email;
userName !== undefined ? userName : existingRow?.username || null; const emailLookupVal = email ? emailLookup(emailNorm) : existing?.email_lookup ?? null;
const finalRiasec = riasec_scores
? JSON.stringify(riasec_scores)
: existingRow?.riasec_scores || null;
if (existingRow) {
const phoneFinal = (phone_e164 !== undefined) ? (phone_e164 || null) : (existing?.phone_e164 ?? null);
const smsOptFinal = (typeof sms_opt_in === 'boolean')
? (sms_opt_in ? 1 : 0)
: (existing?.sms_opt_in ?? 0);
if (existing) {
const updateQuery = ` const updateQuery = `
UPDATE user_profile UPDATE user_profile
SET SET username = ?,
username = ?, firstname = ?,
firstname = ?, lastname = ?,
lastname = ?, email = ?,
email = ?, email_lookup = ?,
zipcode = ?, zipcode = ?,
state = ?, state = ?,
area = ?, area = ?,
career_situation = ?, career_situation = ?,
interest_inventory_answers = ?, interest_inventory_answers = ?,
riasec_scores = ?, riasec_scores = ?,
career_priorities = ?, career_priorities = ?,
career_list = ? career_list = ?,
WHERE id = ? phone_e164 = ?,
sms_opt_in = ?
WHERE id = ?
`; `;
const params = [ const params = [
finalUserName, finalUserName,
firstName || existingRow.firstname, firstName ?? existing.firstname,
lastName || existingRow.lastname, lastName ?? existing.lastname,
email || existingRow.email, encEmail,
zipCode || existingRow.zipcode, emailLookupVal,
state || existingRow.state, zipCode ?? existing.zipcode,
area || existingRow.area, state ?? existing.state,
careerSituation || existingRow.career_situation, area ?? existing.area,
careerSituation ?? existing.career_situation,
finalAnswers, finalAnswers,
finalRiasec, finalRiasec,
finalCareerPriorities, finalCareerPriorities,
finalCareerList, finalCareerList,
finalCareerList, phoneFinal,
phone_e164 ?? existingRow.phone_e164 ?? null, smsOptFinal,
typeof sms_opt_in === 'boolean' ? (sms_opt_in ? 1 : 0) : existingRow.sms_opt_in ?? 0,
profileId profileId
]; ];
await pool.query(updateQuery, params); await pool.query(updateQuery, params);
return res return res.status(200).json({ message: 'User profile updated successfully' });
.status(200)
.json({ message: 'User profile updated successfully' });
} else { } else {
// INSERT branch
const insertQuery = ` const insertQuery = `
INSERT INTO user_profile INSERT INTO user_profile
(id, username, firstname, lastname, email, zipcode, state, area, (id, username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, interest_inventory_answers, riasec_scores, career_situation, interest_inventory_answers, riasec_scores,
career_priorities, career_list) career_priorities, career_list, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?)
`; `;
const params = [ const params = [
profileId, profileId,
finalUserName, finalUserName,
firstName, firstName,
lastName, lastName,
email, encEmail, // <-- was emailNorm
emailLookupVal,
zipCode, zipCode,
state, state,
area, area,
careerSituation || null, careerSituation ?? null,
finalAnswers, finalAnswers,
finalRiasec, finalRiasec,
finalCareerPriorities, finalCareerPriorities,
finalCareerList, finalCareerList,
phone_e164 || null, phoneFinal,
sms_opt_in ? 1 : 0 smsOptFinal
]; ];
await pool.query(insertQuery, params); await pool.query(insertQuery, params);
return res return res.status(201).json({ message: 'User profile created successfully', id: profileId });
.status(201)
.json({ message: 'User profile created successfully', id: profileId });
} }
} catch (err) { } catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
console.error('Error upserting user profile:', err.message); console.error('Error upserting user profile:', err.message);
return res.status(500).json({ error: 'Internal server error' }); return res.status(500).json({ error: 'Internal server error' });
} }
@ -782,24 +853,20 @@ app.post('/api/user-profile', async (req, res) => {
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
FETCH USER PROFILE (MySQL) FETCH USER PROFILE (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
app.get('/api/user-profile', async (req, res) => { app.get('/api/user-profile', requireAuth, async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]; const profileId = req.userId; // from requireAuth middleware
if (!token) return res.status(401).json({ error: 'Authorization token is required' });
let profileId;
try {
const decoded = jwt.verify(token, JWT_SECRET);
profileId = decoded.id;
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
try { try {
const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]); const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]);
if (!results || results.length === 0) { if (!results || results.length === 0) {
return res.status(404).json({ error: 'User profile not found' }); return res.status(404).json({ error: 'User profile not found' });
} }
res.status(200).json(results[0]); const row = results[0];
if (row?.email) {
try { row.email = decrypt(row.email); } catch {}
}
res.status(200).json(row);
} catch (err) { } catch (err) {
console.error('Error fetching user profile:', err.message); console.error('Error fetching user profile:', err.message);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@ -856,18 +923,8 @@ const salaryDbPath =
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
PREMIUM UPGRADE ENDPOINT PREMIUM UPGRADE ENDPOINT
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
app.post('/api/activate-premium', async (req, res) => { app.post('/api/activate-premium', requireAuth, async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]; const profileId = req.userId;
if (!token) return res.status(401).json({ error: 'Authorization token is required' });
let profileId;
try {
const decoded = jwt.verify(token, JWT_SECRET);
profileId = decoded.id;
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
try { try {
await pool.query(` await pool.query(`
UPDATE user_profile UPDATE user_profile

View File

@ -11,6 +11,8 @@ import {
initEncryption initEncryption
} from '../crypto/encryption.js'; } from '../crypto/encryption.js';
const WRITE_RE = /^\s*(insert|update|replace)\s/i;
/* ── map of columns that must be protected ─────────────────── */ /* ── map of columns that must be protected ─────────────────── */
const TABLE_MAP = { const TABLE_MAP = {
user_profile : [ user_profile : [
@ -29,7 +31,8 @@ const TABLE_MAP = {
'planned_monthly_expenses','planned_monthly_debt_payments', 'planned_monthly_expenses','planned_monthly_debt_payments',
'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution', 'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution',
'planned_surplus_emergency_pct','planned_surplus_retirement_pct', 'planned_surplus_emergency_pct','planned_surplus_retirement_pct',
'planned_additional_income','career_goals','desired_retirement_income_monthly' 'planned_additional_income','career_goals','desired_retirement_income_monthly',
'career_name','start_date','retirement_start_date','scenario_title'
], ],
college_profiles : [ college_profiles : [
'selected_school','selected_program','annual_financial_aid', 'selected_school','selected_program','annual_financial_aid',
@ -37,9 +40,9 @@ const TABLE_MAP = {
'loan_term','interest_rate','extra_payment','expected_salary' 'loan_term','interest_rate','extra_payment','expected_salary'
], ],
milestones : ['title','description','date','progress'], milestones : ['title','description','date','progress'],
tasks : ['title','description','due_date','status'], tasks : ['title','description','due_date'],
reminders : ['phone_e164','message_body'], reminders : ['phone_e164','message_body'],
milestone_impacts : ['amount','impact_type'], milestone_impacts : ['amount','impact_type', 'direction'],
ai_risk_analysis : ['reasoning','risk_level'], ai_risk_analysis : ['reasoning','risk_level'],
ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'], ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'],
context_cache : ['ctx_text'] context_cache : ['ctx_text']
@ -73,49 +76,42 @@ function extractTables (sql) {
} }
function extractColumn(sql, paramIndex) { function extractColumn(sql, paramIndex) {
const normalized = sql.replace(/\s+/g, ' ').toLowerCase(); const s = sql.replace(/\s+/g, ' ').toLowerCase();
// INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...) // INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...)
if (normalized.includes('insert into')) { const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i);
const m = normalized.match(/\(\s*([^)]+?)\s*\)\s*values/i); if (ins) {
if (!m || !m[1]) { const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim());
console.warn(`[DAO] INSERT column extraction failed for param ${paramIndex}`); return cols[paramIndex] || null; // only VALUES params exist here
return null; }
// UPDATE t SET c1 = ?, c2 = ? WHERE ...
const upd = s.match(/update\s+[`\w]+\s+set\s+(.+?)(?:\s+where|\s*$)/i);
if (upd) {
const setPart = upd[1];
// Build a list of columns in the same order as the '?'s in SET only
const pairs = setPart.split(',').map(p => p.trim());
const colsForQs = [];
for (const p of pairs) {
const col = p.split('=')[0].replace(/`/g, '').trim();
const qCnt = (p.match(/\?/g) || []).length;
for (let i = 0; i < qCnt; i++) colsForQs.push(col);
} }
const colList = m[1].split(',').map(s => s.replace(/`/g, '').trim());
const col = colList[paramIndex] ?? null; const setQCount = colsForQs.length;
console.log(`[DAO] Param ${paramIndex} maps to column: ${col}`); if (paramIndex < setQCount) return colsForQs[paramIndex];
return col;
// params after SET (WHERE/LIMIT/etc.) → no mapping
return null;
} }
// UPDATE table SET col1 = ?, col2 = ? WHERE ... // SELECT/DELETE/etc. → we dont map WHERE/LIMIT params
if (normalized.includes('update')) {
const m = normalized.match(/set\s+(.*?)\s*(where|$)/);
if (!m || !m[1]) {
console.warn(`[DAO] UPDATE column extraction failed for param ${paramIndex}`);
return null;
}
const colList = m[1].split(',').map(s =>
s.split('=')[0].replace(/`/g, '').trim()
);
const col = colList[paramIndex] ?? null;
console.log(`[DAO] Param ${paramIndex} maps to column: ${col}`);
return col;
}
// SELECT ... WHERE col = ?
if (normalized.includes('where')) {
const m = normalized.match(/where\s+([a-z0-9_]+)\s*=/i);
const col = m?.[1]?.trim() ?? null;
console.log(`[DAO] Param ${paramIndex} maps to column (WHERE clause): ${col}`);
return col;
}
console.log(`[DAO] No column mapping for param ${paramIndex} — unsupported SQL`);
return null; return null;
} }
function decryptRow (row, tables) { function decryptRow (row, tables) {
for (const t of tables) { for (const t of tables) {
const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase())); const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase()));
@ -130,24 +126,28 @@ function decryptRow (row, tables) {
/* /*
Replacement for pool.execute / pool.query Replacement for pool.execute / pool.query
*/ */
export async function exec (sql, params = []) { export async function exec(sql, params = []) {
await ensureCryptoReady(); await ensureCryptoReady();
const tables = extractTables(sql); const isWrite = WRITE_RE.test(sql);
const encryptNeeded = (col) => tables.some(t => TABLE_MAP[t]?.includes(col)); const tables = extractTables(sql);
const encParams = params.map((v, i) => { const encryptNeeded = (col) =>
const col = extractColumn(sql, i); tables.some(t => (TABLE_MAP[t] || []).some(c =>
return (col && encryptNeeded(col) && v != null && !isEncrypted(v)) c.toLowerCase() === String(col || '').toLowerCase()
? encrypt(v) ));
: v;
}); const encParams = isWrite
? params.map((v, i) => {
const col = extractColumn(sql, i);
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
? encrypt(v)
: v;
})
: params; // SELECT path: never touch params
const [rows, fields] = await pool.execute(sql, encParams); const [rows, fields] = await pool.execute(sql, encParams);
if (Array.isArray(rows)) for (const row of rows) decryptRow(row, tables);
if (Array.isArray(rows)) {
for (const row of rows) decryptRow(row, tables);
}
return [rows, fields]; return [rows, fields];
} }

View File

@ -0,0 +1,42 @@
// shared/auth/requireAuth.js
import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js';
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
export async function requireAuth(req, res, next) {
try {
const authz = req.headers.authorization || '';
const token = authz.startsWith('Bearer ') ? authz.slice(7) : '';
if (!token) return res.status(401).json({ error: 'Auth required' });
let payload;
try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
const userId = payload.id;
const iatMs = (payload.iat || 0) * 1000;
// Absolute max token age (optional, off by default)
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' });
}
// Reject tokens issued before last password change
const [rows] = await (pool.raw || pool).query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId]
);
const changedAt = rows?.[0]?.password_changed_at || 0;
if (changedAt && iatMs < changedAt) {
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
}
req.userId = userId;
next();
} catch (e) {
console.error('[requireAuth]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
}

View File

@ -0,0 +1,58 @@
// backfill email_lookup from user_profile.email (decrypt if needed)
import dotenv from 'dotenv';
dotenv.config(); // only used if you run outside compose
import crypto from 'crypto';
import mysqlPool from '../config/mysqlPool.js';
import { initEncryption, decrypt } from '../shared/crypto/encryption.js';
const sql = mysqlPool.raw || mysqlPool;
const EMAIL_INDEX_KEY =
process.env.EMAIL_INDEX_SECRET || process.env.JWT_SECRET || 'dev-fallback';
function emailLookup(s) {
return crypto
.createHmac('sha256', EMAIL_INDEX_KEY)
.update(String(s).trim().toLowerCase())
.digest('hex');
}
async function run() {
await initEncryption(); // needed if some emails are gcm:… encrypted
const [rows] = await sql.query('SELECT id, email FROM user_profile');
let updated = 0, skipped = 0, failed = 0;
for (const r of rows) {
try {
let plain = r.email || '';
if (plain && plain.startsWith('gcm:')) {
// decrypt returns plaintext email
plain = await decrypt(plain);
}
if (!plain) { skipped++; continue; }
const lookup = emailLookup(plain);
await sql.query(
'UPDATE user_profile SET email_lookup = ? WHERE id = ? LIMIT 1',
[lookup, r.id]
);
updated++;
} catch (e) {
failed++;
console.error('[backfill]', r.id, e?.message || e);
}
}
console.log(`✅ backfill complete updated=${updated} skipped=${skipped} failed=${failed}`);
}
// Allow toplevel await in ESM; otherwise wrap:
run().then(() => process.exit(0)).catch(err => {
console.error(err);
process.exit(1);
});

View File

@ -24,7 +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 \ SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
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

@ -31,6 +31,7 @@ services:
expose: ["${SERVER1_PORT}"] expose: ["${SERVER1_PORT}"]
environment: environment:
ENV_NAME: ${ENV_NAME} ENV_NAME: ${ENV_NAME}
APTIVA_API_BASE: ${APTIVA_API_BASE}
PROJECT: ${PROJECT} PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
@ -45,6 +46,7 @@ services:
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} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
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:
@ -91,6 +93,7 @@ services:
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} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
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

@ -26,22 +26,36 @@ ALTER TABLE financial_profiles
/* ───────────────────────── career_profiles ────────────────────── */ /* ───────────────────────── career_profiles ────────────────────── */
ALTER TABLE career_profiles ALTER TABLE career_profiles
MODIFY planned_monthly_expenses VARCHAR(128), MODIFY COLUMN career_name VARCHAR(255) NULL,
MODIFY planned_monthly_debt_payments VARCHAR(128), MODIFY COLUMN start_date VARCHAR(32) NULL,
MODIFY planned_monthly_retirement_contribution VARCHAR(128), MODIFY COLUMN retirement_start_date VARCHAR(32) NULL,
MODIFY planned_monthly_emergency_contribution VARCHAR(128), MODIFY COLUMN planned_monthly_expenses VARCHAR(128) NULL,
MODIFY planned_surplus_emergency_pct VARCHAR(64), MODIFY COLUMN planned_monthly_debt_payments VARCHAR(128) NULL,
MODIFY planned_surplus_retirement_pct VARCHAR(64), MODIFY COLUMN planned_monthly_retirement_contribution VARCHAR(128) NULL,
MODIFY planned_additional_income VARCHAR(128), MODIFY COLUMN planned_monthly_emergency_contribution VARCHAR(128) NULL,
MODIFY career_goals MEDIUMTEXT, MODIFY COLUMN planned_surplus_emergency_pct VARCHAR(128) NULL,
MODIFY desired_retirement_income_monthly VARCHAR(128); MODIFY COLUMN planned_surplus_retirement_pct VARCHAR(128) NULL,
MODIFY COLUMN planned_additional_income VARCHAR(128) NULL,
MODIFY COLUMN career_goals MEDIUMTEXT NULL,
MODIFY COLUMN desired_retirement_income_monthly VARCHAR(128) NULL,
MODIFY COLUMN scenario_title VARCHAR(255) NULL;
encryption_canary(id INTEGER PRIMARY KEY, value TEXT) encryption_canary(id INTEGER PRIMARY KEY, value TEXT)
/* ────────────────────────────────────────────────────────────────
college_profiles migrate for encrypted VARCHAR columns
ALTER TABLE college_profiles
Adjust index names below if SHOW INDEX tells you they differ */ MODIFY COLUMN selected_school VARCHAR(512) NULL,
MODIFY COLUMN selected_program VARCHAR(512) NULL,
MODIFY COLUMN annual_financial_aid VARCHAR(128) NULL,
MODIFY COLUMN existing_college_debt VARCHAR(128) NULL,
MODIFY COLUMN tuition VARCHAR(128) NULL,
MODIFY COLUMN tuition_paid VARCHAR(128) NULL,
MODIFY COLUMN loan_deferral_until_graduation VARCHAR(128) NULL,
MODIFY COLUMN loan_term VARCHAR(128) NULL,
MODIFY COLUMN interest_rate VARCHAR(128) NULL,
MODIFY COLUMN extra_payment VARCHAR(128) NULL,
MODIFY COLUMN expected_salary VARCHAR(128) NULL;
ALTER TABLE user_profile ALTER TABLE user_profile
ADD COLUMN stripe_customer_id_hash CHAR(64) NULL, ADD COLUMN stripe_customer_id_hash CHAR(64) NULL,
@ -109,18 +123,27 @@ CREATE INDEX idx_school_prog ON college_profiles (selected_school(191),
/* ───────────────────────── misc small tables ──────────────────── */ /* ───────────────────────── misc small tables ──────────────────── */
ALTER TABLE milestones ALTER TABLE milestones
MODIFY description MEDIUMTEXT; MODIFY COLUMN title VARCHAR(255) NULL,
MODIFY COLUMN description MEDIUMTEXT NULL,
MODIFY COLUMN date VARCHAR(32) NULL,
MODIFY COLUMN progress VARCHAR(16) NULL;
ALTER TABLE tasks ALTER TABLE tasks
MODIFY description MEDIUMTEXT; MODIFY COLUMN title VARCHAR(255) NULL,
MODIFY COLUMN description MEDIUMTEXT NULL,
MODIFY COLUMN due_date VARCHAR(32) NULL;
ALTER TABLE reminders ALTER TABLE reminders
MODIFY phone_e164 VARCHAR(128), MODIFY COLUMN phone_e164 VARCHAR(128) NULL,
MODIFY message_body MEDIUMTEXT; MODIFY COLUMN message_body MEDIUMTEXT NULL;
ALTER TABLE milestone_impacts ALTER TABLE milestone_impacts
MODIFY amount VARCHAR(128), MODIFY COLUMN impact_type VARCHAR(64) NULL,
MODIFY impact_type VARCHAR(64); MODIFY COLUMN direction VARCHAR(32) NULL,
MODIFY COLUMN amount VARCHAR(128) NULL;
ALTER TABLE user_profile
ADD UNIQUE KEY ux_email_lookup (email_lookup);
ALTER TABLE ai_risk_analysis ALTER TABLE ai_risk_analysis
MODIFY reasoning MEDIUMTEXT, MODIFY reasoning MEDIUMTEXT,
@ -136,3 +159,23 @@ ALTER TABLE ai_suggested_milestones
ALTER TABLE context_cache ALTER TABLE context_cache
MODIFY ctx_text MEDIUMTEXT; MODIFY ctx_text MEDIUMTEXT;
ALTER TABLE user_profile
ADD COLUMN email_lookup CHAR(64) NOT NULL DEFAULT '' AFTER email;
CREATE INDEX idx_user_profile_email_lookup
ON user_profile (email_lookup);
CREATE INDEX idx_password_resets_token_hash ON password_resets (token_hash);
ALTER TABLE user_auth
ADD COLUMN password_changed_at BIGINT UNSIGNED NULL AFTER hashed_password;
-- Optional but useful:
CREATE INDEX ix_user_auth_userid_changedat ON user_auth (user_id, password_changed_at);
UPDATE user_auth
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
WHERE user_id = ?

View File

@ -41,12 +41,27 @@ import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js'; import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js'; import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js'; import ResetPassword from './components/ResetPassword.js';
import { clearToken } from '../auth/authMemory.js';
export const ProfileCtx = React.createContext(); export const ProfileCtx = React.createContext();
function ResetPasswordGate() {
const location = useLocation();
useEffect(() => {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
// If you cache other auth-ish flags, clear them here too
} catch {}
// no navigate here; we want to render the reset UI
}, [location.pathname]);
return <ResetPassword />;
}
function App() { function App() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -161,7 +176,20 @@ const showPremiumCTA = !premiumPaths.some(p =>
// 1) Single Rehydrate UseEffect // 1) Single Rehydrate UseEffect
// ============================== // ==============================
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token'); // 🚫 Never hydrate auth while on the reset page
if (location.pathname.startsWith('/reset-password')) {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
const token = localStorage.getItem('token');
if (!token) { if (!token) {
// No token => not authenticated // No token => not authenticated
@ -194,7 +222,7 @@ const showPremiumCTA = !premiumPaths.some(p =>
// Either success or fail, we're done loading // Either success or fail, we're done loading
setIsLoading(false); setIsLoading(false);
}); });
}, [navigate]); }, [navigate, location.pathname]);
// ========================== // ==========================
// 2) Logout Handler + Modal // 2) Logout Handler + Modal
@ -548,6 +576,7 @@ const showPremiumCTA = !premiumPaths.some(p =>
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />} element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/> />
<Route path="/reset-password/:token" element={<ResetPasswordGate />} />
{/* Public (guest-only) routes */} {/* Public (guest-only) routes */}
<Route <Route
@ -576,10 +605,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />} element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />}
/> />
<Route
path="/reset-password/:token"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ResetPassword />}
/>
<Route path="/paywall" element={<Paywall />} /> <Route path="/paywall" element={<Paywall />} />

9
src/auth/apiFetch.js Normal file
View File

@ -0,0 +1,9 @@
// apiFetch.js
import { getToken } from './authMemory.js';
export async function apiFetch(input, init = {}) {
const headers = new Headers(init.headers || {});
const t = getToken();
if (t) headers.set('Authorization', `Bearer ${t}`);
return fetch(input, { ...init, headers });
}

14
src/auth/authMemory.js Normal file
View File

@ -0,0 +1,14 @@
// authMemory.js
let accessToken = '';
let expiresAt = 0; // ms epoch (optional)
export function setToken(token, expiresInSec) {
accessToken = token || '';
expiresAt = token && expiresInSec ? Date.now() + expiresInSec * 1000 : 0;
}
export function clearToken() { accessToken = ''; expiresAt = 0; }
export function getToken() {
if (!accessToken) return '';
if (expiresAt && Date.now() > expiresAt) return '';
return accessToken;
}

View File

@ -9,18 +9,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loadingRisk, setLoadingRisk] = useState(false); const [loadingRisk, setLoadingRisk] = useState(false);
const aiRisk = careerDetails?.aiRisk || null; const aiRisk = careerDetails?.aiRisk || null;
const fmt = (v) =>
typeof v === 'number'
? v.toLocaleString()
: (v ?? '—');
if (!careerDetails) {
return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center z-50">
<div className="bg-white rounded-lg shadow-lg p-6">
<p className="text-lg text-gray-700">Loading career details</p>
</div>
</div>
);
}
// Handle your normal careerDetails loading logic // Handle your normal careerDetails loading logic
if (careerDetails?.error) { if (careerDetails?.error) {
return ( return (
@ -281,15 +275,14 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</table> </table>
{/* Conditional disclaimer when AI risk is Moderate or High */} {/* Conditional disclaimer when AI risk is Moderate or High */}
{aiRisk.riskLevel && {(aiRisk?.riskLevel === 'Moderate' || aiRisk?.riskLevel === 'High') && (
(aiRisk.riskLevel === 'Moderate' || aiRisk.riskLevel === 'High') && ( <p className="text-sm text-red-600 mt-2">
<p className="text-sm text-red-600 mt-2"> Note: These 10year projections may change if AIdriven tools
Note: These 10year projections may change if AIdriven tools significantly affect {careerDetails.title} tasks. With a&nbsp;
significantly affect {careerDetails.title} tasks. With a&nbsp; <strong>{aiRisk?.riskLevel?.toLowerCase()}</strong> AI risk, its possible
<strong>{aiRisk.riskLevel.toLowerCase()}</strong> AI risk, its possible some responsibilities could be automated over time.
some responsibilities could be automated over time. </p>
</p> )}
)}
</div> </div>
)} )}
</div> </div>

View File

@ -21,7 +21,7 @@ import MilestonePanel from './MilestonePanel.js';
import MilestoneDrawer from './MilestoneDrawer.js'; import MilestoneDrawer from './MilestoneDrawer.js';
import MilestoneEditModal from './MilestoneEditModal.js'; import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js'; import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields from '../utils/getMissingFields.js'; import getMissingFields, { MISSING_LABELS } from '../utils/getMissingFields.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
@ -372,6 +372,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [impactsById, setImpactsById] = useState({}); // id → [impacts] const [impactsById, setImpactsById] = useState({}); // id → [impacts]
const [addingNewMilestone, setAddingNewMilestone] = useState(false); const [addingNewMilestone, setAddingNewMilestone] = useState(false);
const [showMissingBanner, setShowMissingBanner] = useState(false); const [showMissingBanner, setShowMissingBanner] = useState(false);
const [missingKeys, setMissingKeys] = useState([]);
// Config // Config
@ -393,7 +394,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const chat = useContext(ChatCtx) || {}; const chat = useContext(ChatCtx) || {};
const setChatSnapshot = chat?.setChatSnapshot; const setChatSnapshot = chat?.setChatSnapshot;
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
const reloadScenarioAndCollege = useCallback(async () => { const reloadScenarioAndCollege = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
@ -580,15 +581,11 @@ useEffect(() => {
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* 3) Missing-fields modal single authoritative effect * 3) Missing-fields modal single authoritative effect
* -----------------------------------------------------------------*/ * -----------------------------------------------------------------*/
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
useEffect(() => { useEffect(() => {
if (!dataReady || !careerProfileId) return; // wait for all rows if (!dataReady || !careerProfileId) return; // wait for all rows
/* run once per profileid ------------------------------------------------ */
if (modalGuard.current.checked) return;
modalGuard.current.checked = true;
/* derive once, local to this effect -------------------------------------- */ /* derive once, local to this effect -------------------------------------- */
const status = (scenarioRow?.college_enrollment_status || '').toLowerCase(); const status = (scenarioRow?.college_enrollment_status || '').toLowerCase();
const requireCollege = ['currently_enrolled','prospective_student','deferred'] const requireCollege = ['currently_enrolled','prospective_student','deferred']
@ -598,17 +595,9 @@ useEffect(() => {
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile }, { scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
{ requireCollegeData: requireCollege } { requireCollegeData: requireCollege }
); );
setMissingKeys(missing);
if (missing.length) { setShowMissingBanner(missing.length > 0);
/* if we arrived *directly* from onboarding we silently skip the banner }, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
once, but we still want the EditScenario modal to open */
if (modalGuard.current.skip) {
setShowEditModal(true);
} else {
setShowMissingBanner(true);
}
}
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
@ -784,7 +773,7 @@ useEffect(() => {
const refetchScenario = useCallback(async () => { const refetchScenario = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
const r = await authFetch('/api/premium/career-profile/${careerProfileId}'); const r = await authFetch(`/api/premium/career-profile/${careerProfileId}`);
if (r.ok) setScenarioRow(await r.json()); if (r.ok) setScenarioRow(await r.json());
}, [careerProfileId]); }, [careerProfileId]);
@ -920,6 +909,7 @@ useEffect(() => {
setSalaryLoading(false); setSalaryLoading(false);
} else { } else {
console.error('[Salary fetch]', res.status); console.error('[Salary fetch]', res.status);
setSalaryLoading(false);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e); if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
@ -954,6 +944,7 @@ useEffect(() => {
setEconLoading(false); setEconLoading(false);
} else { } else {
console.error('[Econ fetch]', res.status); console.error('[Econ fetch]', res.status);
setEconLoading(false);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e); if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
@ -1106,7 +1097,7 @@ if (allMilestones.length) {
annualFinancialAid: collegeData.annualFinancialAid, annualFinancialAid: collegeData.annualFinancialAid,
calculatedTuition: collegeData.calculatedTuition, calculatedTuition: collegeData.calculatedTuition,
extraPayment: collegeData.extraPayment, extraPayment: collegeData.extraPayment,
enrollmentDate: collegeProfile.enrollmentDate || null, enrollmentDate: collegeProfile.enrollment_Date || null,
inCollege: collegeData.inCollege, inCollege: collegeData.inCollege,
gradDate: collegeData.gradDate, gradDate: collegeData.gradDate,
programType: collegeData.programType, programType: collegeData.programType,
@ -1475,9 +1466,15 @@ const handleMilestonesCreated = useCallback(
{showMissingBanner && ( {showMissingBanner && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4"> <div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
<p className="text-sm text-gray-800"> <p className="text-sm text-gray-800">
We need a few basics (income, expenses, etc.) before we can show a full To run your full projection, please add:
projection.
</p> </p>
{!!missingKeys.length && (
<ul className="mt-2 ml-5 list-disc text-sm text-gray-800">
{missingKeys.map((k) => (
<li key={k}>{MISSING_LABELS[k] || k}</li>
))}
</ul>
)}
<Button <Button
className="mt-2" className="mt-2"
onClick={() => { setShowEditModal(true); setShowMissingBanner(false); }} onClick={() => { setShowEditModal(true); setShowMissingBanner(false); }}

View File

@ -1,20 +1,27 @@
import React, { useState } from 'react'; // /src/components/ChangePasswordForm.js
import React, { useState, useRef } from 'react';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { validatePassword, passwordHelp } from '../utils/passwordRules.ts';
function ChangePasswordForm() { function getToken() {
try { return localStorage.getItem('token') || ''; } catch { return ''; }
}
export default function ChangePasswordForm({ redirectOnSuccess = true }) {
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(null); // { type: 'ok' | 'err', text: string } const [msg, setMsg] = useState(null); // { type: 'ok'|'err', text: string }
const newPwRef = useRef(null);
function validate() { function validate() {
if (!currentPassword || !newPassword || !confirmPassword) { if (!currentPassword || !newPassword || !confirmPassword) {
return 'All fields are required.'; return 'All fields are required.';
} }
if (newPassword.length < 8) { const pwErr = validatePassword(newPassword);
return 'New password must be at least 8 characters.'; if (pwErr) return pwErr;
}
if (newPassword === currentPassword) { if (newPassword === currentPassword) {
return 'New password must be different from current password.'; return 'New password must be different from current password.';
} }
@ -24,19 +31,28 @@ function ChangePasswordForm() {
return null; return null;
} }
async function handleSubmit(e) { // Allow Enter to trigger submit even without a <form>
e.preventDefault(); function handleKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}
async function handleSubmit() {
setMsg(null); setMsg(null);
const err = validate(); const err = validate();
if (err) { if (err) {
setMsg({ type: 'err', text: err }); setMsg({ type: 'err', text: err });
// focus the new password field on validation failure
if (newPwRef.current) newPwRef.current.focus();
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const token = localStorage.getItem('token') || ''; const token = getToken();
const res = await fetch('/api/auth/password-change', { const res = await fetch('/api/auth/password-change', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -51,6 +67,18 @@ function ChangePasswordForm() {
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
// your backend invalidates existing JWTs when password changes;
// optionally send user to sign-in immediately
if (redirectOnSuccess) {
setTimeout(() => {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
window.location.replace('/signin');
}, 800);
}
} else { } else {
let detail = 'Failed to change password.'; let detail = 'Failed to change password.';
try { try {
@ -62,7 +90,7 @@ function ChangePasswordForm() {
if (res.status === 429) detail = 'Too many attempts. Please wait a bit and try again.'; if (res.status === 429) detail = 'Too many attempts. Please wait a bit and try again.';
setMsg({ type: 'err', text: detail }); setMsg({ type: 'err', text: detail });
} }
} catch (e) { } catch (_e) {
setMsg({ type: 'err', text: 'Network error. Please try again.' }); setMsg({ type: 'err', text: 'Network error. Please try again.' });
} finally { } finally {
setLoading(false); setLoading(false);
@ -75,6 +103,8 @@ function ChangePasswordForm() {
{msg && ( {msg && (
<div <div
role="status"
aria-live="polite"
className={ className={
msg.type === 'ok' 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-green-200 bg-green-50 px-3 py-2 text-sm text-green-800'
@ -85,7 +115,8 @@ function ChangePasswordForm() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> {/* IMPORTANT: no <form> here to avoid nested-form submit to /profile */}
<div className="space-y-4" onKeyDown={handleKeyDown}>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label> <label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input <input
@ -101,6 +132,7 @@ function ChangePasswordForm() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label> <label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input <input
ref={newPwRef}
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
@ -109,7 +141,7 @@ function ChangePasswordForm() {
required required
minLength={8} minLength={8}
/> />
<p className="mt-1 text-xs text-gray-500">At least 8 characters.</p> <p className="mt-1 text-xs text-gray-500">{passwordHelp}</p>
</div> </div>
<div> <div>
@ -127,16 +159,15 @@ function ChangePasswordForm() {
<div className="pt-2"> <div className="pt-2">
<Button <Button
type="submit" type="button"
onClick={handleSubmit}
disabled={loading} disabled={loading}
className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed" className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60"
> >
{loading ? 'Updating…' : 'Update Password'} {loading ? 'Updating…' : 'Update Password'}
</Button> </Button>
</div> </div>
</form> </div>
</div> </div>
); );
} }
export default ChangePasswordForm;

View File

@ -92,18 +92,40 @@ function EducationalProgramsPage() {
const { setChatSnapshot } = useContext(ChatCtx); const { setChatSnapshot } = useContext(ChatCtx);
function normalizeCipPrefix(code) {
if (code === undefined || code === null) return null;
let s = String(code).trim();
if (s.includes('.')) {
// ensure two digits before first dot (e.g. "4.0201" → "04.0201")
s = s.replace(/^(\d)\./, '0$1.');
// drop non-digits (remove dot) → "04.0201" → "040201"
s = s.replace(/\D/g, '');
} else {
// already digits-only ("040201", "0402", "402", 402, etc.)
s = s.replace(/\D/g, '');
}
// force 4-digit prefix (pad if too short, trim if too long)
return s.padStart(4, '0').slice(0, 4);
}
function normalizeCipList(arr) {
const list = Array.isArray(arr) ? arr : [arr];
// unique and non-empty
return [...new Set(list.map(normalizeCipPrefix).filter(Boolean))];
}
// If user picks a new career from CareerSearch // If user picks a new career from CareerSearch
const handleCareerSelected = (foundObj) => { const handleCareerSelected = (foundObj) => {
setCareerTitle(foundObj.title || ''); setCareerTitle(foundObj.title || '');
setSelectedCareer(foundObj); setSelectedCareer(foundObj);
localStorage.setItem('selectedCareer', JSON.stringify(foundObj)); localStorage.setItem('selectedCareer', JSON.stringify(foundObj));
let rawCips = Array.isArray(foundObj.cip_code) ? foundObj.cip_code : [foundObj.cip_code]; const cleaned = normalizeCipList(foundObj.cip_code);
setCipCodes(cleaned);
const cleanedCips = rawCips.map((code) => {
const codeStr = code.toString();
return codeStr.replace('.', '').slice(0, 4);
});
setCipCodes(cleanedCips);
setSocCode(foundObj.soc_code); setSocCode(foundObj.soc_code);
setShowSearch(false); setShowSearch(false);
}; };
@ -142,6 +164,7 @@ function EducationalProgramsPage() {
]; ];
} }
useEffect(() => { useEffect(() => {
if (!location.state) return; // nothing passed if (!location.state) return; // nothing passed
const { const {
@ -152,7 +175,7 @@ function EducationalProgramsPage() {
} = location.state; } = location.state;
if (newSoc) setSocCode(newSoc); if (newSoc) setSocCode(newSoc);
if (newCips.length) setCipCodes(newCips); if (newCips.length) setCipCodes(normalizeCipList(newCips));
if (newTitle) setCareerTitle(newTitle); if (newTitle) setCareerTitle(newTitle);
if (navCareer) setSelectedCareer(navCareer); if (navCareer) setSelectedCareer(navCareer);
/* if *any* career info arrived we dont need the search box */ /* if *any* career info arrived we dont need the search box */
@ -247,13 +270,9 @@ useEffect(() => {
setCareerTitle(parsed.title || ''); setCareerTitle(parsed.title || '');
// Re-set CIP code logic (like in handleCareerSelected) // Re-set CIP code logic (like in handleCareerSelected)
let rawCips = parsed.cip_code || []; setCipCodes(normalizeCipList(parsed.cip_code));
if (!Array.isArray(rawCips)) rawCips = [rawCips].filter(Boolean);
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
setCipCodes(cleanedCips);
setSocCode(parsed.soc_code); setSocCode(parsed.soc_code);
setShowSearch(false); setShowSearch(false);

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { validatePassword, passwordHelp } from '../utils/passwordRules.ts';
export default function ResetPassword() { export default function ResetPassword() {
const { token } = useParams(); const { token } = useParams();
@ -14,10 +15,8 @@ export default function ResetPassword() {
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!pw || pw.length < 8) { const pwErr = validatePassword(pw);
setError('Password must be at least 8 characters.'); if (pwErr) { setError(pwErr); return; }
return;
}
if (pw !== pw2) { if (pw !== pw2) {
setError('Passwords do not match.'); setError('Passwords do not match.');
return; return;
@ -30,6 +29,7 @@ export default function ResetPassword() {
body: JSON.stringify({ token, password: pw }) body: JSON.stringify({ token, password: pw })
}); });
if (!r.ok) { if (!r.ok) {
if (r.status === 429) throw new Error('Too many attempts. Please wait ~30 seconds and try again.');
const j = await r.json().catch(() => ({})); const j = await r.json().catch(() => ({}));
throw new Error(j.error || 'Reset failed'); throw new Error(j.error || 'Reset failed');
} }
@ -47,7 +47,17 @@ export default function ResetPassword() {
<h2 className="text-xl font-semibold mb-2">Password updated</h2> <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> <p className="text-sm text-gray-700">You can now sign in with your new password.</p>
<div className="mt-4"> <div className="mt-4">
<Button onClick={() => navigate('/signin')}>Go to Sign In</Button> <Button
onClick={() => {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
window.location.replace('/signin');
}}
>
Go to Sign In
</Button>
</div> </div>
</div> </div>
); );
@ -76,7 +86,9 @@ export default function ResetPassword() {
onChange={(e) => setPw(e.target.value)} onChange={(e) => setPw(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
required required
minLength={8}
/> />
<p className="mt-1 text-xs text-gray-500">{passwordHelp}</p>
</div> </div>
<div> <div>
<label className="block text-sm mb-1">Confirm password</label> <label className="block text-sm mb-1">Confirm password</label>
@ -87,6 +99,7 @@ export default function ResetPassword() {
onChange={(e) => setPw2(e.target.value)} onChange={(e) => setPw2(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
required required
minLength={8}
/> />
</div> </div>
{error && <p className="text-red-600 text-xs">{error}</p>} {error && <p className="text-red-600 text-xs">{error}</p>}

View File

@ -567,7 +567,7 @@ if (formData.retirement_start_date) {
/* ─── 5) close modal + tell parent to refetch ───────────── */ /* ─── 5) close modal + tell parent to refetch ───────────── */
onClose(true); // CareerRoadmaps onClose(true) triggers reload onClose(true); // CareerRoadmaps onClose(true) triggers reload
window.location.reload();
} catch (err) { } catch (err) {
console.error("handleSave", err); console.error("handleSave", err);
alert(err.message || "Failed to save scenario"); alert(err.message || "Failed to save scenario");

View File

@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect, useContext } from 'react'; import React, { useRef, useState, useEffect, useContext } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { ProfileCtx } from '../App.js'; import { ProfileCtx } from '../App.js';
import { setToken } from '../auth/authMemory.js';
function SignIn({ setIsAuthenticated, setUser }) { function SignIn({ setIsAuthenticated, setUser }) {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import SituationCard from './ui/SituationCard.js'; import SituationCard from './ui/SituationCard.js';
import PromptModal from './ui/PromptModal.js'; import PromptModal from './ui/PromptModal.js';
import { validatePassword, passwordHelp } from '../utils/passwordRules.ts';
const careerSituations = [ const careerSituations = [
@ -138,8 +139,9 @@ function SignUp() {
return false; return false;
} }
if (!passwordRegex.test(password)) { const pwErr = validatePassword(password);
setError('Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character.'); if (pwErr) {
setError(pwErr);
return false; return false;
} }
@ -316,7 +318,7 @@ return (
checked={optIn} checked={optIn}
onChange={e => setOptIn(e.target.checked)} onChange={e => setOptIn(e.target.checked)}
/> />
I agree to receive SMS reminders I agree to receive occasional SMS reminders and updates related to my career plan. Message & data rates may apply. Uncheck anytime from Profile:Account to opt out.
</label> </label>
<input <input

View File

@ -13,6 +13,9 @@ function UserProfile() {
const [careerSituation, setCareerSituation] = useState(''); const [careerSituation, setCareerSituation] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false); const [loadingAreas, setLoadingAreas] = useState(false);
const [isPremiumUser, setIsPremiumUser] = useState(false); const [isPremiumUser, setIsPremiumUser] = useState(false);
const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false);
const [showChangePw, setShowChangePw] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
@ -63,6 +66,8 @@ function UserProfile() {
setSelectedState(data.state || ''); setSelectedState(data.state || '');
setSelectedArea(data.area || ''); setSelectedArea(data.area || '');
setCareerSituation(data.career_situation || ''); setCareerSituation(data.career_situation || '');
setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in);
if (data.is_premium === 1) { if (data.is_premium === 1) {
setIsPremiumUser(true); setIsPremiumUser(true);
@ -140,6 +145,8 @@ function UserProfile() {
state: selectedState, state: selectedState,
area: selectedArea, area: selectedArea,
careerSituation, careerSituation,
phone_e164: phoneE164 || null,
sms_opt_in: !!smsOptIn
}; };
try { try {
@ -307,6 +314,25 @@ function UserProfile() {
</div> </div>
)} )}
<div className="mt-4">
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
<input
type="tel"
placeholder="+15551234567"
value={phoneE164}
onChange={(e) => setPhoneE164(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none"
/>
<label className="mt-2 inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={smsOptIn}
onChange={(e) => setSmsOptIn(e.target.checked)}
/>
I agree to receive SMS updates.
</label>
</div>
{/* Career Situation */} {/* Career Situation */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">
@ -326,7 +352,21 @@ function UserProfile() {
</select> </select>
</div> </div>
<ChangePasswordForm /> <div className="mt-8">
<button
type="button"
onClick={() => setShowChangePw(s => !s)}
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
>
{showChangePw ? 'Cancel password change' : 'Change password'}
</button>
{showChangePw && (
<div className="mt-4">
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
</div>
)}
</div>
{/* 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">

View File

@ -5,6 +5,9 @@ import App from './App.js';
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
import reportWebVitals from './reportWebVitals.js'; import reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js'; import { PageFlagsProvider } from './utils/PageFlagsContext.js';
import { installStorageGuard } from './utils/storageGuard.js';
installStorageGuard(); // Initialize storage guard
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(

View File

@ -253,10 +253,7 @@ function simulateDrawdown(opts){
} = opts; } = opts;
let bal = startingBalance, m = 0; let bal = startingBalance, m = 0;
while (bal > 0 && m < 1200){ while (bal > 0 && m < 1200){
const r = getMonthlyInterestRate( const r = getMonthlyInterestRate();
interestStrategy, flatAnnualRate,
randomRangeMin, randomRangeMax,
monthlyReturnSamples);
bal = bal*(1+r) - monthlySpend; bal = bal*(1+r) - monthlySpend;
m++; m++;
} }
@ -286,6 +283,18 @@ function simulateDrawdown(opts){
); );
const finalProgramLength = programLength || dynamicProgramLength; const finalProgramLength = programLength || dynamicProgramLength;
const enrollmentStart = enrollmentDate
? moment(enrollmentDate).startOf('month')
: (startDate ? moment(startDate).startOf('month') : scenarioStartClamped.clone());
const creditsPerYear = creditHoursPerYear || 30;
const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted);
const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12);
const gradDateEffective = gradDate
? moment(gradDate).startOf('month')
: enrollmentStart.clone().add(monthsRemaining, 'months');
/*************************************************** /***************************************************
* 4) TUITION CALC * 4) TUITION CALC
***************************************************/ ***************************************************/
@ -328,7 +337,7 @@ function simulateDrawdown(opts){
* 6) SETUP FOR THE SIMULATION LOOP * 6) SETUP FOR THE SIMULATION LOOP
***************************************************/ ***************************************************/
const maxMonths = simulationYears * 12; const maxMonths = simulationYears * 12;
let loanBalance = Math.max(studentLoanAmount, 0); let loanBalance = initialLoanPrincipal;
let loanPaidOffMonth = null; let loanPaidOffMonth = null;
let currentEmergencySavings = emergencySavings; let currentEmergencySavings = emergencySavings;
@ -360,11 +369,6 @@ function simulateDrawdown(opts){
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
const hasGraduated =
isProgrammeActive &&
gradDate &&
currentSimDate.isSameOrAfter(moment(gradDate).startOf('month'));
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) { if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
reachedRetirement = true; reachedRetirement = true;
firstRetirementBalance = currentRetirementSavings; // capture once firstRetirementBalance = currentRetirementSavings; // capture once
@ -375,29 +379,26 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
currentSimDate.isSameOrAfter(retirementStartISO) currentSimDate.isSameOrAfter(retirementStartISO)
// figure out if we are in the college window // figure out if we are in the college window
let stillInCollege = false; const stillInCollege =
if (inCollege && enrollmentDateObj && graduationDateObj) { inCollege &&
stillInCollege = currentSimDate.isSameOrAfter(enrollmentDateObj) currentSimDate.isSameOrAfter(enrollmentStart) &&
&& currentSimDate.isBefore(graduationDateObj); currentSimDate.isBefore(gradDateEffective);
if (inCollege && gradDate) { const hasGraduated = currentSimDate.isSameOrAfter(gradDateEffective.clone().add(1, 'month'));
stillInCollege =
currentSimDate.isSameOrAfter(enrollmentDateObj) &&
currentSimDate.isBefore(graduationDateObj);
}
}
/************************************************ /************************************************
* 7.1 TUITION lumps * 7.1 TUITION lumps
************************************************/ ************************************************/
let tuitionCostThisMonth = 0; let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) { if (stillInCollege && lumpsPerYear > 0) {
const academicYearIndex = Math.floor(monthIndex / 12); const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
const monthInYear = monthIndex % 12; const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) { const monthInAcadYear = monthsSinceEnroll % 12;
tuitionCostThisMonth = lumpAmount; if (lumpsSchedule.includes(monthInAcadYear) && academicYearIndex < finalProgramLength) {
} tuitionCostThisMonth = lumpAmount;
} }
}
// If deferring tuition => add to loan, no direct expense // If deferring tuition => add to loan, no direct expense
if (stillInCollege && loanDeferralUntilGraduation && tuitionCostThisMonth > 0) { if (stillInCollege && loanDeferralUntilGraduation && tuitionCostThisMonth > 0) {
@ -410,24 +411,15 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
************************************************/ ************************************************/
let baseMonthlyIncome = 0; let baseMonthlyIncome = 0;
if (reachedRetirement) { if (reachedRetirement) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings); const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal; currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal; baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) { } else if (!stillInCollege) {
// Use expectedSalary **only** once the user has graduated const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
const monthlyFromJob = ( baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12);
hasGraduated && expectedSalary > 0 } else {
? expectedSalary // kicks in the month *after* gradDate baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
: currentSalary
) / 12;
baseMonthlyIncome =
monthlyFromJob + (additionalIncome / 12);
} else { // stillInCollege branch
baseMonthlyIncome =
(currentSalary / 12) + (additionalIncome / 12);
} }
/************************************************ /************************************************
@ -495,16 +487,13 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
* 7.5 WITHDRAW FROM RETIREMENT * 7.5 WITHDRAW FROM RETIREMENT
************************************************/ ************************************************/
if (reachedRetirement && retirementSpendMonthly > 0) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
// Treat the withdrawal like (taxable) income so the cash-flow works out
baseMonthlyIncome += withdrawal;
}
// From here on well use `livingCost` instead of `monthlyExpenses` // From here on well use `livingCost` instead of `monthlyExpenses`
const livingCost = reachedRetirement ? retirementSpendMonthly : monthlyExpenses; const livingCost = reachedRetirement ? retirementSpendMonthly : monthlyExpenses;
// Leaving deferral → begin repayment using current balance
if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) {
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm);
}
/************************************************ /************************************************
* 7.6 LOAN + EXPENSES * 7.6 LOAN + EXPENSES
************************************************/ ************************************************/
@ -527,6 +516,11 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
} }
} }
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
}
let leftover = netMonthlyIncome - totalMonthlyExpenses; let leftover = netMonthlyIncome - totalMonthlyExpenses;
const canSaveThisMonth = leftover > 0; const canSaveThisMonth = leftover > 0;
@ -543,6 +537,8 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
if (leftover >= baselineContributions) { if (leftover >= baselineContributions) {
effectiveRetirementContribution = monthlyRetContrib; effectiveRetirementContribution = monthlyRetContrib;
effectiveEmergencyContribution = monthlyEmergContrib; effectiveEmergencyContribution = monthlyEmergContrib;
currentRetirementSavings += effectiveRetirementContribution;
currentEmergencySavings += effectiveEmergencyContribution;
leftover -= baselineContributions; leftover -= baselineContributions;
} }
@ -567,7 +563,7 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
} }
const monthlyReturnRate = getMonthlyInterestRate(); const monthlyReturnRate = getMonthlyInterestRate();
if (monthlyReturnRate !== 0 && leftover > 0) { if (monthlyReturnRate !== 0) {
currentRetirementSavings *= (1 + monthlyReturnRate); currentRetirementSavings *= (1 + monthlyReturnRate);
} }
const netSavings = netMonthlyIncome - actualExpensesPaid; const netSavings = netMonthlyIncome - actualExpensesPaid;
@ -617,11 +613,6 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
firstRetirementBalance = currentRetirementSavings; firstRetirementBalance = currentRetirementSavings;
} }
// final loanPaidOffMonth if never set
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
}
/* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */ /* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */
const monthlySpend = retirementSpendMonthly || 0; const monthlySpend = retirementSpendMonthly || 0;

View File

@ -1,74 +1,95 @@
// utils/getMissingFields.js
// Treat 0 / "0" as present; only null/undefined/"" are missing.
export const hasValue = (v) => {
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim().length > 0;
return true; // numbers, booleans, objects…
};
// helper: pass if any of these have a value
const any = (...vals) => vals.some(hasValue);
// Humanreadable labels for the banner
export const MISSING_LABELS = {
start_date: 'Start date',
retirement_start_date: 'Planned retirement date',
current_salary: 'Current salary',
monthly_expenses: 'Monthly expenses (or scenario override)',
monthly_debt_payments: 'Monthly debt payments (or scenario override)',
savings_plan: 'Savings plan (either contributions or surplus %)',
tuition: 'Yearly tuition (can be 0)',
interest_rate: 'Loan interest rate',
loan_term: 'Loan term',
expected_graduation: 'Expected graduation date',
program_type: 'Program type',
academic_calendar: 'Academic calendar'
};
/** /**
* Identify which *critical* fields are still empty so the UI can * Return a list of *blocking* missing fields.
* decide whether to pop the Scenario-Edit modal. * Keep this minimal so the yellow banner only appears when
* * the projection truly cannot run.
* Scenario overrides (planned_ values) are **optional**
* College data is only required when the user is actually
* enrolled / planning to enrol.
*/ */
export default function getMissingFields( export default function getMissingFields(
{ scenario = {}, financial = {}, college = {} }, { scenario = {}, financial = {}, college = {} },
{ requireCollegeData = true } = {} { requireCollegeData = false } = {}
) { ) {
const missing = []; const missing = [];
/* ---------- 1 ▸ Scenario essentials ---------- */ // ── Scenario (only what the simulator truly needs)
const requiredScenario = [ if (!hasValue(scenario.start_date)) missing.push('start_date');
'career_name', if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date');
'scenario_title',
'start_date',
'status',
'currently_working',
'college_enrollment_status'
];
requiredScenario.forEach((f) => { // ── Financial base
if (!hasValue(scenario[f])) missing.push(f); if (!hasValue(financial.current_salary)) missing.push('current_salary');
});
/* ---------- 2 ▸ Financial profile ---------- */ if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) {
const requiredFin = [ missing.push('monthly_expenses');
'current_salary', }
'monthly_expenses', if (!any(scenario.planned_monthly_debt_payments, financial.monthly_debt_payments)) {
'monthly_debt_payments', missing.push('monthly_debt_payments');
'emergency_fund', }
'retirement_savings'
];
requiredFin.forEach((f) => { // ── Savings plan: need at least one way to save
if (!hasValue(financial[f])) missing.push(f); const hasRetirementPlan = any(
}); scenario.planned_monthly_retirement_contribution,
financial.retirement_contribution,
scenario.planned_surplus_retirement_pct
);
const hasEmergencyPlan = any(
scenario.planned_monthly_emergency_contribution,
financial.emergency_contribution,
scenario.planned_surplus_emergency_pct
);
if (!(hasRetirementPlan || hasEmergencyPlan)) {
missing.push('savings_plan');
}
/* ---------- 3 ▸ College profile (conditional) ---------- */ // ── College only if theyre enrolled / prospective
if (requireCollegeData) { if (requireCollegeData) {
const requiredCol = [ if (!hasValue(college.tuition)) missing.push('tuition');
'selected_school',
'selected_program',
'program_type',
'academic_calendar',
'credit_hours_per_year',
'tuition', // can be auto-calcd, but still required
'interest_rate',
'loan_term',
'existing_college_debt',
'expected_graduation'
];
if (!Object.keys(college).length) { const plansToBorrow =
missing.push('collegeProfile'); (Number(college.existing_college_debt) || 0) > 0 ||
} else { (Number(college.tuition) || 0) > (Number(college.annual_financial_aid) || 0);
requiredCol.forEach((f) => {
if (!hasValue(college[f])) missing.push(f); if (plansToBorrow) {
}); if (!hasValue(college.interest_rate)) missing.push('interest_rate');
if (!hasValue(college.loan_term)) missing.push('loan_term');
if (college.loan_deferral_until_graduation && !hasValue(college.expected_graduation)) {
missing.push('expected_graduation');
}
}
// Only insist on these if theyve actually chosen a school/program
const hasSchoolOrProgram = any(college.selected_school, college.selected_program);
if (hasSchoolOrProgram) {
if (!hasValue(college.program_type)) missing.push('program_type');
if (!hasValue(college.academic_calendar)) missing.push('academic_calendar');
} }
} }
return missing; return missing;
} }
/* ==== helpers ========================================================== */
function hasValue(v) {
if (v === null || v === undefined) return false;
if (typeof v === 'string') return v.trim() !== '';
return true; // numbers, booleans, etc.
}

View File

@ -0,0 +1,11 @@
export const passwordRegex =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
export const passwordHelp =
'Must be 8+ characters and include uppercase, lowercase, a number, and a special character (!@#$%^&*).';
export function validatePassword(pw: string): string | null {
if (!pw) return 'Password is required.';
if (!passwordRegex.test(pw)) return passwordHelp;
return null;
}

21
src/utils/safeLocal.js Normal file
View File

@ -0,0 +1,21 @@
// safeLocal.js
const NS = 'aptiva:';
const ALLOW = {
theme: 30*24*3600*1000, // 30d
layoutMode: 30*24*3600*1000, // 30d
lastPanel: 24*3600*1000, // 1d
flagsVersion: 6*3600*1000, // 6h
lastCareerName: 24*3600*1000, // 1d
};
export function setItem(key, value) {
if (!(key in ALLOW)) throw new Error(`[safeLocal] Not allowed: ${key}`);
const exp = Date.now() + (ALLOW[key] || 0);
localStorage.setItem(NS + key, JSON.stringify({ v: value, e: exp }));
}
export function getItem(key) {
if (!(key in ALLOW)) return null;
const raw = localStorage.getItem(NS + key); if (!raw) return null;
try { const { v, e } = JSON.parse(raw); if (e && e < Date.now()) { localStorage.removeItem(NS + key); return null; } return v; }
catch { localStorage.removeItem(NS + key); return null; }
}
export function removeItem(key) { localStorage.removeItem(NS + key); }

23
src/utils/storageGuard.js Normal file
View File

@ -0,0 +1,23 @@
// storageGuard.js
const RESTRICTED_SUBSTRINGS = [
'token','access','refresh','userid','user_id','user','profile','email','phone',
'answers','interest','riasec','salary','ssn','auth'
];
function shouldBlock(key) {
const k = String(key || '').toLowerCase();
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
}
function wrap(storage) {
if (!storage) return;
const _set = storage.setItem.bind(storage);
storage.setItem = (k, v) => {
if (shouldBlock(k)) {
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
}
return _set(k, v);
};
}
export function installStorageGuard() {
try { wrap(window.localStorage); } catch {}
try { wrap(window.sessionStorage); } catch {}
}