From 761f511601f3e9ae14ef6e06b0430afa011196bc Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 13 Aug 2025 19:58:24 +0000 Subject: [PATCH] cookie implementation --- .env | 2 +- .env.production | 42 +++ .woodpecker.yml | 12 +- backend/jobs/reminderCron.js | 1 - backend/server1.js | 87 +++++-- backend/server2.js | 143 ++++++----- backend/server3.js | 295 +++++++--------------- backend/shared/requireAuth.js | 37 ++- deploy_all.sh | 1 + docker-compose.yml | 12 + migrate_encrypted_columns.sql | 4 + package-lock.json | 23 ++ package.json | 1 + src/App.js | 144 ++++++----- src/auth/ProtectedRoute.js | 14 + src/auth/apiFetch.js | 89 ++++++- src/auth/installAxiosAuthShim.js | 31 +++ src/auth/installFetchAuthShim.js | 135 ++++++++++ src/auth/useAuthGuard.js | 16 ++ src/components/CareerExplorer.js | 7 +- src/components/EducationalProgramsPage.js | 36 +-- src/components/SignIn.js | 120 ++++----- src/components/UserProfile.js | 192 +++++--------- src/index.js | 68 +++-- src/utils/authFetch.js | 44 +--- src/utils/onboardingState.js | 15 ++ src/utils/storageGuard.js | 186 ++++++++++++-- 27 files changed, 1080 insertions(+), 677 deletions(-) create mode 100644 .env.production create mode 100644 src/auth/ProtectedRoute.js create mode 100644 src/auth/installAxiosAuthShim.js create mode 100644 src/auth/installFetchAuthShim.js create mode 100644 src/auth/useAuthGuard.js create mode 100644 src/utils/onboardingState.js diff --git a/.env b/.env index 1b8e344..702ecbc 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=ed1fdbb-202508121553 +IMG_TAG=fb2e052-202508131933 ENV_NAME=dev PROJECT=aptivaai-dev \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..5a7798c --- /dev/null +++ b/.env.production @@ -0,0 +1,42 @@ +# ─── O*NET ─────────────────────────────── +ONET_USERNAME=aptivaai +ONET_PASSWORD=2296ahq + +# ─── Public‐facing React build ─────────── +NODE_ENV=production +REACT_APP_ENV=production +APTIVA_API_BASE=https://dev1.aptivaai.com +REACT_APP_API_URL=${APTIVA_API_BASE} +REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20 +REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231 +REACT_APP_OPENAI_API_KEY=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 + +# ─── Back-end services ─────────────────── +OPENAI_API_KEY=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 +GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20 +COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj +SALARY_DB=/salary_info.db + +# ─── Database (premium server) ─────────── +DB_HOST=34.67.180.54 +DB_PORT=3306 +DB_USER=sqluser +DB_PASSWORD=ps { 'Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With' ); + res.setHeader('Access-Control-Allow-Credentials', 'true'); res.status(200).end(); }); @@ -284,6 +287,30 @@ const pwDailyLimiter = rateLimit({ keyGenerator: (req) => req.ip, }); +// ---- Auth cookie / token helper ---- +const COOKIE_NAME = process.env.ACCESS_COOKIE_NAME || 'aptiva_access'; +const COOKIE_SECURE = String(process.env.COOKIE_SECURE).toLowerCase() === 'true'; +const COOKIE_SAMESITE = process.env.COOKIE_SAMESITE || 'Lax'; +const COOKIE_DOMAIN = (process.env.COOKIE_DOMAIN || '').trim() || undefined; + +// Default max-age: use TOKEN_MAX_AGE_MS if set, else 2h +const MAX_AGE_MS = Number(process.env.TOKEN_MAX_AGE_MS || 0) || (2 * 60 * 60 * 1000); +const EXPIRES_SEC = Math.floor(MAX_AGE_MS / 1000); + +// standardize on `sub` (requireAuth also accepts id/userId) +function issueSession(res, userId) { + const token = jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: EXPIRES_SEC }); + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + secure: COOKIE_SECURE, + sameSite: COOKIE_SAMESITE, + domain: COOKIE_DOMAIN, // undefined => host-only cookie + path: '/', + maxAge: MAX_AGE_MS, + }); + return token; +} + async function setPasswordByEmail(email, bcryptHash) { const sql = ` UPDATE user_auth ua @@ -578,17 +605,19 @@ app.post('/api/register', async (req, res) => { 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 maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000; + const expiresSec = Math.floor(maxAgeMs / 1000); + const token = issueSession(res, newProfileId); - return res.status(201).json({ - message: 'User registered successfully', - profileId: newProfileId, - token, - user: { - username, firstname, lastname, email: emailNorm, zipcode, state, area, - career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in - } - }); +return res.status(201).json({ + message: 'User registered successfully', + profileId: newProfileId, + token, // optional; frontend doesn’t need it anymore + 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') { @@ -665,16 +694,16 @@ app.post('/api/signin', async (req, res) => { if (profile?.email) { try { profile.email = decrypt(profile.email); } catch {} } +const maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000; + const expiresSec = Math.floor(maxAgeMs / 1000); + const token = issueSession(res, row.userProfileId); - - const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); - - res.status(200).json({ - message: 'Login successful', - token, - id: row.userProfileId, - user: profile - }); +return res.status(200).json({ + message: 'Login successful', + token, // optional + id: row.userProfileId, + user: profile +}); } catch (err) { console.error('Error querying user_auth:', err.message); return res @@ -940,6 +969,24 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => { } }); +/* Logout endpoint */ +app.post('/api/logout', (req, res) => { + const cookieName = process.env.ACCESS_COOKIE_NAME || 'aptiva_access'; + const isSecure = String(process.env.COOKIE_SECURE).toLowerCase() === 'true'; + const sameSite = process.env.COOKIE_SAMESITE || 'Lax'; + const cookieDomain = (process.env.COOKIE_DOMAIN || '').trim() || undefined; + + res.clearCookie(cookieName, { + httpOnly: true, + secure: isSecure, + sameSite, + domain: cookieDomain, // must match what you set on sign-in + path: '/', + }); + res.status(200).json({ ok: true }); +}); + + /* ------------------------------------------------------------------ START SERVER diff --git a/backend/server2.js b/backend/server2.js index f7e7ad5..c829a41 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -21,8 +21,10 @@ import rateLimit from 'express-rate-limit'; import authenticateUser from './utils/authenticateUser.js'; import { vectorSearch } from "./utils/vectorSearch.js"; import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; +import { requireAuth } from '../shared/auth/requireAuth.js'; import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail import crypto from 'crypto'; +import cookieParser from 'cookie-parser'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -71,6 +73,8 @@ try { // Create Express app const app = express(); +app.set('trust proxy', 1); +app.use(cookieParser()); const PORT = process.env.SERVER2_PORT || 5001; function fprPathFromEnv() { @@ -1158,93 +1162,88 @@ chatFreeEndpoint(app, { * Returns 429 Too Many Requests if limits exceeded * Supports deduplication for 10 minutes * *************************************************/ -app.post( - '/api/support', - authenticateUser, // logged-in only - supportBurstLimiter, - supportDailyLimiter, - async (req, res) => { - try { - const user = req.user || {}; - const userId = user.id || user.user_id || user.sub; // depends on your token - if (!userId) { - return res.status(401).json({ error: 'Auth required' }); - } +const _supportSeen = new Map(); +function _isDupAndRemember(key, ttlMs = 5 * 60 * 1000) { + const now = Date.now(); + const last = _supportSeen.get(key); + _supportSeen.set(key, now); + // sweep occasionally + for (const [k, t] of _supportSeen) if (now - t > ttlMs) _supportSeen.delete(k); + return last && (now - last) < ttlMs; +} +function _escape(s) { + return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); +} - // Prefer token email; fall back to DB; last resort: body.email - let accountEmail = user.email || user.mail || null; - if (!accountEmail) { - try { - const row = await userProfileDb.get( - 'SELECT email FROM user_profile WHERE id = ?', - [userId] - ); - accountEmail = row?.email || null; - } catch {} - } - if (!accountEmail) { - accountEmail = (req.body && req.body.email) || null; - } - if (!accountEmail) { - return res.status(400).json({ error: 'No email on file for this user' }); - } +app.post('/api/support', requireAuth, async (req, res) => { + try { + const userId = req.userId || req.user?.id; + if (!userId) return res.status(401).json({ error: 'Auth required' }); - const { subject = '', category = 'general', message = '' } = req.body || {}; + // 1) email priority: token → DB decrypted → request body + let accountEmail = req.user?.email || req.user?.mail || null; - // Basic validation - const allowedCats = new Set(['general','billing','technical','data','ux']); - const subj = subject.toString().slice(0, 120).trim(); - const body = message.toString().trim(); + if (!accountEmail) { + try { + const [rows] = await pool.query( + 'SELECT email FROM user_profile WHERE id = ? LIMIT 1', + [userId] + ); + const enc = rows?.[0]?.email || null; + if (enc) { + try { accountEmail = decrypt(enc); } catch {} + } + } catch {} + } + if (!accountEmail) accountEmail = req.body?.email || null; + if (!accountEmail) { + return res.status(400).json({ error: 'No email on file for this user' }); + } - if (!allowedCats.has(String(category))) { - return res.status(400).json({ error: 'Invalid category' }); - } - if (body.length < 5) { - return res.status(400).json({ error: 'Message too short' }); - } + // 2) validate payload + const subject = String(req.body?.subject || '').slice(0, 120).trim(); + const category = String(req.body?.category || 'general'); + const message = String(req.body?.message || '').trim(); - // Dedupe - const key = makeKey(userId, subj || '(no subject)', body); - if (isDuplicateAndRemember(key)) { - return res.status(202).json({ ok: true, deduped: true }); - } + const allowed = new Set(['general','billing','technical','data','ux']); + if (!allowed.has(category)) return res.status(400).json({ error: 'Invalid category' }); + if (message.length < 5) return res.status(400).json({ error: 'Message too short' }); - // Require mail config - const FROM = 'support@aptivaai.com'; - const TO = 'support@aptivaai.com'; + // 3) de-dup + const dedupeKey = `${userId}::${category}::${subject}::${message}`; + if (_isDupAndRemember(dedupeKey)) { + return res.status(202).json({ ok: true, deduped: true }); + } - if (!SENDGRID_KEY) { + // 4) email config + const FROM = process.env.SUPPORT_FROM || 'support@aptivaai.com'; + const TO = process.env.SUPPORT_TO || 'support@aptivaai.com'; + if (!process.env.SENDGRID_KEY) { return res.status(503).json({ error: 'Support email not configured' }); } - const humanSubject = - `[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`; - - const textBody = -`User: ${userId} + // 5) send + const humanSubject = `[Support • ${category}] ${subject || '(no subject)'} — user ${userId}`; + const textBody = `User: ${userId} Email: ${accountEmail} Category: ${category} -${body}`; +${message}`; - await sgMail.send({ - to: TO, - from: FROM, - replyTo: accountEmail, - subject: humanSubject, - text: textBody, - html: `
${textBody}
`, - categories: ['support', String(category || 'general')] - }); + await sgMail.send({ + to: TO, + from: FROM, + replyTo: accountEmail, + subject: humanSubject, + text: textBody, + html: `
${_escape(textBody)}
`, + categories: ['support', category] + }); - - return res.status(200).json({ ok: true }); - } catch (err) { - console.error('[support] error:', err?.message || err); - return res.status(500).json({ error: 'Failed to send support message' }); - } - } -); + return res.json({ ok: true }); + } catch (err) { + console.error('[support] error:', err?.message || err); + return res.status(500).json({ error: 'Failed to send support message' }); /************************************************** * Start the Express server diff --git a/backend/server3.js b/backend/server3.js index 3524a4a..3fcae5a 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -8,7 +8,6 @@ const __dirname = path.dirname(__filename); import express from 'express'; import helmet from 'helmet'; -import fs, { readFile } from 'fs/promises'; // <-- add this import multer from 'multer'; import fetch from 'node-fetch'; import mammoth from 'mammoth'; @@ -16,6 +15,9 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import pkg from 'pdfjs-dist'; import pool from './config/mysqlPool.js'; +import * as fsSync from 'fs'; // for safeUnlink() +import { readFile } from 'fs/promises'; +import cookieParser from 'cookie-parser'; import OpenAI from 'openai'; import Fuse from 'fuse.js'; @@ -27,6 +29,8 @@ import { hashForLookup } from './shared/crypto/encryption.js'; import './jobs/reminderCron.js'; import { cacheSummary } from "./utils/ctxCache.js"; +import { requireAuth } from './shared/requireAuth.js'; +import rateLimit from 'express-rate-limit'; const rootPath = path.resolve(__dirname, '..'); const env = (process.env.NODE_ENV || 'production'); @@ -38,6 +42,8 @@ if (!process.env.FROM_SECRETS_MANAGER) { const PORT = process.env.SERVER3_PORT || 5002; const API_BASE = `http://localhost:${PORT}/api`; + + /* ─── helper: canonical public origin ─────────────────────────── */ const PUBLIC_BASE = ( process.env.APTIVA_AI_BASE @@ -57,7 +63,11 @@ function isSafeRedirect(url) { } const app = express(); + +app.set('trust proxy', 1); +app.use(cookieParser()); const { getDocument } = pkg; + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' }); // ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ── @@ -158,6 +168,17 @@ function internalFetch(req, urlPath, opts = {}) { const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts); +const rlAuth = rateLimit({ windowMs: 10 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); // sign-in/signup/reset +const rlPublicAI = rateLimit({ windowMs: 10 * 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false }); // /api/public/* +const rlPremiumAI = rateLimit({ windowMs: 10 * 60 * 1000, max: 120, standardHeaders: true, legacyHeaders: false }); // paid AI features + +const publicAIRiskLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 min + max: 30, // 30 requests / IP / 15min + standardHeaders: true, + legacyHeaders: false +}); + // AI Risk Analysis Helper Functions async function getRiskAnalysisFromDB(socCode) { const [rows] = await pool.query( @@ -230,6 +251,17 @@ app.post( return res.status(400).end(); } + // Idempotency: ignore if we've seen this event.id + try { + const [[seen]] = await pool.query('SELECT id FROM stripe_events WHERE id=?', [event.id]); + if (seen) { + return res.sendStatus(200); // already processed + } + } catch (e) { + console.error('[Stripe] idempotency check failed', e); + // still proceed; worst case Stripe retries + } + const upFlags = async (customerId, premium, pro) => { const h = hashForLookup(customerId); console.log('[Stripe] upFlags', { customerId, premium, pro }); @@ -260,7 +292,12 @@ app.post( default: // Ignore everything else } - res.sendStatus(200); + try { + await pool.query('INSERT INTO stripe_events (id) VALUES (?)', [event.id]); + } catch (e) { + // race-safe: duplicate key just means another worker won + } + res.sendStatus(200); } ); @@ -312,23 +349,12 @@ app.use((req, res, next) => { }); // 3) Authentication middleware -const authenticatePremiumUser = (req, res, next) => { - const token = (req.headers.authorization || '') - .replace(/^Bearer\s+/i, '') // drop “Bearer ” - .trim(); // strip CR/LF, spaces - if (!token) { - return res.status(401).json({ error: 'Premium authorization required' }); - } - - try { - const JWT_SECRET = process.env.JWT_SECRET; - const { id } = jwt.verify(token, JWT_SECRET); - req.id = id; // store user ID in request +const authenticatePremiumUser = (req, res, next) => + requireAuth(req, res, () => { + // preserve existing field name so routes don’t change + req.id = req.userId; next(); - } catch (error) { - return res.status(403).json({ error: 'Invalid or expired token' }); - } -}; + }); /** ------------------------------------------------------------------ * Returns the user’s stripe_customer_id (or null) given req.id. @@ -742,180 +768,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs } }); -/*************************************************** - AI - NEXT STEPS ENDPOINT (with date constraints, - ignoring scenarioRow.start_date) - ****************************************************/ -app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => { - try { - // 1) Gather user data from request - const { - userProfile = {}, - scenarioRow = {}, - financialProfile = {}, - collegeProfile = {}, - previouslyUsedTitles = [] - } = req.body; - - // 2) Build a summary for ChatGPT - // (We'll ignore scenarioRow.start_date in the prompt) - // 4. Get / build the cached big-context card (one DB hit, or none on cache-hit) - // build the big summary with your local helper -let summaryText = buildUserSummary({ - userProfile, - scenarioRow, - financialProfile, - collegeProfile, - aiRisk -}); - -summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText); - - let avoidSection = ''; - if (previouslyUsedTitles.length > 0) { - avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles - .map((t) => `- ${t}`) - .join('\n')}\n`; - } - - // 3) Dynamically compute "today's" date and future cutoffs - const now = new Date(); - const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01" - - // short-term = within 6 months - const shortTermLimit = new Date(now); - shortTermLimit.setMonth(shortTermLimit.getMonth() + 6); - const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10); - - // long-term = 1-3 years - const oneYearFromNow = new Date(now); - oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); - const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10); - - const threeYearsFromNow = new Date(now); - threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3); - const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10).slice(0, 10); - - // 4) Construct ChatGPT messages - const messages = [ - { - role: 'system', - content: ` -You are an expert career & financial coach. -Today's date: ${isoToday}. -Short-term means any date up to ${isoShortTermLimit} (within 6 months). -Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years). -All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words. - -IMPORTANT RESTRICTIONS: -- NEVER suggest specific investments in cryptocurrency, stocks, or other speculative financial instruments. -- NEVER provide specific investment advice without appropriate risk disclosures. -- NEVER provide legal, medical, or psychological advice. -- ALWAYS promote responsible and low-risk financial planning strategies. -- Emphasize skills enhancement, networking, and education as primary pathways to financial success. - -Respond ONLY in the requested JSON format.` - }, - { - role: 'user', - content: ` -Here is the user's current situation: -${summaryText} - -Please provide exactly 2 short-term (within 6 months) and 1 long-term (1–3 years) milestones. Avoid any previously suggested milestones. -Each milestone must have: - - "title" (up to 5 words) - - "date" in YYYY-MM-DD format (>= ${isoToday}) - - "description" (1-2 sentences) - - ${avoidSection} - -Return ONLY a JSON array, no extra text: - -[ - { - "title": "string", - "date": "YYYY-MM-DD", - "description": "string" - }, - ... -]` - } - ]; - - // 5) Call OpenAI (ignoring scenarioRow.start_date for date logic) - const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); - const completion = await openai.chat.completions.create({ - model: 'gpt-4o-mini', // or 'gpt-4' - messages, - temperature: 0.7, - max_tokens: 600 - }); - - // 6) Extract raw text - const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response'; - - res.json({ recommendations: aiAdvice }); - } catch (err) { - console.error('Error in /api/premium/ai/next-steps =>', err); - res.status(500).json({ error: 'Failed to get AI next steps.' }); - } -}); - -/** - * Helper that converts user data into a concise text summary. - * This can still mention scenarioRow, but we do NOT feed - * scenarioRow.start_date to ChatGPT for future date calculations. - */ -function buildUserSummary({ - userProfile = {}, - scenarioRow = {}, - financialProfile = {}, - collegeProfile = {}, - aiRisk = null -}) { - const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`; - const careerName = scenarioRow.career_name || 'Unknown'; - const careerGoals = scenarioRow.career_goals || 'No goals specified'; - const status = scenarioRow.status || 'planned'; - const currentlyWorking = scenarioRow.currently_working || 'no'; - - const currentSalary = financialProfile.current_salary || 0; - const monthlyExpenses = financialProfile.monthly_expenses || 0; - const monthlyDebt = financialProfile.monthly_debt_payments || 0; - const retirementSavings = financialProfile.retirement_savings || 0; - const emergencyFund = financialProfile.emergency_fund || 0; - - let riskText = ''; - if (aiRisk?.riskLevel) { - riskText = ` -AI Automation Risk: ${aiRisk.riskLevel} -Reasoning: ${aiRisk.reasoning}`; - } - - return ` -User Location: ${location} -Career Name: ${careerName} -Career Goals: ${careerGoals} -Career Status: ${status} -Currently Working: ${currentlyWorking} - -Financial: - - Salary: \$${currentSalary} - - Monthly Expenses: \$${monthlyExpenses} - - Monthly Debt: \$${monthlyDebt} - - Retirement Savings: \$${retirementSavings} - - Emergency Fund: \$${emergencyFund} - -${riskText} -`.trim(); -} - -// Example: ai/chat with correct milestone-saving logic -// At the top of server3.js, leave your imports and setup as-is -// (No need to import 'pluralize' if we're no longer using it!) - -app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { +app.post('/api/premium/ai/chat', rlPremiumAI, authenticatePremiumUser, async (req, res) => { try { const { userProfile = {}, @@ -1459,7 +1312,7 @@ ${avoidBlock} `.trim(); const NEEDS_OPS_CARD = !chatHistory.some( - m => m.role === "system" && m.content.includes("APTIVA OPS CHEAT-SHEET") + m => m.role === "system" && m.content.includes("APTIVA OPS YOU CAN USE ANY TIME") ); const NEEDS_CTX_CARD = !chatHistory.some( @@ -1476,8 +1329,14 @@ if (NEEDS_OPS_CARD) { messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD }); } -if (NEEDS_CTX_CARD || SEND_CTX_CARD) - messagesToSend.push({ role:"system", content: summaryText }); +if (SEND_CTX_CARD) { + const systemPromptDetailedContext = ` +[DETAILED USER PROFILE & CONTEXT] +${summaryText} +`.trim(); +messagesToSend.push({ role: "system", content: systemPromptDetailedContext }); + +} // ② Per-turn contextual helpers (small!) messagesToSend.push( @@ -1692,7 +1551,7 @@ Check your Milestones tab. Let me know if you want any changes! RETIREMENT AI-CHAT ENDPOINT (clone + patch) ─────────────────────────────────────────── */ app.post( - '/api/premium/retirement/aichat', + '/api/premium/retirement/aichat', rlPremiumAI, authenticatePremiumUser, async (req, res) => { try { @@ -2134,12 +1993,12 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r } }); -app.post('/api/public/ai-risk-analysis', async (req, res) => { +app.post('/api/public/ai-risk-analysis', publicAIRiskLimiter, async (req, res) => { try { const { socCode, careerName, - jobDescription, + jobDescription = '', tasks = [] } = req.body; @@ -2147,10 +2006,15 @@ app.post('/api/public/ai-risk-analysis', async (req, res) => { return res.status(400).json({ error: 'socCode and careerName are required.' }); } + // simple size clamps to keep prompts sane / cheap + const jd = String(jobDescription).slice(0, 2000); + const safeTasks = Array.isArray(tasks) + ? tasks.slice(0, 25).map(t => String(t).slice(0, 200)) + : []; const prompt = ` The user has a career named: ${careerName} - Description: ${jobDescription} - Tasks: ${tasks.join('; ')} + Description: ${jd} + Tasks: ${safeTasks.join('; ')} Provide AI automation risk analysis for the next 10 years. Return JSON exactly in this format: @@ -3617,7 +3481,7 @@ let allKsaNames = []; // an array of unique KSA names (for fuzzy matching) (async function loadKsaJson() { try { const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json'); - const raw = await fs.readFile(filePath, 'utf8'); + const raw = await readFile(filePath, 'utf8'); onetKsaData = JSON.parse(raw); // Build a set of unique KSA names for fuzzy search @@ -3763,7 +3627,8 @@ function processChatGPTKsa(chatGptKSA, ksaType) { // 6) The new route app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => { const { socCode } = req.params; - const { careerTitle = '' } = req.query; // or maybe from body + const { careerTitle: rawTitle = '' } = req.query; + const careerTitle = String(rawTitle).slice(0, 120); try { // 1) Check local data @@ -3857,7 +3722,16 @@ return res.json({ ------------------------------------------------------------------ */ // Setup file upload via multer -const upload = multer({ dest: 'uploads/' }); +const upload = multer({ + dest: 'uploads/', + limits: { fileSize: 8 * 1024 * 1024 }, // 8 MB + fileFilter: (req, file, cb) => { + const ok = ['application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/msword'].includes(file.mimetype); + cb(ok ? null : new Error('Unsupported file type'), ok); + } +}); function buildResumePrompt(resumeText, jobTitle, jobDescription) { // Full ChatGPT prompt for resume optimization: @@ -3902,9 +3776,11 @@ async function extractTextFromPDF(filePath) { app.post( '/api/premium/resume/optimize', - upload.single('resumeFile'), + upload.single('resumeFile'), authenticatePremiumUser, async (req, res) => { + const tmpPath = req.file?.path; + const safeUnlink = () => { try { if (tmpPath) fsSync.unlinkSync(tmpPath); } catch {} }; try { const { jobTitle, jobDescription } = req.body; if (!jobTitle || !jobDescription || !req.file) { @@ -3970,7 +3846,6 @@ app.post( const result = await mammoth.extractRawText({ path: filePath }); resumeText = result.value; } else { - await fs.unlink(filePath); return res.status(400).json({ error: 'Unsupported or corrupted file upload.' }); } @@ -3996,8 +3871,7 @@ app.post( const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1); - // remove uploaded file - await fs.unlink(filePath); + res.json({ optimizedResume, @@ -4005,8 +3879,11 @@ app.post( resetDate: resetDate.toISOString().slice(0, 10) }); } catch (err) { - console.error('Error optimizing resume:', err); - res.status(500).json({ error: 'Failed to optimize resume.' }); + console.error('Error optimizing resume:', err); + res.status(500).json({ error: 'Failed to optimize resume.' }); + } finally { + // always clean up the temp upload + safeUnlink(); } } ); diff --git a/backend/shared/requireAuth.js b/backend/shared/requireAuth.js index c78de68..3772b92 100644 --- a/backend/shared/requireAuth.js +++ b/backend/shared/requireAuth.js @@ -2,41 +2,54 @@ 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 +const { JWT_SECRET, TOKEN_MAX_AGE_MS, ACCESS_COOKIE_NAME = 'aptiva_access' } = process.env; +const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); + +function extractBearer(authz) { + if (!authz || typeof authz !== 'string') return ''; + if (!authz.toLowerCase().startsWith('bearer ')) return ''; + const v = authz.slice(7).trim(); + if (!v || v === 'null' || v === 'undefined') return ''; + return v; +} export async function requireAuth(req, res, next) { try { - const authz = req.headers.authorization || ''; - const token = authz.startsWith('Bearer ') ? authz.slice(7) : ''; + const cookieToken = req.cookies?.[ACCESS_COOKIE_NAME]; + const bearerToken = extractBearer(req.headers.authorization); + const token = cookieToken || bearerToken; // cookie always wins + 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; + const userId = payload.sub || payload.id || payload.userId; + 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) { + const changedAtMs = rows?.[0]?.password_changed_at ? new Date(rows[0].password_changed_at).getTime() : 0; + if (changedAtMs && iatMs < changedAtMs) { return res.status(401).json({ error: 'Session invalidated. Please sign in again.' }); } - req.userId = userId; + req.user = (payload && typeof payload === 'object') + ? { ...payload, id: userId } + : { id: userId }; + +req.userId = userId; +next(); next(); } catch (e) { console.error('[requireAuth]', e?.message || e); - return res.status(500).json({ error: 'Server error' }); + res.status(500).json({ error: 'Server error' }); } } diff --git a/deploy_all.sh b/deploy_all.sh index bb857d1..a09cf26 100755 --- a/deploy_all.sh +++ b/deploy_all.sh @@ -26,6 +26,7 @@ SECRETS=( DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \ SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \ TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \ + ACCESS_COOKIE_NAME COOKIE_SECURE COOKIE_SAMESITE TOKEN_MAX_AGE_MS \ KMS_KEY_NAME DEK_PATH ) diff --git a/docker-compose.yml b/docker-compose.yml index df2fa3c..962916b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,10 @@ services: environment: ENV_NAME: ${ENV_NAME} APTIVA_API_BASE: ${APTIVA_API_BASE} + ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME} + COOKIE_SECURE: ${COOKIE_SECURE} + COOKIE_SAMESITE: ${COOKIE_SAMESITE} + TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS} PROJECT: ${PROJECT} KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} @@ -79,6 +83,10 @@ services: PROJECT: ${PROJECT} KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} + ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME} + COOKIE_SECURE: ${COOKIE_SECURE} + COOKIE_SAMESITE: ${COOKIE_SAMESITE} + TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS} ONET_USERNAME: ${ONET_USERNAME} ONET_PASSWORD: ${ONET_PASSWORD} JWT_SECRET: ${JWT_SECRET} @@ -128,6 +136,10 @@ services: KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} JWT_SECRET: ${JWT_SECRET} + ACCESS_COOKIE_NAME: ${ACCESS_COOKIE_NAME} + COOKIE_SECURE: ${COOKIE_SECURE} + COOKIE_SAMESITE: ${COOKIE_SAMESITE} + TOKEN_MAX_AGE_MS: ${TOKEN_MAX_AGE_MS} OPENAI_API_KEY: ${OPENAI_API_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql index a6d9b93..db332fa 100644 --- a/migrate_encrypted_columns.sql +++ b/migrate_encrypted_columns.sql @@ -179,3 +179,7 @@ UPDATE user_auth SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000) WHERE user_id = ? +CREATE TABLE IF NOT EXISTS stripe_events ( + id VARCHAR(255) PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/package-lock.json b/package-lock.json index 75f4ed5..82b7640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cra-template": "1.2.0", "docx": "^9.5.0", @@ -7328,6 +7329,28 @@ "node": ">=18" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/package.json b/package.json index b95ffc8..49d1b7e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cra-template": "1.2.0", "docx": "^9.5.0", diff --git a/src/App.js b/src/App.js index 9e69c76..78a7558 100644 --- a/src/App.js +++ b/src/App.js @@ -41,7 +41,7 @@ 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'; +import { clearToken } from './auth/authMemory.js'; @@ -172,57 +172,63 @@ const showPremiumCTA = !premiumPaths.some(p => setUserEmail(user?.email || ''); }, [user]); - // ============================== - // 1) Single Rehydrate UseEffect - // ============================== + /* Multi-tab signout listener */ useEffect(() => { - // 🚫 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 - setIsLoading(false); - return; + const onStorage = (e) => { + if (e.key === 'token' && !e.newValue) { + // another tab cleared the token + clearToken(); + setIsAuthenticated(false); + setUser(null); + navigate('/signin?session=expired'); } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); +}, [navigate]); + + // ============================== + // 1) Single Rehydrate UseEffect (cookie mode) + // ============================== + useEffect(() => { + let cancelled = false; + (async () => { + + const isAuthRoute = + location.pathname === '/signin' || + location.pathname === '/signup' || + location.pathname === '/forgot-password' || + location.pathname.startsWith('/reset-password'); + + if (isAuthRoute) { + try { localStorage.removeItem('token'); localStorage.removeItem('id'); } catch {} + if (!cancelled) { + setIsAuthenticated(false); + setUser(null); + setIsLoading(false); + } + return; + } + try { + // Cookie goes automatically; shim sends credentials:'include' + const res = await fetch('/api/user-profile', { credentials: 'include' }); + if (!res.ok) throw new Error('unauthorized'); + const profile = await res.json(); + if (cancelled) return; + setUser(profile); + setFinancialProfile(profile); + setIsAuthenticated(true); + } catch { + if (cancelled) return; + setIsAuthenticated(false); + setUser(null); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [location.pathname]); - // If we have a token, validate it by fetching user - fetch('/api/user-profile', { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((res) => { - if (!res.ok) throw new Error('Token invalid on server side'); - return res.json(); - }) - .then((profile) => { - // Successfully got user profile => user is authenticated - setUser(profile); - setIsAuthenticated(true); - }) - .catch((err) => { - console.error(err); - // Invalid token => remove it, force sign in - localStorage.removeItem('token'); - setIsAuthenticated(false); - setUser(null); - navigate('/signin?session=expired'); - }) - .finally(() => { - // Either success or fail, we're done loading - setIsLoading(false); - }); - }, [navigate, location.pathname]); // ========================== // 2) Logout Handler + Modal @@ -237,31 +243,35 @@ const showPremiumCTA = !premiumPaths.some(p => } }; - + const confirmLogout = async () => { + // Clear any sensitive values from Web Storage + [ + 'token', + 'id', + 'careerSuggestionsCache', + 'lastSelectedCareerProfileId', + 'selectedCareer', + 'aiClickCount', + 'aiClickDate', + 'aiRecommendations', + 'premiumOnboardingState', + 'financialProfile' + ].forEach(k => { + try { localStorage.removeItem(k); } catch {} + }); - const confirmLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('id'); - localStorage.removeItem('careerSuggestionsCache'); - localStorage.removeItem('lastSelectedCareerProfileId'); - localStorage.removeItem('selectedCareer'); - localStorage.removeItem('aiClickCount'); - localStorage.removeItem('aiClickDate'); - localStorage.removeItem('aiRecommendations'); - localStorage.removeItem('premiumOnboardingState'); // ← NEW - localStorage.removeItem('financialProfile'); // ← if you cache it + // Clear in-memory token +try { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); } catch {} + try { clearToken(); } catch {} - setFinancialProfile(null); // ← reset any React-context copy + // Reset React state/context + setFinancialProfile(null); setScenario(null); setIsAuthenticated(false); setUser(null); setShowLogoutWarning(false); - // Reset auth - setIsAuthenticated(false); - setUser(null); - setShowLogoutWarning(false); - + // Navigate to Sign In navigate('/signin'); }; diff --git a/src/auth/ProtectedRoute.js b/src/auth/ProtectedRoute.js new file mode 100644 index 0000000..84e657a --- /dev/null +++ b/src/auth/ProtectedRoute.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { getToken } from './authMemory.js'; + +export default function ProtectedRoute({ children }) { + const location = useLocation(); + const token = getToken(); + + if (!token) { + const next = encodeURIComponent(location.pathname + location.search); + return ; + } + return children; +} diff --git a/src/auth/apiFetch.js b/src/auth/apiFetch.js index 9836fea..bf4a8e9 100644 --- a/src/auth/apiFetch.js +++ b/src/auth/apiFetch.js @@ -1,9 +1,82 @@ -// apiFetch.js -import { getToken } from './authMemory.js'; +// src/auth/apiFetch.js +// +// A tiny wrapper around window.fetch. +// - NEVER sets Authorization (shim does that). +// - Smart JSON handling (auto stringify, auto parse in helpers). +// - Optional timeout via AbortController. +// - Optional shim bypass: { bypassAuth: true } adds X-Bypass-Auth: 1. +// - Leaves error semantics up to caller or helper. +// +// Use: +// const res = await apiFetch('/api/user-profile'); +// const json = await res.json(); +// +// Or helpers: +// const data = await apiGetJSON('/api/user-profile'); +// const out = await apiPostJSON('/api/premium/thing', { foo: 'bar' }); -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 +const DEFAULT_TIMEOUT_MS = 25000; // 25s + +export async function apiFetch(url, options = {}) { + const headers = new Headers(options.headers || {}); + const init = { ...options, credentials: options.credentials || 'include' }; + + // If body is a plain object and no Content-Type set, send JSON + if (!headers.has('Content-Type') && + options.body && + typeof options.body === 'object' && + !(options.body instanceof FormData)) { + headers.set('Content-Type', 'application/json'); + init.body = JSON.stringify(options.body); + } + + if (headers.size) init.headers = headers; + + // This must always return a Response, never null + return fetch(url, init); +} + +export async function apiGetJSON(url) { + const res = await apiFetch(url); + if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`); + return res.json(); +} + +export async function apiPostJSON(url, payload) { + const res = await apiFetch(url, { method: 'POST', body: payload }); + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + const msg = errBody?.error || `POST ${url} failed: ${res.status}`; + throw new Error(msg); + } + return res.json().catch(() => ({})); +} + +/** PUT JSON → parse JSON, throw on !ok. */ +export async function apiPutJSON(url, payload, opts = {}) { + const res = await apiFetch(url, { ...opts, method: 'PUT', body: payload }); + const text = await res.text(); + const data = safeJSON(text); + if (!res.ok) throw new Error(errorFromServer(data, text, res.status)); + return data; +} + +/** DELETE → parse JSON (if any), throw on !ok. */ +export async function apiDeleteJSON(url, opts = {}) { + const res = await apiFetch(url, { ...opts, method: 'DELETE' }); + const text = await res.text(); + const data = safeJSON(text); + if (!res.ok) throw new Error(errorFromServer(data, text, res.status)); + return data || { ok: true }; +} + +/* -------------------- utils -------------------- */ +function safeJSON(text) { + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} +function errorFromServer(json, text, status) { + if (json && typeof json === 'object' && json.error) return json.error; + if (text) return `Request failed (${status}): ${text.slice(0, 240)}`; + return `Request failed (${status})`; +} diff --git a/src/auth/installAxiosAuthShim.js b/src/auth/installAxiosAuthShim.js new file mode 100644 index 0000000..372a47c --- /dev/null +++ b/src/auth/installAxiosAuthShim.js @@ -0,0 +1,31 @@ +// src/auth/installAxiosAuthShim.js +import axios from 'axios'; + +export function installAxiosAuthShim({ debug = false } = {}) { + axios.defaults.withCredentials = true; + + axios.interceptors.request.use((config) => { + try { + const url = new URL(config.url, window.location.origin); + const isSameOrigin = url.origin === window.location.origin; + const isApi = url.pathname.startsWith('/api/'); + if (isSameOrigin && isApi && config.headers) { + const auth = String(config.headers.Authorization || '').trim(); + if (/^Bearer(\s*(null|undefined)?)?$/i.test(auth)) { + delete config.headers.Authorization; // let cookie flow + if (debug) console.debug('[axiosShim] stripped bad Authorization'); + } + } + } catch {} + return config; + }); + + axios.interceptors.response.use(r => r, (err) => { + const s = err?.response?.status; + if ([401,403,419,440].includes(s) && !window.location.pathname.startsWith('/signin')) { + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace(`/signin?session=expired&next=${next}`); + } + return Promise.reject(err); + }); +} diff --git a/src/auth/installFetchAuthShim.js b/src/auth/installFetchAuthShim.js new file mode 100644 index 0000000..7dbce4e --- /dev/null +++ b/src/auth/installFetchAuthShim.js @@ -0,0 +1,135 @@ +// src/auth/installFetchAuthShim.js +import { getToken, clearToken } from './authMemory.js'; + + +/** + * Monkey-patches window.fetch to auto-attach Authorization for same-origin /api/* calls, + * while never attaching to explicitly public endpoints. + * + * Usage (in index.js): + * import { installFetchAuthShim } from './auth/installFetchAuthShim.js'; + * installFetchAuthShim({ debug: false, publicPaths: [...] }); + */ +export function installFetchAuthShim(opts = {}) { + if (window.__aptivaFetchShimInstalled) return; +window.__aptivaFetchShimInstalled = true; + const { + debug = false, + attachBearer = false, + // Add/override from App-level knowledge if needed + publicPaths = [ + '/api/signin', + '/api/signup', + '/api/register', + '/api/check-username', + '/api/areas', + '/api/auth/password-reset/request', + '/api/auth/password-reset/confirm', + '/api/public', + '/livez', + '/readyz', + '/healthz', + // public salary lookup + ], + } = opts; + + if (!window || !window.fetch) return; + + const originalFetch = window.fetch; + let redirecting = false; // loop guard + + window.fetch = async (input, init = {}) => { + try { + // Build a URL object for robust origin/path checks + const requestUrl = (() => { + if (typeof input === 'string') { + // Relative or absolute + return new URL(input, window.location.origin); + } + // Request or URL object + return new URL(input.url, window.location.origin); + })(); + + const isSameOrigin = requestUrl.origin === window.location.origin; + const isApiCall = requestUrl.pathname.startsWith('/api/'); + const fullPath = requestUrl.pathname + requestUrl.search; + + // Respect explicit bypass header for one-offs (handy in staging/debug) + const existingHeaders = new Headers(init.headers || (typeof input === 'object' ? input.headers : undefined) || {}); + const bypass = existingHeaders.get('X-Bypass-Auth') === '1'; + + // Never attach to any configured public path + const isPublic = publicPaths.some((p) => fullPath.startsWith(p)); + + // Only attach if same-origin, /api/*, not public, and not bypassed + const shouldAttachAuth = + isSameOrigin && + isApiCall && + !isPublic && + !bypass; + + // Clone headers to avoid mutating caller's object + const headers = new Headers(existingHeaders); + + if (attachBearer && shouldAttachAuth && !headers.has('Authorization')) { + const token = getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + if (debug) { + // Minimal, non-sensitive log + // (do not log the token or any body) + // eslint-disable-next-line no-console + console.debug(`[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (auth attached)`); + } + } else if (debug) { + // eslint-disable-next-line no-console + console.debug(`[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (no token available)`); + } + } else if (debug) { + // eslint-disable-next-line no-console + console.debug( + `[authShim] → ${init.method || 'GET'} ${requestUrl.pathname} (auth NOT attached: ` + + `${isSameOrigin ? '' : 'cross-origin '} ${isApiCall ? '' : 'non-/api '} ${isPublic ? 'public ' : ''}${bypass ? 'bypass ' : ''})` + ); + } + + // If caller already set Authorization, we never overwrite it. + const finalInit = { + ...init, + headers: headers.size ? headers : init.headers, + credentials: init.credentials || 'include', // send cookies + }; + + const res = await originalFetch(input, finalInit); + + // Centralized expired/unauthorized handling (same-origin, non-public API) + const expired = [401, 403, 419, 440].includes(res.status); +if (isSameOrigin && isApiCall && !isPublic && expired) { + try { clearToken(); } catch {} + + // NEW: call the optional global handler (back-compat with setSessionExpiredCallback) + const handler = window.__aptivaOnSessionExpired; + if (typeof handler === 'function') { + try { handler({ path: requestUrl.pathname, status: res.status, response: res }); } catch {} + } + + // Fallback redirect if the app didn't handle it + const onSignin = window.location.pathname.startsWith('/signin'); + if (!redirecting && !onSignin) { + redirecting = true; + const next = encodeURIComponent(window.location.pathname + window.location.search); + if (debug) console.debug('[authShim] 401 → redirecting to /signin'); + window.location.replace(`/signin?session=expired&next=${next}`); + } +} + return res; + } catch (e) { + // On any unexpected error, fall back to original fetch without blocking the request + if (debug) { + // eslint-disable-next-line no-console + console.debug('[authShim] error', e); + } + return originalFetch(input, init); + } + }; +} diff --git a/src/auth/useAuthGuard.js b/src/auth/useAuthGuard.js new file mode 100644 index 0000000..eef744c --- /dev/null +++ b/src/auth/useAuthGuard.js @@ -0,0 +1,16 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { getToken } from './authMemory.js'; + +export function useAuthGuard() { + const nav = useNavigate(); + const loc = useLocation(); + return () => { + const token = getToken(); + if (!token) { + const next = encodeURIComponent(loc.pathname + loc.search); + nav(`/signin?next=${next}`, { replace: true }); + return false; + } + return true; + }; +} diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 9bdf272..45ab519 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -291,10 +291,7 @@ function CareerExplorer() { const fetchUserProfile = async () => { try { - const token = localStorage.getItem('token'); - const res = await axios.get('/api/user-profile', { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await axios.get('/api/user-profile'); if (res.status === 200) { const profileData = res.data; @@ -1054,7 +1051,7 @@ const handleSelectForEducation = async (career) => { defaultMeaning={modalData.defaultMeaning} /> - {selectedCareer && ( + {selectedCareer && careerDetails && ( { useEffect(() => { async function loadUserProfile() { try { - const token = localStorage.getItem('token'); - if (!token) { - console.warn('No token found, cannot load user-profile.'); - return; - } - const res = await fetch('/api/user-profile', { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await authFetch('/api/user-profile'); if (!res.ok) throw new Error('Failed to fetch user profile'); const data = await res.json(); setUserZip(data.zipcode || ''); @@ -588,19 +585,8 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({ setKsaError(null); try { - const token = localStorage.getItem('token'); - if (!token) { - throw new Error('No auth token found; cannot fetch AI-based KSAs.'); - } - - // Call the new endpoint in server3.js - const resp = await fetch( - `/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`, - { - headers: { - Authorization: `Bearer ${token}` - } - } + const resp = await authFetch( + `/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}` ); if (!resp.ok) { diff --git a/src/components/SignIn.js b/src/components/SignIn.js index a30def3..5c0c2aa 100644 --- a/src/components/SignIn.js +++ b/src/components/SignIn.js @@ -1,7 +1,8 @@ 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'; +import { ProfileCtx } from '../App.js'; +import { clearToken } from '../auth/authMemory.js'; +import { apiFetch, apiGetJSON } from '../auth/apiFetch.js'; function SignIn({ setIsAuthenticated, setUser }) { const navigate = useNavigate(); @@ -13,7 +14,6 @@ function SignIn({ setIsAuthenticated, setUser }) { const location = useLocation(); useEffect(() => { - // Check if the URL query param has ?session=expired const query = new URLSearchParams(location.search); if (query.get('session') === 'expired') { setShowSessionExpiredMsg(true); @@ -21,80 +21,80 @@ function SignIn({ setIsAuthenticated, setUser }) { }, [location.search]); const handleSignIn = async (event) => { - event.preventDefault(); - setError(''); + event.preventDefault(); + setError(''); - // 0️⃣ clear everything that belongs to the *previous* user - localStorage.removeItem('careerSuggestionsCache'); - localStorage.removeItem('lastSelectedCareerProfileId'); - localStorage.removeItem('aiClickCount'); - localStorage.removeItem('aiClickDate'); - localStorage.removeItem('aiRecommendations'); - localStorage.removeItem('premiumOnboardingState'); - localStorage.removeItem('financialProfile'); // if you cache it - localStorage.removeItem('selectedScenario'); + // 0️⃣ Clear anything that might carry over from a previous user/session + try { + [ + 'careerSuggestionsCache', + 'lastSelectedCareerProfileId', + 'aiClickCount', + 'aiClickDate', + 'aiRecommendations', + 'premiumOnboardingState', + 'financialProfile', + 'selectedScenario', + 'token', // legacy cleanup + 'id' // legacy cleanup + ].forEach((k) => localStorage.removeItem(k)); + } catch {} - const username = usernameRef.current.value; - const password = passwordRef.current.value; + const username = usernameRef.current.value; + const password = passwordRef.current.value; - if (!username || !password) { - setError('Please enter both username and password'); - return; - } + if (!username || !password) { + setError('Please enter both username and password'); + return; + } - try { - const resp = await fetch('/api/signin', { - method : 'POST', - headers: { 'Content-Type': 'application/json' }, - body : JSON.stringify({username, password}), - }); + try { + const resp = await fetch('/api/signin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + credentials: 'include', + }); - const data = await resp.json(); // ← read ONCE + // Always read body once + const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data.error || 'Failed to sign in'); + if (!resp.ok) { + throw new Error(data?.error || 'Failed to sign in'); + } - /* ---------------- success path ---------------- */ - const { token, id, user } = data; + // ---------------- success path ---------------- - // fetch current user profile immediately - const profileRes = await fetch('/api/user-profile', { - headers: { Authorization: `Bearer ${token}` } - }); - const profile = await profileRes.json(); - setFinancialProfile(profile); - setScenario(null); // or fetch latest scenario separately + const profile = await apiGetJSON('/api/user-profile'); + const { user } = data; + setFinancialProfile(profile); + setScenario(null); - /* purge any leftovers from prior session */ - ['careerSuggestionsCache', - 'lastSelectedCareerProfileId', - 'aiClickCount', - 'aiClickDate', - 'aiRecommendations', - 'premiumOnboardingState', - 'financialProfile', - 'selectedScenario' - ].forEach(k => localStorage.removeItem(k)); + // Mark auth in your app state + setIsAuthenticated(true); + setUser(profile); - /* store new session data */ - localStorage.setItem('token', token); - localStorage.setItem('id', id); - - setIsAuthenticated(true); - setUser(user); - navigate('/signin-landing'); - } catch (err) { - setError(err.message); - } - }; + // Navigate to your post-signin landing + // Respect `next` and clear ?session=expired + const params = new URLSearchParams(window.location.search); + const next = params.get('next'); + const url = new URL(window.location.href); + url.searchParams.delete('session'); + window.history.replaceState({}, '', url); // remove banner flag + navigate(next ? decodeURIComponent(next) : '/signin-landing', { replace: true }); + } catch (err) { + setError(err?.message || 'Sign in failed'); + } + }; return ( -
{showSessionExpiredMsg && (
Your session has expired. Please sign in again.
)} +

Sign In

@@ -141,7 +141,7 @@ function SignIn({ setIsAuthenticated, setUser }) { Forgot your password?
-
+ ); diff --git a/src/components/UserProfile.js b/src/components/UserProfile.js index 2cc01bc..82d9a17 100644 --- a/src/components/UserProfile.js +++ b/src/components/UserProfile.js @@ -1,12 +1,13 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import ChangePasswordForm from './ChangePasswordForm.js'; +import authFetch from '../utils/authFetch.js'; function UserProfile() { const [firstName, setFirstName] = useState(''); - const [lastName, setLastName] = useState(''); - const [email, setEmail] = useState(''); - const [zipCode, setZipCode] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [zipCode, setZipCode] = useState(''); const [selectedState, setSelectedState] = useState(''); const [areas, setAreas] = useState([]); const [selectedArea, setSelectedArea] = useState(''); @@ -14,51 +15,20 @@ function UserProfile() { const [loadingAreas, setLoadingAreas] = useState(false); const [isPremiumUser, setIsPremiumUser] = useState(false); const [phoneE164, setPhoneE164] = useState(''); - const [smsOptIn, setSmsOptIn] = useState(false); + const [smsOptIn, setSmsOptIn] = useState(false); const [showChangePw, setShowChangePw] = useState(false); const navigate = useNavigate(); - // Helper to do authorized fetch - const authFetch = async (url, options = {}) => { - const token = localStorage.getItem('token'); - if (!token) { - navigate('/signin'); - return null; - } - - const res = await fetch(url, { - ...options, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - if ([401, 403].includes(res.status)) { - console.warn('Token invalid or expired. Redirecting to Sign In.'); - navigate('/signin'); - return null; - } - - return res; - }; - + // --- Load profile (cookies via authFetch) and initial areas (if state present) useEffect(() => { const fetchProfileAndAreas = async () => { try { - const token = localStorage.getItem('token'); - if (!token) return; - - const res = await authFetch('/api/user-profile', { - method: 'GET', - }); - - if (!res || !res.ok) return; - + const res = await authFetch('/api/user-profile', { method: 'GET' }); + if (!res || !res.ok) return; // shim will redirect on 401 const data = await res.json(); + // Map exact server fields setFirstName(data.firstname || ''); setLastName(data.lastname || ''); setEmail(data.email || ''); @@ -68,21 +38,21 @@ function UserProfile() { setCareerSituation(data.career_situation || ''); setPhoneE164(data.phone_e164 || ''); setSmsOptIn(!!data.sms_opt_in); - - if (data.is_premium === 1) { - setIsPremiumUser(true); - } + setIsPremiumUser(data.is_premium === 1); // If we have a state, load its areas if (data.state) { setLoadingAreas(true); try { - const areaRes = await authFetch(`/api/areas?state=${data.state}`); - if (!areaRes || !areaRes.ok) { - throw new Error('Failed to fetch areas'); - } + const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`); + if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas'); const areaData = await areaRes.json(); - setAreas(areaData.areas); + const list = Array.isArray(areaData.areas) ? areaData.areas : []; + setAreas(list); + // If current selectedArea isn't in the new list, clear it + if (list.length && !list.includes(data.area)) { + setSelectedArea(''); + } } catch (areaErr) { console.error('Error fetching areas:', areaErr); setAreas([]); @@ -96,35 +66,28 @@ function UserProfile() { }; fetchProfileAndAreas(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // only runs once + }, []); - // Whenever user changes "selectedState", re-fetch areas + // --- When user changes state, re-fetch areas (cookies only) useEffect(() => { const fetchAreasByState = async () => { if (!selectedState) { setAreas([]); + setSelectedArea(''); return; } setLoadingAreas(true); try { - const token = localStorage.getItem('token'); - if (!token) return; - - const areaRes = await fetch(`/api/areas?state=${selectedState}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!areaRes.ok) { - throw new Error('Failed to fetch areas'); + const res = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`); + if (!res || !res.ok) throw new Error('Failed to fetch areas'); + const data = await res.json(); + const list = Array.isArray(data.areas) ? data.areas : []; + setAreas(list); + if (list.length && !list.includes(selectedArea)) { + setSelectedArea(''); } - - const areaData = await areaRes.json(); - setAreas(areaData.areas || []); - } catch (error) { - console.error('Error fetching areas:', error); + } catch (err) { + console.error('Error fetching areas:', err); setAreas([]); } finally { setLoadingAreas(false); @@ -132,12 +95,14 @@ function UserProfile() { }; fetchAreasByState(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedState]); const handleFormSubmit = async (e) => { e.preventDefault(); const profileData = { + // keep the POST field names you’re already using server-side firstName, lastName, email, @@ -152,9 +117,9 @@ function UserProfile() { try { const response = await authFetch('/api/user-profile', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileData), }); - if (!response || !response.ok) { throw new Error('Failed to save profile'); } @@ -185,24 +150,11 @@ function UserProfile() { { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, ]; - // The updated career situations (same as in SignUp.js) const careerSituations = [ - { - id: 'planning', - title: 'Planning Your Career', - }, - { - id: 'preparing', - title: 'Preparing for Your (Next) Career', - }, - { - id: 'enhancing', - title: 'Enhancing Your Career', - }, - { - id: 'retirement', - title: 'Retirement Planning', - }, + { id: 'planning', title: 'Planning Your Career' }, + { id: 'preparing', title: 'Preparing for Your (Next) Career' }, + { id: 'enhancing', title: 'Enhancing Your Career' }, + { id: 'retirement', title: 'Retirement Planning' }, ]; return ( @@ -213,9 +165,7 @@ function UserProfile() {
{/* First Name */}
- + - + - + - +
- {/* State Dropdown */} + {/* State */}
- + setSelectedArea(e.target.value)} @@ -305,8 +243,8 @@ function UserProfile() { className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none" > - {areas.map((area, index) => ( - ))} @@ -314,6 +252,7 @@ function UserProfile() {
)} + {/* Phone + SMS opt-in */}
-
- + {/* Change password */} +
+ + + {showChangePw && ( +
+ setShowChangePw(false)} /> +
+ )} +
- {showChangePw && ( -
- setShowChangePw(false)} /> -
- )} -
- {/* Form Buttons */}