Password resets - Signin and UserProfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
ed1fdbbba6
commit
fb2e0522d3
2
.env
2
.env
@ -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
|
@ -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 profile’s 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 requireAuth’s 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
|
||||
|
@ -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 don’t 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];
|
||||
}
|
||||
|
||||
|
42
backend/shared/requireAuth.js
Normal file
42
backend/shared/requireAuth.js
Normal 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' });
|
||||
}
|
||||
}
|
58
backend/utils/email_lookup_backfill.js
Normal file
58
backend/utils/email_lookup_backfill.js
Normal 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 top‑level await in ESM; otherwise wrap:
|
||||
run().then(() => process.exit(0)).catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
@ -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
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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 = ?
|
||||
|
||||
|
37
src/App.js
37
src/App.js
@ -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
9
src/auth/apiFetch.js
Normal 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
14
src/auth/authMemory.js
Normal 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;
|
||||
}
|
@ -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 10‑year projections may change if AI‑driven tools
|
||||
significantly affect {careerDetails.title} tasks. With a
|
||||
<strong>{aiRisk.riskLevel.toLowerCase()}</strong> AI risk, it’s 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 10‑year projections may change if AI‑driven tools
|
||||
significantly affect {careerDetails.title} tasks. With a
|
||||
<strong>{aiRisk?.riskLevel?.toLowerCase()}</strong> AI risk, it’s possible
|
||||
some responsibilities could be automated over time.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -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 profile‑id ------------------------------------------------ */
|
||||
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 Edit‑Scenario 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); }}
|
||||
|
@ -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;
|
||||
|
@ -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 don’t 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);
|
||||
|
@ -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>}
|
||||
|
@ -567,7 +567,7 @@ if (formData.retirement_start_date) {
|
||||
|
||||
/* ─── 5) close modal + tell parent to refetch ───────────── */
|
||||
onClose(true); // CareerRoadmap’s onClose(true) triggers reload
|
||||
window.location.reload();
|
||||
|
||||
} catch (err) {
|
||||
console.error("handleSave", err);
|
||||
alert(err.message || "Failed to save scenario");
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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(
|
||||
|
@ -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 we’ll 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;
|
||||
|
@ -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);
|
||||
|
||||
// Human‑readable 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 they’re enrolled / prospective
|
||||
if (requireCollegeData) {
|
||||
const requiredCol = [
|
||||
'selected_school',
|
||||
'selected_program',
|
||||
'program_type',
|
||||
'academic_calendar',
|
||||
'credit_hours_per_year',
|
||||
'tuition', // can be auto-calc’d, 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 they’ve 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.
|
||||
}
|
||||
|
11
src/utils/passwordRules.ts
Normal file
11
src/utils/passwordRules.ts
Normal 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
21
src/utils/safeLocal.js
Normal 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
23
src/utils/storageGuard.js
Normal 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 {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user