This commit is contained in:
parent
60e2673539
commit
761f511601
2
.env
2
.env
@ -2,7 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
|
||||
SERVER1_PORT=5000
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
IMG_TAG=ed1fdbb-202508121553
|
||||
IMG_TAG=fb2e052-202508131933
|
||||
|
||||
ENV_NAME=dev
|
||||
PROJECT=aptivaai-dev
|
42
.env.production
Normal file
42
.env.production
Normal file
@ -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<g+2DO-eTb2mb5
|
||||
DB_NAME=user_profile_db
|
||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
||||
|
||||
# ── Twilio (needed only by server3) ─────────────────────────
|
||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
||||
|
||||
# ─── Anything new goes here ──────────────
|
||||
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
|
||||
|
||||
# ------------ env/prod.env ------------
|
||||
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
|
||||
SERVER1_PORT=5000
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
|
||||
IMG_TAG=20250716
|
@ -110,6 +110,14 @@ steps:
|
||||
export DEK_PATH; \
|
||||
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
|
||||
export SUPPORT_SENDGRID_API_KEY; \
|
||||
ACCESS_COOKIE_NAME=$(gcloud secrets versions access latest --secret=ACCESS_COOKIE_NAME_$ENV --project=$PROJECT); \
|
||||
export ACCESS_COOKIE_NAME; \
|
||||
COOKIE_SECURE=$(gcloud secrets versions access latest --secret=COOKIE_SECURE_$ENV --project=$PROJECT); \
|
||||
export COOKIE_SECURE; \
|
||||
COOKIE_SAMESITE=$(gcloud secrets versions access latest --secret=COOKIE_SAMESITE_$ENV --project=$PROJECT); \
|
||||
export COOKIE_SAMESITE; \
|
||||
TOKEN_MAX_AGE_MS=$(gcloud secrets versions access latest --secret=TOKEN_MAX_AGE_MS_$ENV --project=$PROJECT); \
|
||||
export TOKEN_MAX_AGE_MS; \
|
||||
export FROM_SECRETS_MANAGER=true; \
|
||||
\
|
||||
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
|
||||
@ -127,9 +135,9 @@ steps:
|
||||
fi; \
|
||||
\
|
||||
cd /home/jcoakley/aptiva-staging-app; \
|
||||
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \
|
||||
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
|
||||
docker compose pull; \
|
||||
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \
|
||||
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,ACCESS_COOKIE_NAME,COOKIE_SECURE,COOKIE_SAMESITE,TOKEN_MAX_AGE_MS \
|
||||
docker compose up -d --force-recreate --remove-orphans; \
|
||||
echo "✅ Staging stack refreshed with tag $IMG_TAG"'
|
||||
|
||||
|
@ -2,7 +2,6 @@
|
||||
import cron from 'node-cron';
|
||||
import pool from '../config/mysqlPool.js';
|
||||
import { sendSMS } from '../utils/smsService.js';
|
||||
import { query } from '../shared/db/withEncryption.js';
|
||||
|
||||
const BATCH_SIZE = 25; // tune as you like
|
||||
|
||||
|
@ -15,6 +15,7 @@ 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';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
const CANARY_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS encryption_canary (
|
||||
@ -83,6 +84,7 @@ try {
|
||||
Express app & middleware
|
||||
---------------------------------------------------------------- */
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
const PORT = process.env.SERVER1_PORT || 5000;
|
||||
|
||||
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
||||
@ -93,8 +95,8 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
@ -227,6 +229,7 @@ app.options('*', (req, res) => {
|
||||
'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,12 +605,14 @@ 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,
|
||||
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
|
||||
@ -665,13 +694,13 @@ 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({
|
||||
return res.status(200).json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
token, // optional
|
||||
id: row.userProfileId,
|
||||
user: profile
|
||||
});
|
||||
@ -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
|
||||
|
@ -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,74 +1162,73 @@ 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;
|
||||
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' });
|
||||
|
||||
// 1) email priority: token → DB decrypted → request body
|
||||
let accountEmail = req.user?.email || req.user?.mail || null;
|
||||
|
||||
if (!accountEmail) {
|
||||
try {
|
||||
const row = await userProfileDb.get(
|
||||
'SELECT email FROM user_profile WHERE id = ?',
|
||||
const [rows] = await pool.query(
|
||||
'SELECT email FROM user_profile WHERE id = ? LIMIT 1',
|
||||
[userId]
|
||||
);
|
||||
accountEmail = row?.email || null;
|
||||
const enc = rows?.[0]?.email || null;
|
||||
if (enc) {
|
||||
try { accountEmail = decrypt(enc); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!accountEmail) {
|
||||
accountEmail = (req.body && req.body.email) || null;
|
||||
}
|
||||
if (!accountEmail) accountEmail = req.body?.email || null;
|
||||
if (!accountEmail) {
|
||||
return res.status(400).json({ error: 'No email on file for this user' });
|
||||
}
|
||||
|
||||
const { subject = '', category = 'general', message = '' } = req.body || {};
|
||||
// 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();
|
||||
|
||||
// Basic validation
|
||||
const allowedCats = new Set(['general','billing','technical','data','ux']);
|
||||
const subj = subject.toString().slice(0, 120).trim();
|
||||
const body = message.toString().trim();
|
||||
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' });
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// Dedupe
|
||||
const key = makeKey(userId, subj || '(no subject)', body);
|
||||
if (isDuplicateAndRemember(key)) {
|
||||
// 3) de-dup
|
||||
const dedupeKey = `${userId}::${category}::${subject}::${message}`;
|
||||
if (_isDupAndRemember(dedupeKey)) {
|
||||
return res.status(202).json({ ok: true, deduped: true });
|
||||
}
|
||||
|
||||
// Require mail config
|
||||
const FROM = 'support@aptivaai.com';
|
||||
const TO = 'support@aptivaai.com';
|
||||
|
||||
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,
|
||||
@ -1233,18 +1236,14 @@ ${body}`;
|
||||
replyTo: accountEmail,
|
||||
subject: humanSubject,
|
||||
text: textBody,
|
||||
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${textBody}</pre>`,
|
||||
categories: ['support', String(category || 'general')]
|
||||
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${_escape(textBody)}</pre>`,
|
||||
categories: ['support', category]
|
||||
});
|
||||
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
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
|
||||
|
@ -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,6 +292,11 @@ app.post(
|
||||
default:
|
||||
// Ignore everything else
|
||||
}
|
||||
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:
|
||||
@ -3905,6 +3779,8 @@ app.post(
|
||||
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,
|
||||
@ -4007,6 +3881,9 @@ app.post(
|
||||
} catch (err) {
|
||||
console.error('Error optimizing resume:', err);
|
||||
res.status(500).json({ error: 'Failed to optimize resume.' });
|
||||
} finally {
|
||||
// always clean up the temp upload
|
||||
safeUnlink();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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 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.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' });
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
);
|
||||
|
23
package-lock.json
generated
23
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
140
src/App.js
140
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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
const onStorage = (e) => {
|
||||
if (e.key === 'token' && !e.newValue) {
|
||||
// another tab cleared the token
|
||||
clearToken();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
navigate('/signin?session=expired');
|
||||
})
|
||||
.finally(() => {
|
||||
// Either success or fail, we're done loading
|
||||
}
|
||||
};
|
||||
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);
|
||||
});
|
||||
}, [navigate, location.pathname]);
|
||||
}
|
||||
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]);
|
||||
|
||||
|
||||
// ==========================
|
||||
// 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 {}
|
||||
});
|
||||
|
||||
// Clear in-memory token
|
||||
try { await fetch('/api/logout', { method: 'POST', credentials: 'include' }); } catch {}
|
||||
try { clearToken(); } 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
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
|
14
src/auth/ProtectedRoute.js
Normal file
14
src/auth/ProtectedRoute.js
Normal file
@ -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 <Navigate to={`/signin?next=${next}`} replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
@ -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 });
|
||||
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})`;
|
||||
}
|
31
src/auth/installAxiosAuthShim.js
Normal file
31
src/auth/installAxiosAuthShim.js
Normal file
@ -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);
|
||||
});
|
||||
}
|
135
src/auth/installFetchAuthShim.js
Normal file
135
src/auth/installFetchAuthShim.js
Normal file
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
16
src/auth/useAuthGuard.js
Normal file
16
src/auth/useAuthGuard.js
Normal file
@ -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;
|
||||
};
|
||||
}
|
@ -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 && (
|
||||
<CareerModal
|
||||
career={selectedCareer}
|
||||
careerDetails={careerDetails}
|
||||
|
@ -4,6 +4,8 @@ import CareerSearch from './CareerSearch.js';
|
||||
import { ONET_DEFINITIONS } from './definitions.js';
|
||||
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
||||
import ChatCtx from '../contexts/ChatCtx.js';
|
||||
import { onboardingState } from '../utils/onboardingState.js';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
|
||||
// Helper to combine IM and LV for each KSA
|
||||
function combineIMandLV(rows) {
|
||||
@ -143,10 +145,12 @@ function normalizeCipList(arr) {
|
||||
'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
|
||||
);
|
||||
if (proceed) {
|
||||
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
|
||||
storedOnboarding.collegeData = storedOnboarding.collegeData || {};
|
||||
storedOnboarding.collegeData.selectedSchool = school; // or any property name
|
||||
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding));
|
||||
const cur = onboardingState.get();
|
||||
const next = {
|
||||
...cur,
|
||||
collegeData: { ...(cur.collegeData || {}), selectedSchool: school }
|
||||
};
|
||||
onboardingState.set(next);
|
||||
navigate('/career-roadmap', { state: { selectedSchool: school } });
|
||||
}
|
||||
};
|
||||
@ -246,14 +250,7 @@ useEffect(() => {
|
||||
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) {
|
||||
|
@ -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 { 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);
|
||||
@ -24,15 +24,21 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
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;
|
||||
@ -47,54 +53,48 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
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();
|
||||
const profile = await apiGetJSON('/api/user-profile');
|
||||
const { user } = data;
|
||||
setFinancialProfile(profile);
|
||||
setScenario(null); // or fetch latest scenario separately
|
||||
|
||||
/* purge any leftovers from prior session */
|
||||
['careerSuggestionsCache',
|
||||
'lastSelectedCareerProfileId',
|
||||
'aiClickCount',
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'financialProfile',
|
||||
'selectedScenario'
|
||||
].forEach(k => localStorage.removeItem(k));
|
||||
|
||||
/* store new session data */
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('id', id);
|
||||
setScenario(null);
|
||||
|
||||
// Mark auth in your app state
|
||||
setIsAuthenticated(true);
|
||||
setUser(user);
|
||||
navigate('/signin-landing');
|
||||
setUser(profile);
|
||||
|
||||
// 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);
|
||||
setError(err?.message || 'Sign in failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
||||
{showSessionExpiredMsg && (
|
||||
<div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded">
|
||||
Your session has expired. Please sign in again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-sm rounded-md bg-white p-6 shadow-md">
|
||||
<h1 className="mb-6 text-center text-2xl font-semibold">Sign In</h1>
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
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('');
|
||||
@ -19,46 +20,15 @@ function UserProfile() {
|
||||
|
||||
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() {
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
First Name:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
@ -227,9 +177,7 @@ function UserProfile() {
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Last Name:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
@ -241,9 +189,7 @@ function UserProfile() {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Email:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@ -255,9 +201,7 @@ function UserProfile() {
|
||||
|
||||
{/* ZIP Code */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
ZIP Code:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zipCode}
|
||||
@ -267,11 +211,9 @@ function UserProfile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* State Dropdown */}
|
||||
{/* State */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
State:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
|
||||
<select
|
||||
value={selectedState}
|
||||
onChange={(e) => setSelectedState(e.target.value)}
|
||||
@ -288,16 +230,12 @@ function UserProfile() {
|
||||
</div>
|
||||
|
||||
{/* Loading indicator for areas */}
|
||||
{loadingAreas && (
|
||||
<p className="text-sm text-gray-500">Loading areas...</p>
|
||||
)}
|
||||
{loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
|
||||
|
||||
{/* Areas Dropdown */}
|
||||
{/* Areas */}
|
||||
{!loadingAreas && areas.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Area:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="">Select an Area</option>
|
||||
{areas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{areas.map((area, idx) => (
|
||||
<option key={idx} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
))}
|
||||
@ -314,6 +252,7 @@ function UserProfile() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone + SMS opt-in */}
|
||||
<div className="mt-4">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
|
||||
<input
|
||||
@ -352,6 +291,7 @@ function UserProfile() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Change password */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
@ -378,7 +318,7 @@ function UserProfile() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/getting-started')}
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded bg-gray-300 px-5 py-2 text-gray-700 hover:bg-gray-400"
|
||||
>
|
||||
Go Back
|
||||
|
38
src/index.js
38
src/index.js
@ -5,9 +5,39 @@ 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';
|
||||
import { installStorageGuard, scrubLegacyPII } from './utils/storageGuard.js';
|
||||
import { installFetchAuthShim } from './auth/installFetchAuthShim.js';
|
||||
import { installAxiosAuthShim } from './auth/installAxiosAuthShim.js';
|
||||
|
||||
installStorageGuard({
|
||||
mode: 'divert',
|
||||
debug: false,
|
||||
denyAll: true, // ⟵ this is the big lever
|
||||
// keep only truly harmless stuff on disk (optional)
|
||||
allowKeys: ['ui_theme', 'cookieConsent', 'careerSuggestionsCache'],
|
||||
allowPrefixes: ['coachChat:']
|
||||
});
|
||||
scrubLegacyPII();
|
||||
|
||||
installFetchAuthShim({
|
||||
debug: false, // flip to true to trace requests
|
||||
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',
|
||||
],
|
||||
});
|
||||
|
||||
installAxiosAuthShim({ debug: false }); // axios → cookies + 401 handler
|
||||
|
||||
installStorageGuard(); // Initialize storage guard
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
@ -18,8 +48,4 @@ root.render(
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
|
||||
|
@ -1,35 +1,19 @@
|
||||
let onSessionExpiredCallback = null;
|
||||
// keep this file name/location so existing imports keep working
|
||||
import { apiFetch } from '../auth/apiFetch.js';
|
||||
|
||||
export const setSessionExpiredCallback = (callback) => {
|
||||
onSessionExpiredCallback = callback;
|
||||
};
|
||||
let onExpired = null;
|
||||
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
onSessionExpiredCallback?.();
|
||||
return null;
|
||||
/** Back-compat: allow callers to register a session-expired handler. */
|
||||
export function setSessionExpiredCallback(fn) {
|
||||
onExpired = (typeof fn === 'function') ? fn : null;
|
||||
// expose for the fetch shim so it can call into here
|
||||
try { window.__aptivaOnSessionExpired = onExpired; } catch {}
|
||||
}
|
||||
|
||||
const method = options.method?.toUpperCase() || 'GET';
|
||||
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if ([401, 403].includes(res.status)) {
|
||||
onSessionExpiredCallback?.();
|
||||
return null;
|
||||
export default async function authFetch(url, options = {}) {
|
||||
const res = await apiFetch(url, options);
|
||||
if ([401, 403, 419, 440].includes(res.status) && typeof onExpired === 'function') {
|
||||
try { onExpired({ url, status: res.status, response: res }); } catch {}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export default authFetch;
|
||||
}
|
||||
|
15
src/utils/onboardingState.js
Normal file
15
src/utils/onboardingState.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { safeStorage } from './storageGuard.js';
|
||||
const KEY = 'premiumOnboardingState';
|
||||
|
||||
export const onboardingState = {
|
||||
get() {
|
||||
try { return JSON.parse(safeStorage.get(KEY) || '{}'); }
|
||||
catch { return {}; }
|
||||
},
|
||||
set(patch) {
|
||||
const cur = onboardingState.get();
|
||||
const next = { ...cur, ...patch };
|
||||
safeStorage.set(KEY, JSON.stringify(next));
|
||||
},
|
||||
clear() { safeStorage.remove(KEY); }
|
||||
};
|
@ -1,23 +1,173 @@
|
||||
// storageGuard.js
|
||||
const RESTRICTED_SUBSTRINGS = [
|
||||
// src/utils/storageGuard.js
|
||||
|
||||
// Default substring guards (case-insensitive)
|
||||
const DEFAULT_DENY_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);
|
||||
|
||||
// A small set of explicit keys we know should never hit disk.
|
||||
const DEFAULT_DENY_KEYS = [
|
||||
'financialProfile',
|
||||
'premiumOnboardingState',
|
||||
'selectedScenario',
|
||||
'aiRecommendations',
|
||||
'careerSuggestionsCache',
|
||||
'lastSelectedCareerProfileId',
|
||||
'selectedCareer',
|
||||
// legacy
|
||||
'token','id'
|
||||
];
|
||||
|
||||
/**
|
||||
* Decide if a key should be diverted/blocked
|
||||
*/
|
||||
function buildShouldDeny({ denyKeys, denySubstrings }) {
|
||||
const denySet = new Set((denyKeys || []).map(k => String(k || '')));
|
||||
const subs = (denySubstrings || []).map(s => String(s || '').toLowerCase());
|
||||
|
||||
return function shouldDeny(key) {
|
||||
const k = String(key || '');
|
||||
if (denySet.has(k)) return true;
|
||||
const lower = k.toLowerCase();
|
||||
return subs.some(s => s && lower.includes(s));
|
||||
};
|
||||
}
|
||||
export function installStorageGuard() {
|
||||
try { wrap(window.localStorage); } catch {}
|
||||
try { wrap(window.sessionStorage); } catch {}
|
||||
|
||||
/**
|
||||
* Wrap a Storage object (localStorage/sessionStorage)
|
||||
* mode: 'divert' (default, production-safe) or 'block' (throw; great in dev)
|
||||
* debug: console.debug minimal notices
|
||||
*/
|
||||
function wrapStorage(storage, {
|
||||
shouldDeny,
|
||||
mode = 'divert',
|
||||
debug = false,
|
||||
vault
|
||||
}) {
|
||||
if (!storage) return;
|
||||
|
||||
const _setItem = storage.setItem.bind(storage);
|
||||
const _getItem = storage.getItem.bind(storage);
|
||||
const _removeItem = storage.removeItem.bind(storage);
|
||||
const _clear = storage.clear.bind(storage);
|
||||
const _key = storage.key.bind(storage);
|
||||
|
||||
storage.setItem = (k, v) => {
|
||||
if (shouldDeny(k)) {
|
||||
if (mode === 'block') {
|
||||
throw new Error(`[storageGuard] Blocked setItem("${k}"). Sensitive data is not allowed in Web Storage.`);
|
||||
}
|
||||
// divert to memory
|
||||
vault.set(k, String(v ?? ''));
|
||||
if (debug) console.debug(`[storageGuard] diverted setItem("${k}") to memory`);
|
||||
// fire a custom event for app tooling/tests if desired
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('aptiva:storage-divert', { detail: { key: k } }));
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
return _setItem(k, v);
|
||||
};
|
||||
|
||||
storage.getItem = (k) => {
|
||||
if (shouldDeny(k)) {
|
||||
return vault.has(k) ? vault.get(k) : null;
|
||||
}
|
||||
return _getItem(k);
|
||||
};
|
||||
|
||||
storage.removeItem = (k) => {
|
||||
if (shouldDeny(k)) {
|
||||
vault.delete(k);
|
||||
if (debug) console.debug(`[storageGuard] diverted removeItem("${k}")`);
|
||||
return;
|
||||
}
|
||||
return _removeItem(k);
|
||||
};
|
||||
|
||||
// Clear disk entries but keep diverted keys strictly in memory semantics
|
||||
storage.clear = () => {
|
||||
// clear the in-memory vault too
|
||||
vault.clear();
|
||||
return _clear();
|
||||
};
|
||||
|
||||
// NOTE: we keep .key()/.length behavior as native (disk only).
|
||||
// Calls to .key() won't enumerate diverted keys. That's ok for our app usage.
|
||||
storage.key = (i) => _key(i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public install function
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {('divert'|'block')} options.mode - default 'divert' (safe). Use 'block' in dev to catch offenders.
|
||||
* @param {boolean} options.debug - console.debug notices
|
||||
* @param {string[]} options.denyKeys - explicit keys to deny
|
||||
* @param {string[]} options.denySubstrings - substrings to deny
|
||||
*/
|
||||
export function installStorageGuard(options = {}) {
|
||||
const {
|
||||
mode = (process.env.NODE_ENV === 'production' ? 'divert' : 'divert'),
|
||||
debug = false,
|
||||
denyKeys = DEFAULT_DENY_KEYS,
|
||||
denySubstrings = DEFAULT_DENY_SUBSTRINGS,
|
||||
// NEW: strong default deny with small allowlist
|
||||
denyAll = false,
|
||||
allowKeys = [],
|
||||
allowPrefixes = [],
|
||||
shouldDeny: customShouldDeny
|
||||
} = options;
|
||||
|
||||
const allowSet = new Set(allowKeys.map(String));
|
||||
const allowPref = allowPrefixes.map(String);
|
||||
|
||||
const baseShouldDeny = customShouldDeny || buildShouldDeny({ denyKeys, denySubstrings });
|
||||
const shouldDeny = denyAll
|
||||
? (key) => {
|
||||
const k = String(key || '');
|
||||
if (allowSet.has(k)) return false;
|
||||
if (allowPref.some(p => p && k.startsWith(p))) return false;
|
||||
return true; // deny everything else
|
||||
}
|
||||
: baseShouldDeny;
|
||||
|
||||
// a single vault per tab/session for both storages
|
||||
const vault = new Map();
|
||||
|
||||
try { wrapStorage(window.localStorage, { shouldDeny, mode, debug, vault }); } catch {}
|
||||
try { wrapStorage(window.sessionStorage, { shouldDeny, mode, debug, vault }); } catch {}
|
||||
|
||||
// expose for rare debugging/migration
|
||||
try { window.__aptivaMemVault = vault; } catch {}
|
||||
|
||||
if (debug) console.debug('[storageGuard] installed', { mode, denyKeys, denySubstringsCount: denySubstrings.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time scrub of legacy PII left on disk
|
||||
* Call this once on boot BEFORE React mounts (after install is fine too).
|
||||
*/
|
||||
export function scrubLegacyPII(overrideKeys) {
|
||||
const keys = overrideKeys && overrideKeys.length ? overrideKeys : DEFAULT_DENY_KEYS;
|
||||
for (const k of keys) {
|
||||
try { window.localStorage.removeItem(k); } catch {}
|
||||
try { window.sessionStorage.removeItem(k); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional helper API for future refactors:
|
||||
* Use this when you want an explicit PII-safe stash w/o touching Web Storage.
|
||||
*/
|
||||
export const safeStorage = (() => {
|
||||
const vault = (typeof window !== 'undefined' && window.__aptivaMemVault) ? window.__aptivaMemVault : new Map();
|
||||
return {
|
||||
set(key, value) { vault.set(String(key), String(value ?? '')); },
|
||||
get(key) { return vault.get(String(key)) ?? null; },
|
||||
remove(key) { vault.delete(String(key)); },
|
||||
has(key) { return vault.has(String(key)); },
|
||||
clear() { vault.clear(); }
|
||||
};
|
||||
})();
|
||||
|
Loading…
Reference in New Issue
Block a user