diff --git a/.env b/.env index e8af31e..1b8e344 100644 --- a/.env +++ b/.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 \ No newline at end of file diff --git a/backend/server1.js b/backend/server1.js index dd5ed51..7d576ae 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -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 } - * 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 diff --git a/backend/shared/db/withEncryption.js b/backend/shared/db/withEncryption.js index 6b5e8dd..1d2ae13 100644 --- a/backend/shared/db/withEncryption.js +++ b/backend/shared/db/withEncryption.js @@ -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]; } diff --git a/backend/shared/requireAuth.js b/backend/shared/requireAuth.js new file mode 100644 index 0000000..c78de68 --- /dev/null +++ b/backend/shared/requireAuth.js @@ -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' }); + } +} diff --git a/backend/utils/email_lookup_backfill.js b/backend/utils/email_lookup_backfill.js new file mode 100644 index 0000000..ed212c5 --- /dev/null +++ b/backend/utils/email_lookup_backfill.js @@ -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); +}); diff --git a/deploy_all.sh b/deploy_all.sh index 35efd11..bb857d1 100755 --- a/deploy_all.sh +++ b/deploy_all.sh @@ -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 ) diff --git a/docker-compose.yml b/docker-compose.yml index 1c9d6db..df2fa3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql index bf1de6a..a6d9b93 100644 --- a/migrate_encrypted_columns.sql +++ b/migrate_encrypted_columns.sql @@ -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 = ? + diff --git a/src/App.js b/src/App.js index 08a23b9..9e69c76 100644 --- a/src/App.js +++ b/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 ; +} + 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={} /> + } /> {/* Public (guest-only) routes */} element={isAuthenticated ? : } /> - : } - /> } /> diff --git a/src/auth/apiFetch.js b/src/auth/apiFetch.js new file mode 100644 index 0000000..9836fea --- /dev/null +++ b/src/auth/apiFetch.js @@ -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 }); +} \ No newline at end of file diff --git a/src/auth/authMemory.js b/src/auth/authMemory.js new file mode 100644 index 0000000..fd36957 --- /dev/null +++ b/src/auth/authMemory.js @@ -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; +} \ No newline at end of file diff --git a/src/components/CareerModal.js b/src/components/CareerModal.js index ef79dc2..4a56c01 100644 --- a/src/components/CareerModal.js +++ b/src/components/CareerModal.js @@ -9,17 +9,11 @@ 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 ( -
-
-

Loading career details…

-
-
- ); - } // Handle your normal careerDetails loading logic if (careerDetails?.error) { @@ -281,15 +275,14 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {/* Conditional disclaimer when AI risk is Moderate or High */} - {aiRisk.riskLevel && - (aiRisk.riskLevel === 'Moderate' || aiRisk.riskLevel === 'High') && ( -

- Note: These 10‑year projections may change if AI‑driven tools - significantly affect {careerDetails.title} tasks. With a  - {aiRisk.riskLevel.toLowerCase()} AI risk, it’s possible - some responsibilities could be automated over time. -

- )} + {(aiRisk?.riskLevel === 'Moderate' || aiRisk?.riskLevel === 'High') && ( +

+ Note: These 10‑year projections may change if AI‑driven tools + significantly affect {careerDetails.title} tasks. With a  + {aiRisk?.riskLevel?.toLowerCase()} AI risk, it’s possible + some responsibilities could be automated over time. +

+ )} )} diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 49ba3a2..2ed1344 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -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 && (

- We need a few basics (income, expenses, etc.) before we can show a full - projection. + To run your full projection, please add:

+ {!!missingKeys.length && ( +
    + {missingKeys.map((k) => ( +
  • {MISSING_LABELS[k] || k}
  • + ))} +
+ )}
- + ); } - -export default ChangePasswordForm; diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index 1f3571b..091c365 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -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,12 +270,8 @@ 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); diff --git a/src/components/ResetPassword.js b/src/components/ResetPassword.js index 88b17c4..14c278a 100644 --- a/src/components/ResetPassword.js +++ b/src/components/ResetPassword.js @@ -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() {

Password updated

You can now sign in with your new password.

- +
); @@ -76,7 +86,9 @@ export default function ResetPassword() { onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required + minLength={8} /> +

{passwordHelp}

@@ -87,6 +99,7 @@ export default function ResetPassword() { onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required + minLength={8} />
{error &&

{error}

} diff --git a/src/components/ScenarioEditModal.js b/src/components/ScenarioEditModal.js index b749296..5911a49 100644 --- a/src/components/ScenarioEditModal.js +++ b/src/components/ScenarioEditModal.js @@ -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"); diff --git a/src/components/SignIn.js b/src/components/SignIn.js index 0334027..a30def3 100644 --- a/src/components/SignIn.js +++ b/src/components/SignIn.js @@ -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(); diff --git a/src/components/SignUp.js b/src/components/SignUp.js index 2def852..964d461 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -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. )} +
+ + 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" + /> + +
+ {/* Career Situation */}
- +
+ + + {showChangePw && ( +
+ setShowChangePw(false)} /> +
+ )} +
{/* Form Buttons */}
diff --git a/src/index.js b/src/index.js index 3f9813c..e993368 100644 --- a/src/index.js +++ b/src/index.js @@ -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( diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index 065bdd8..fca620a 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -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; @@ -359,11 +368,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; @@ -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); - - if (inCollege && gradDate) { - stillInCollege = - currentSimDate.isSameOrAfter(enrollmentDateObj) && - currentSimDate.isBefore(graduationDateObj); -} - } + const stillInCollege = + inCollege && + currentSimDate.isSameOrAfter(enrollmentStart) && + currentSimDate.isBefore(gradDateEffective); + +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; @@ -616,11 +612,6 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax if (!firstRetirementBalance && reachedRetirement) { firstRetirementBalance = currentRetirementSavings; } - - // final loanPaidOffMonth if never set - if (loanBalance <= 0 && !loanPaidOffMonth) { - loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM'); - } /* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */ diff --git a/src/utils/getMissingFields.js b/src/utils/getMissingFields.js index db8e1bc..5dcc863 100644 --- a/src/utils/getMissingFields.js +++ b/src/utils/getMissingFields.js @@ -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. -} diff --git a/src/utils/passwordRules.ts b/src/utils/passwordRules.ts new file mode 100644 index 0000000..851cf23 --- /dev/null +++ b/src/utils/passwordRules.ts @@ -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; +} diff --git a/src/utils/safeLocal.js b/src/utils/safeLocal.js new file mode 100644 index 0000000..b2d6b7c --- /dev/null +++ b/src/utils/safeLocal.js @@ -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); } \ No newline at end of file diff --git a/src/utils/storageGuard.js b/src/utils/storageGuard.js new file mode 100644 index 0000000..e4b222d --- /dev/null +++ b/src/utils/storageGuard.js @@ -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 {} +} \ No newline at end of file