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
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=bea8671-202508111402
IMG_TAG=ed1fdbb-202508121553
ENV_NAME=dev
PROJECT=aptivaai-dev

View File

@ -14,6 +14,7 @@ import crypto from 'crypto';
import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
const CANARY_SQL = `
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);
});
// Password reset token table (MySQL)
try {
const db = pool.raw || pool;
@ -247,13 +249,33 @@ app.use((req, res, next) => {
next();
});
const pwBurstLimiter = rateLimit({
windowMs: 30 * 1000, // 1 every 30s
// keep tight on request
const pwRequestLimiter = rateLimit({
windowMs: 30 * 1000,
max: 1,
standardHeaders: true,
legacyHeaders: false,
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({
windowMs: 24 * 60 * 60 * 1000, // per day
max: 5,
@ -263,21 +285,33 @@ const pwDailyLimiter = rateLimit({
});
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
SET ua.hashed_password = ?, ua.password_changed_at = ?
WHERE up.email_lookup = ?
`;
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;
}
// ---- 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) -----
const RESET_CONFIG = {
// 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
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');
}
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)
app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, 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 now = Date.now();
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
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 (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid payload' });
}
if (!PASSWORD_REGEX.test(newPassword)) {
return res.status(400).json({ error: PASSWORD_HELP });
}
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',
const db = pool.raw || pool;
const [rows] = await db.query(
'SELECT hashed_password FROM user_auth WHERE user_id = ? ORDER BY id DESC 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]
// 🔧 store epoch ms to match requireAuths comparison
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 });
} catch (e) {
@ -353,7 +388,7 @@ app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
/*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 {
const email = String(req.body?.email || '').trim().toLowerCase();
// 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)
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]);
const [rows] = await (pool.raw || pool).query(
`SELECT ua.user_id
FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id
WHERE up.email_lookup = ?
LIMIT 1`,
[emailLookup(email)]
);
exists = !!rows?.length;
} catch { /* ignore */ }
// 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 {
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' });
// 1) Validate input + password policy
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 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
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
ORDER BY id DESC LIMIT 1`,
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
ORDER BY id DESC LIMIT 1`,
[tokenHash, now]
);
const tok = tokRows?.[0];
if (!tok) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const row = rows?.[0];
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
// 3) Find user by email_lookup (HMAC of normalized email)
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);
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]
const [userRows] = await db.query(
`SELECT ua.user_id, ua.hashed_password
FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id
WHERE up.email_lookup = ?
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 });
} catch (e) {
console.error('[password-reset/confirm]', e?.message || e);
if (e?.stack) console.error(e.stack);
return res.status(500).json({ error: 'Server error' });
}
});
/* ------------------------------------------------------------------
USER REGISTRATION (MySQL)
------------------------------------------------------------------ */
/**
* POST /api/register
* Body:
* username, password, firstname, lastname, email, zipcode, state, area, career_situation
*/
------------------------------------------------------------------ */
app.post('/api/register', async (req, res) => {
const {
username, password, firstname, lastname, email,
@ -486,44 +557,50 @@ app.post('/api/register', async (req, res) => {
try {
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
(username, firstname, lastname, email, zipcode, state, area, career_situation, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [resultProfile] = await pool.query(profileQuery, [
username, firstname, lastname, email, zipcode, state, area,
career_situation, phone_e164 || null, sms_opt_in ? 1 : 0
(username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
username, firstname, lastname, encEmail, emailLookupVal,
zipcode, state, area, career_situation || null,
phone_e164 || null, sms_opt_in ? 1 : 0
]);
const newProfileId = resultProfile.insertId;
const authQuery = `
INSERT INTO user_auth (user_id, username, hashed_password)
VALUES (?, ?, ?)
`;
const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
await pool.query(authQuery, [newProfileId, username, hashedPassword]);
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({
message: 'User registered successfully',
profileId: newProfileId,
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) {
// 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);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
SIGN-IN (MySQL)
------------------------------------------------------------------ */
@ -584,7 +661,11 @@ app.post('/api/signin', async (req, res) => {
FROM user_profile WHERE id = ?',
[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' });
@ -621,29 +702,9 @@ app.get('/api/check-username/:username', async (req, res) => {
/* ------------------------------------------------------------------
UPSERT USER PROFILE (MySQL)
------------------------------------------------------------------ */
/**
* POST /api/user-profile
* 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' });
}
------------------------------------------------------------------ */
app.post('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId; // from requireAuth middleware
const {
userName,
@ -663,116 +724,126 @@ app.post('/api/user-profile', async (req, res) => {
} = req.body;
try {
const [results] = await pool.query(
`SELECT * FROM user_profile WHERE id = ?`,
[profileId]
);
const existingRow = results.length > 0 ? results[0] : null;
const [rows] = await pool.query(`SELECT * FROM user_profile WHERE id = ?`, [profileId]);
const existing = rows[0];
if (
!existingRow &&
(!firstName || !lastName || !email || !zipCode || !state || !area)
) {
return res.status(400).json({
error: 'All fields are required for initial profile creation.',
});
if (!existing &&
(!firstName || !lastName || !email || !zipCode || !state || !area)) {
return res.status(400).json({ error: 'All fields are required for initial profile creation.' });
}
const finalAnswers =
interest_inventory_answers !== undefined
? interest_inventory_answers
: existingRow?.interest_inventory_answers || null;
const finalAnswers = (interest_inventory_answers !== undefined)
? interest_inventory_answers
: existing?.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 =
career_priorities !== undefined
? career_priorities
: existingRow?.career_priorities || null;
// Normalize email and compute lookup iff email is provided (or keep existing)
const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } };
const finalCareerList =
career_list !== undefined
? career_list
: existingRow?.career_list || null;
const emailNorm = email
? String(email).trim().toLowerCase()
: existing?.email ? safeDecrypt(existing.email) : null;
const finalUserName =
userName !== undefined ? userName : existingRow?.username || null;
const encEmail = email ? encrypt(emailNorm) : existing?.email;
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 = `
UPDATE user_profile
SET
username = ?,
firstname = ?,
lastname = ?,
email = ?,
zipcode = ?,
state = ?,
area = ?,
career_situation = ?,
interest_inventory_answers = ?,
riasec_scores = ?,
career_priorities = ?,
career_list = ?
WHERE id = ?
SET username = ?,
firstname = ?,
lastname = ?,
email = ?,
email_lookup = ?,
zipcode = ?,
state = ?,
area = ?,
career_situation = ?,
interest_inventory_answers = ?,
riasec_scores = ?,
career_priorities = ?,
career_list = ?,
phone_e164 = ?,
sms_opt_in = ?
WHERE id = ?
`;
const params = [
finalUserName,
firstName || existingRow.firstname,
lastName || existingRow.lastname,
email || existingRow.email,
zipCode || existingRow.zipcode,
state || existingRow.state,
area || existingRow.area,
careerSituation || existingRow.career_situation,
firstName ?? existing.firstname,
lastName ?? existing.lastname,
encEmail,
emailLookupVal,
zipCode ?? existing.zipcode,
state ?? existing.state,
area ?? existing.area,
careerSituation ?? existing.career_situation,
finalAnswers,
finalRiasec,
finalCareerPriorities,
finalCareerList,
finalCareerList,
phone_e164 ?? existingRow.phone_e164 ?? null,
typeof sms_opt_in === 'boolean' ? (sms_opt_in ? 1 : 0) : existingRow.sms_opt_in ?? 0,
phoneFinal,
smsOptFinal,
profileId
];
await pool.query(updateQuery, params);
return res
.status(200)
.json({ message: 'User profile updated successfully' });
return res.status(200).json({ message: 'User profile updated successfully' });
} else {
// INSERT branch
const insertQuery = `
INSERT INTO user_profile
(id, username, firstname, lastname, email, zipcode, state, area,
career_situation, interest_inventory_answers, riasec_scores,
career_priorities, career_list)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, interest_inventory_answers, riasec_scores,
career_priorities, career_list, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?)
`;
const params = [
profileId,
finalUserName,
firstName,
lastName,
email,
encEmail, // <-- was emailNorm
emailLookupVal,
zipCode,
state,
area,
careerSituation || null,
careerSituation ?? null,
finalAnswers,
finalRiasec,
finalCareerPriorities,
finalCareerList,
phone_e164 || null,
sms_opt_in ? 1 : 0
];
phoneFinal,
smsOptFinal
];
await pool.query(insertQuery, params);
return res
.status(201)
.json({ message: 'User profile created successfully', id: profileId });
return res.status(201).json({ message: 'User profile created successfully', id: profileId });
}
} 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);
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)
------------------------------------------------------------------ */
app.get('/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) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
app.get('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId; // from requireAuth middleware
try {
const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]);
if (!results || results.length === 0) {
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) {
console.error('Error fetching user profile:', err.message);
res.status(500).json({ error: 'Internal server error' });
@ -856,18 +923,8 @@ const salaryDbPath =
/* ------------------------------------------------------------------
PREMIUM UPGRADE ENDPOINT
------------------------------------------------------------------ */
app.post('/api/activate-premium', 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) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
app.post('/api/activate-premium', requireAuth, async (req, res) => {
const profileId = req.userId;
try {
await pool.query(`
UPDATE user_profile

View File

@ -11,6 +11,8 @@ import {
initEncryption
} from '../crypto/encryption.js';
const WRITE_RE = /^\s*(insert|update|replace)\s/i;
/* ── map of columns that must be protected ─────────────────── */
const TABLE_MAP = {
user_profile : [
@ -29,7 +31,8 @@ const TABLE_MAP = {
'planned_monthly_expenses','planned_monthly_debt_payments',
'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution',
'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 : [
'selected_school','selected_program','annual_financial_aid',
@ -37,9 +40,9 @@ const TABLE_MAP = {
'loan_term','interest_rate','extra_payment','expected_salary'
],
milestones : ['title','description','date','progress'],
tasks : ['title','description','due_date','status'],
tasks : ['title','description','due_date'],
reminders : ['phone_e164','message_body'],
milestone_impacts : ['amount','impact_type'],
milestone_impacts : ['amount','impact_type', 'direction'],
ai_risk_analysis : ['reasoning','risk_level'],
ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'],
context_cache : ['ctx_text']
@ -73,49 +76,42 @@ function extractTables (sql) {
}
function extractColumn(sql, paramIndex) {
const normalized = sql.replace(/\s+/g, ' ').toLowerCase();
const s = sql.replace(/\s+/g, ' ').toLowerCase();
// INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
if (normalized.includes('insert into')) {
const m = normalized.match(/\(\s*([^)]+?)\s*\)\s*values/i);
if (!m || !m[1]) {
console.warn(`[DAO] INSERT column extraction failed for param ${paramIndex}`);
return null;
// INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...)
const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i);
if (ins) {
const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim());
return cols[paramIndex] || null; // only VALUES params exist here
}
// 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;
console.log(`[DAO] Param ${paramIndex} maps to column: ${col}`);
return col;
const setQCount = colsForQs.length;
if (paramIndex < setQCount) return colsForQs[paramIndex];
// params after SET (WHERE/LIMIT/etc.) → no mapping
return null;
}
// UPDATE table SET col1 = ?, col2 = ? WHERE ...
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`);
// SELECT/DELETE/etc. → we dont map WHERE/LIMIT params
return null;
}
function decryptRow (row, tables) {
for (const t of tables) {
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
*/
export async function exec (sql, params = []) {
export async function exec(sql, params = []) {
await ensureCryptoReady();
const tables = extractTables(sql);
const encryptNeeded = (col) => tables.some(t => TABLE_MAP[t]?.includes(col));
const isWrite = WRITE_RE.test(sql);
const tables = extractTables(sql);
const encParams = params.map((v, i) => {
const col = extractColumn(sql, i);
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
? encrypt(v)
: v;
});
const encryptNeeded = (col) =>
tables.some(t => (TABLE_MAP[t] || []).some(c =>
c.toLowerCase() === String(col || '').toLowerCase()
));
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);
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];
}

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 \
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
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 \
KMS_KEY_NAME DEK_PATH
)

View File

@ -31,6 +31,7 @@ services:
expose: ["${SERVER1_PORT}"]
environment:
ENV_NAME: ${ENV_NAME}
APTIVA_API_BASE: ${APTIVA_API_BASE}
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH}
@ -45,6 +46,7 @@ services:
DB_SSL_CA: ${DB_SSL_CA}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
@ -91,6 +93,7 @@ services:
DB_SSL_CA: ${DB_SSL_CA}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:

View File

@ -26,22 +26,36 @@ ALTER TABLE financial_profiles
/* ───────────────────────── career_profiles ────────────────────── */
ALTER TABLE career_profiles
MODIFY planned_monthly_expenses VARCHAR(128),
MODIFY planned_monthly_debt_payments VARCHAR(128),
MODIFY planned_monthly_retirement_contribution VARCHAR(128),
MODIFY planned_monthly_emergency_contribution VARCHAR(128),
MODIFY planned_surplus_emergency_pct VARCHAR(64),
MODIFY planned_surplus_retirement_pct VARCHAR(64),
MODIFY planned_additional_income VARCHAR(128),
MODIFY career_goals MEDIUMTEXT,
MODIFY desired_retirement_income_monthly VARCHAR(128);
MODIFY COLUMN career_name VARCHAR(255) NULL,
MODIFY COLUMN start_date VARCHAR(32) NULL,
MODIFY COLUMN retirement_start_date VARCHAR(32) NULL,
MODIFY COLUMN planned_monthly_expenses VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_debt_payments VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_retirement_contribution VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_emergency_contribution VARCHAR(128) NULL,
MODIFY COLUMN planned_surplus_emergency_pct VARCHAR(128) NULL,
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)
/* ────────────────────────────────────────────────────────────────
college_profiles migrate for encrypted VARCHAR columns
Adjust index names below if SHOW INDEX tells you they differ */
ALTER TABLE college_profiles
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
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 ──────────────────── */
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
MODIFY description MEDIUMTEXT;
MODIFY COLUMN title VARCHAR(255) NULL,
MODIFY COLUMN description MEDIUMTEXT NULL,
MODIFY COLUMN due_date VARCHAR(32) NULL;
ALTER TABLE reminders
MODIFY phone_e164 VARCHAR(128),
MODIFY message_body MEDIUMTEXT;
MODIFY COLUMN phone_e164 VARCHAR(128) NULL,
MODIFY COLUMN message_body MEDIUMTEXT NULL;
ALTER TABLE milestone_impacts
MODIFY amount VARCHAR(128),
MODIFY impact_type VARCHAR(64);
MODIFY COLUMN impact_type VARCHAR(64) NULL,
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
MODIFY reasoning MEDIUMTEXT,
@ -136,3 +159,23 @@ ALTER TABLE ai_suggested_milestones
ALTER TABLE context_cache
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 ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js';
import { clearToken } from '../auth/authMemory.js';
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() {
const navigate = useNavigate();
@ -161,7 +176,20 @@ const showPremiumCTA = !premiumPaths.some(p =>
// 1) Single Rehydrate 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) {
// No token => not authenticated
@ -194,7 +222,7 @@ const showPremiumCTA = !premiumPaths.some(p =>
// Either success or fail, we're done loading
setIsLoading(false);
});
}, [navigate]);
}, [navigate, location.pathname]);
// ==========================
// 2) Logout Handler + Modal
@ -548,6 +576,7 @@ const showPremiumCTA = !premiumPaths.some(p =>
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/>
<Route path="/reset-password/:token" element={<ResetPasswordGate />} />
{/* Public (guest-only) routes */}
<Route
@ -576,10 +605,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
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 />} />

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 [loadingRisk, setLoadingRisk] = useState(false);
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
if (careerDetails?.error) {
return (
@ -281,15 +275,14 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</table>
{/* Conditional disclaimer when AI risk is Moderate or High */}
{aiRisk.riskLevel &&
(aiRisk.riskLevel === 'Moderate' || aiRisk.riskLevel === 'High') && (
<p className="text-sm text-red-600 mt-2">
Note: These 10year projections may change if AIdriven tools
significantly affect {careerDetails.title} tasks. With a&nbsp;
<strong>{aiRisk.riskLevel.toLowerCase()}</strong> AI risk, its possible
some responsibilities could be automated over time.
</p>
)}
{(aiRisk?.riskLevel === 'Moderate' || aiRisk?.riskLevel === 'High') && (
<p className="text-sm text-red-600 mt-2">
Note: These 10year projections may change if AIdriven tools
significantly affect {careerDetails.title} tasks. With a&nbsp;
<strong>{aiRisk?.riskLevel?.toLowerCase()}</strong> AI risk, its possible
some responsibilities could be automated over time.
</p>
)}
</div>
)}
</div>

View File

@ -21,7 +21,7 @@ import MilestonePanel from './MilestonePanel.js';
import MilestoneDrawer from './MilestoneDrawer.js';
import MilestoneEditModal from './MilestoneEditModal.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 authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
@ -372,6 +372,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [impactsById, setImpactsById] = useState({}); // id → [impacts]
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
const [showMissingBanner, setShowMissingBanner] = useState(false);
const [missingKeys, setMissingKeys] = useState([]);
// Config
@ -393,7 +394,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const chat = useContext(ChatCtx) || {};
const setChatSnapshot = chat?.setChatSnapshot;
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
const reloadScenarioAndCollege = useCallback(async () => {
if (!careerProfileId) return;
@ -580,15 +581,11 @@ useEffect(() => {
/* ------------------------------------------------------------------
* 3) Missing-fields modal single authoritative effect
* -----------------------------------------------------------------*/
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null;
useEffect(() => {
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 -------------------------------------- */
const status = (scenarioRow?.college_enrollment_status || '').toLowerCase();
const requireCollege = ['currently_enrolled','prospective_student','deferred']
@ -598,17 +595,9 @@ useEffect(() => {
{ scenario: scenarioRow, financial: financialProfile, college: collegeProfile },
{ requireCollegeData: requireCollege }
);
if (missing.length) {
/* if we arrived *directly* from onboarding we silently skip the banner
once, but we still want the EditScenario modal to open */
if (modalGuard.current.skip) {
setShowEditModal(true);
} else {
setShowMissingBanner(true);
}
}
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
setMissingKeys(missing);
setShowMissingBanner(missing.length > 0);
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
@ -784,7 +773,7 @@ useEffect(() => {
const refetchScenario = useCallback(async () => {
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());
}, [careerProfileId]);
@ -920,6 +909,7 @@ useEffect(() => {
setSalaryLoading(false);
} else {
console.error('[Salary fetch]', res.status);
setSalaryLoading(false);
}
} catch (e) {
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
@ -954,6 +944,7 @@ useEffect(() => {
setEconLoading(false);
} else {
console.error('[Econ fetch]', res.status);
setEconLoading(false);
}
} catch (e) {
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
@ -1106,7 +1097,7 @@ if (allMilestones.length) {
annualFinancialAid: collegeData.annualFinancialAid,
calculatedTuition: collegeData.calculatedTuition,
extraPayment: collegeData.extraPayment,
enrollmentDate: collegeProfile.enrollmentDate || null,
enrollmentDate: collegeProfile.enrollment_Date || null,
inCollege: collegeData.inCollege,
gradDate: collegeData.gradDate,
programType: collegeData.programType,
@ -1475,9 +1466,15 @@ const handleMilestonesCreated = useCallback(
{showMissingBanner && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 rounded shadow mb-4">
<p className="text-sm text-gray-800">
We need a few basics (income, expenses, etc.) before we can show a full
projection.
To run your full projection, please add:
</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
className="mt-2"
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 { 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 [newPassword, setNewPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(null); // { type: 'ok' | 'err', text: string }
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState(null); // { type: 'ok'|'err', text: string }
const newPwRef = useRef(null);
function validate() {
if (!currentPassword || !newPassword || !confirmPassword) {
return 'All fields are required.';
}
if (newPassword.length < 8) {
return 'New password must be at least 8 characters.';
}
const pwErr = validatePassword(newPassword);
if (pwErr) return pwErr;
if (newPassword === currentPassword) {
return 'New password must be different from current password.';
}
@ -24,19 +31,28 @@ function ChangePasswordForm() {
return null;
}
async function handleSubmit(e) {
e.preventDefault();
// Allow Enter to trigger submit even without a <form>
function handleKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}
async function handleSubmit() {
setMsg(null);
const err = validate();
if (err) {
setMsg({ type: 'err', text: err });
// focus the new password field on validation failure
if (newPwRef.current) newPwRef.current.focus();
return;
}
setLoading(true);
try {
const token = localStorage.getItem('token') || '';
const token = getToken();
const res = await fetch('/api/auth/password-change', {
method: 'POST',
headers: {
@ -51,6 +67,18 @@ function ChangePasswordForm() {
setCurrentPassword('');
setNewPassword('');
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 {
let detail = 'Failed to change password.';
try {
@ -62,7 +90,7 @@ function ChangePasswordForm() {
if (res.status === 429) detail = 'Too many attempts. Please wait a bit and try again.';
setMsg({ type: 'err', text: detail });
}
} catch (e) {
} catch (_e) {
setMsg({ type: 'err', text: 'Network error. Please try again.' });
} finally {
setLoading(false);
@ -75,6 +103,8 @@ function ChangePasswordForm() {
{msg && (
<div
role="status"
aria-live="polite"
className={
msg.type === 'ok'
? '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>
)}
<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>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
@ -101,6 +132,7 @@ function ChangePasswordForm() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
ref={newPwRef}
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
@ -109,7 +141,7 @@ function ChangePasswordForm() {
required
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>
@ -127,16 +159,15 @@ function ChangePasswordForm() {
<div className="pt-2">
<Button
type="submit"
type="button"
onClick={handleSubmit}
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'}
</Button>
</div>
</form>
</div>
</div>
);
}
export default ChangePasswordForm;

View File

@ -92,18 +92,40 @@ function EducationalProgramsPage() {
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
const handleCareerSelected = (foundObj) => {
setCareerTitle(foundObj.title || '');
setSelectedCareer(foundObj);
localStorage.setItem('selectedCareer', JSON.stringify(foundObj));
let rawCips = Array.isArray(foundObj.cip_code) ? foundObj.cip_code : [foundObj.cip_code];
const cleanedCips = rawCips.map((code) => {
const codeStr = code.toString();
return codeStr.replace('.', '').slice(0, 4);
});
setCipCodes(cleanedCips);
const cleaned = normalizeCipList(foundObj.cip_code);
setCipCodes(cleaned);
setSocCode(foundObj.soc_code);
setShowSearch(false);
};
@ -142,6 +164,7 @@ function EducationalProgramsPage() {
];
}
useEffect(() => {
if (!location.state) return; // nothing passed
const {
@ -152,7 +175,7 @@ function EducationalProgramsPage() {
} = location.state;
if (newSoc) setSocCode(newSoc);
if (newCips.length) setCipCodes(newCips);
if (newCips.length) setCipCodes(normalizeCipList(newCips));
if (newTitle) setCareerTitle(newTitle);
if (navCareer) setSelectedCareer(navCareer);
/* if *any* career info arrived we dont need the search box */
@ -247,13 +270,9 @@ useEffect(() => {
setCareerTitle(parsed.title || '');
// Re-set CIP code logic (like in handleCareerSelected)
let rawCips = parsed.cip_code || [];
if (!Array.isArray(rawCips)) rawCips = [rawCips].filter(Boolean);
setCipCodes(normalizeCipList(parsed.cip_code));
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
setCipCodes(cleanedCips);
setSocCode(parsed.soc_code);
setShowSearch(false);

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Button } from './ui/button.js';
import { validatePassword, passwordHelp } from '../utils/passwordRules.ts';
export default function ResetPassword() {
const { token } = useParams();
@ -14,10 +15,8 @@ export default function ResetPassword() {
async function onSubmit(e) {
e.preventDefault();
setError('');
if (!pw || pw.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
const pwErr = validatePassword(pw);
if (pwErr) { setError(pwErr); return; }
if (pw !== pw2) {
setError('Passwords do not match.');
return;
@ -30,6 +29,7 @@ export default function ResetPassword() {
body: JSON.stringify({ token, password: pw })
});
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(() => ({}));
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>
<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>
<Button
onClick={() => {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
window.location.replace('/signin');
}}
>
Go to Sign In
</Button>
</div>
</div>
);
@ -76,7 +86,9 @@ export default function ResetPassword() {
onChange={(e) => setPw(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
<p className="mt-1 text-xs text-gray-500">{passwordHelp}</p>
</div>
<div>
<label className="block text-sm mb-1">Confirm password</label>
@ -87,6 +99,7 @@ export default function ResetPassword() {
onChange={(e) => setPw2(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</div>
{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 ───────────── */
onClose(true); // CareerRoadmaps onClose(true) triggers reload
window.location.reload();
} catch (err) {
console.error("handleSave", err);
alert(err.message || "Failed to save scenario");

View File

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

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from './ui/button.js';
import SituationCard from './ui/SituationCard.js';
import PromptModal from './ui/PromptModal.js';
import { validatePassword, passwordHelp } from '../utils/passwordRules.ts';
const careerSituations = [
@ -138,8 +139,9 @@ function SignUp() {
return false;
}
if (!passwordRegex.test(password)) {
setError('Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character.');
const pwErr = validatePassword(password);
if (pwErr) {
setError(pwErr);
return false;
}
@ -316,7 +318,7 @@ return (
checked={optIn}
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>
<input

View File

@ -13,6 +13,9 @@ function UserProfile() {
const [careerSituation, setCareerSituation] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false);
const [isPremiumUser, setIsPremiumUser] = useState(false);
const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false);
const [showChangePw, setShowChangePw] = useState(false);
const navigate = useNavigate();
@ -63,6 +66,8 @@ function UserProfile() {
setSelectedState(data.state || '');
setSelectedArea(data.area || '');
setCareerSituation(data.career_situation || '');
setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in);
if (data.is_premium === 1) {
setIsPremiumUser(true);
@ -140,6 +145,8 @@ function UserProfile() {
state: selectedState,
area: selectedArea,
careerSituation,
phone_e164: phoneE164 || null,
sms_opt_in: !!smsOptIn
};
try {
@ -307,6 +314,25 @@ function UserProfile() {
</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 */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
@ -326,7 +352,21 @@ function UserProfile() {
</select>
</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 */}
<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 reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js';
import { installStorageGuard } from './utils/storageGuard.js';
installStorageGuard(); // Initialize storage guard
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(

View File

@ -253,10 +253,7 @@ function simulateDrawdown(opts){
} = opts;
let bal = startingBalance, m = 0;
while (bal > 0 && m < 1200){
const r = getMonthlyInterestRate(
interestStrategy, flatAnnualRate,
randomRangeMin, randomRangeMax,
monthlyReturnSamples);
const r = getMonthlyInterestRate();
bal = bal*(1+r) - monthlySpend;
m++;
}
@ -286,6 +283,18 @@ function simulateDrawdown(opts){
);
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
***************************************************/
@ -328,7 +337,7 @@ function simulateDrawdown(opts){
* 6) SETUP FOR THE SIMULATION LOOP
***************************************************/
const maxMonths = simulationYears * 12;
let loanBalance = Math.max(studentLoanAmount, 0);
let loanBalance = initialLoanPrincipal;
let loanPaidOffMonth = null;
let currentEmergencySavings = emergencySavings;
@ -360,11 +369,6 @@ function simulateDrawdown(opts){
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
const hasGraduated =
isProgrammeActive &&
gradDate &&
currentSimDate.isSameOrAfter(moment(gradDate).startOf('month'));
if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) {
reachedRetirement = true;
firstRetirementBalance = currentRetirementSavings; // capture once
@ -375,29 +379,26 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
currentSimDate.isSameOrAfter(retirementStartISO)
// figure out if we are in the college window
let stillInCollege = false;
if (inCollege && enrollmentDateObj && graduationDateObj) {
stillInCollege = currentSimDate.isSameOrAfter(enrollmentDateObj)
&& currentSimDate.isBefore(graduationDateObj);
const stillInCollege =
inCollege &&
currentSimDate.isSameOrAfter(enrollmentStart) &&
currentSimDate.isBefore(gradDateEffective);
if (inCollege && gradDate) {
stillInCollege =
currentSimDate.isSameOrAfter(enrollmentDateObj) &&
currentSimDate.isBefore(graduationDateObj);
}
}
const hasGraduated = currentSimDate.isSameOrAfter(gradDateEffective.clone().add(1, 'month'));
/************************************************
* 7.1 TUITION lumps
************************************************/
let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) {
const academicYearIndex = Math.floor(monthIndex / 12);
const monthInYear = monthIndex % 12;
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount;
}
}
if (stillInCollege && lumpsPerYear > 0) {
const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months'));
const academicYearIndex = Math.floor(monthsSinceEnroll / 12);
const monthInAcadYear = monthsSinceEnroll % 12;
if (lumpsSchedule.includes(monthInAcadYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount;
}
}
// If deferring tuition => add to loan, no direct expense
if (stillInCollege && loanDeferralUntilGraduation && tuitionCostThisMonth > 0) {
@ -410,24 +411,15 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
************************************************/
let baseMonthlyIncome = 0;
if (reachedRetirement) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) {
// Use expectedSalary **only** once the user has graduated
const monthlyFromJob = (
hasGraduated && expectedSalary > 0
? expectedSalary // kicks in the month *after* gradDate
: currentSalary
) / 12;
baseMonthlyIncome =
monthlyFromJob + (additionalIncome / 12);
} else { // stillInCollege branch
baseMonthlyIncome =
(currentSalary / 12) + (additionalIncome / 12);
if (reachedRetirement) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) {
const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12);
} else {
baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
}
/************************************************
@ -495,16 +487,13 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
* 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`
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
************************************************/
@ -527,6 +516,11 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
}
}
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
}
let leftover = netMonthlyIncome - totalMonthlyExpenses;
const canSaveThisMonth = leftover > 0;
@ -543,6 +537,8 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
if (leftover >= baselineContributions) {
effectiveRetirementContribution = monthlyRetContrib;
effectiveEmergencyContribution = monthlyEmergContrib;
currentRetirementSavings += effectiveRetirementContribution;
currentEmergencySavings += effectiveEmergencyContribution;
leftover -= baselineContributions;
}
@ -567,7 +563,7 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
}
const monthlyReturnRate = getMonthlyInterestRate();
if (monthlyReturnRate !== 0 && leftover > 0) {
if (monthlyReturnRate !== 0) {
currentRetirementSavings *= (1 + monthlyReturnRate);
}
const netSavings = netMonthlyIncome - actualExpensesPaid;
@ -617,11 +613,6 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
firstRetirementBalance = currentRetirementSavings;
}
// final loanPaidOffMonth if never set
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
}
/* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */
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
* decide whether to pop the Scenario-Edit modal.
*
* Scenario overrides (planned_ values) are **optional**
* College data is only required when the user is actually
* enrolled / planning to enrol.
* Return a list of *blocking* missing fields.
* Keep this minimal so the yellow banner only appears when
* the projection truly cannot run.
*/
export default function getMissingFields(
{ scenario = {}, financial = {}, college = {} },
{ requireCollegeData = true } = {}
{ requireCollegeData = false } = {}
) {
const missing = [];
/* ---------- 1 ▸ Scenario essentials ---------- */
const requiredScenario = [
'career_name',
'scenario_title',
'start_date',
'status',
'currently_working',
'college_enrollment_status'
];
// ── Scenario (only what the simulator truly needs)
if (!hasValue(scenario.start_date)) missing.push('start_date');
if (!hasValue(scenario.retirement_start_date)) missing.push('retirement_start_date');
requiredScenario.forEach((f) => {
if (!hasValue(scenario[f])) missing.push(f);
});
// ── Financial base
if (!hasValue(financial.current_salary)) missing.push('current_salary');
/* ---------- 2 ▸ Financial profile ---------- */
const requiredFin = [
'current_salary',
'monthly_expenses',
'monthly_debt_payments',
'emergency_fund',
'retirement_savings'
];
if (!any(scenario.planned_monthly_expenses, financial.monthly_expenses)) {
missing.push('monthly_expenses');
}
if (!any(scenario.planned_monthly_debt_payments, financial.monthly_debt_payments)) {
missing.push('monthly_debt_payments');
}
requiredFin.forEach((f) => {
if (!hasValue(financial[f])) missing.push(f);
});
// ── Savings plan: need at least one way to save
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) {
const requiredCol = [
'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 (!hasValue(college.tuition)) missing.push('tuition');
if (!Object.keys(college).length) {
missing.push('collegeProfile');
} else {
requiredCol.forEach((f) => {
if (!hasValue(college[f])) missing.push(f);
});
const plansToBorrow =
(Number(college.existing_college_debt) || 0) > 0 ||
(Number(college.tuition) || 0) > (Number(college.annual_financial_aid) || 0);
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;
}
/* ==== 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 {}
}