This commit is contained in:
parent
2d9e63af32
commit
d71b026ce0
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
|
SERVER1_PORT=5000
|
||||||
SERVER2_PORT=5001
|
SERVER2_PORT=5001
|
||||||
SERVER3_PORT=5002
|
SERVER3_PORT=5002
|
||||||
IMG_TAG=ed1fdbb-202508121553
|
IMG_TAG=fb2e052-202508131933
|
||||||
|
|
||||||
ENV_NAME=dev
|
ENV_NAME=dev
|
||||||
PROJECT=aptivaai-dev
|
PROJECT=aptivaai-dev
|
@ -110,6 +110,14 @@ steps:
|
|||||||
export DEK_PATH; \
|
export DEK_PATH; \
|
||||||
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
|
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
|
||||||
export SUPPORT_SENDGRID_API_KEY; \
|
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; \
|
export FROM_SECRETS_MANAGER=true; \
|
||||||
\
|
\
|
||||||
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
|
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
|
||||||
@ -127,9 +135,9 @@ steps:
|
|||||||
fi; \
|
fi; \
|
||||||
\
|
\
|
||||||
cd /home/jcoakley/aptiva-staging-app; \
|
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; \
|
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; \
|
docker compose up -d --force-recreate --remove-orphans; \
|
||||||
echo "✅ Staging stack refreshed with tag $IMG_TAG"'
|
echo "✅ Staging stack refreshed with tag $IMG_TAG"'
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import pool from '../config/mysqlPool.js';
|
import pool from '../config/mysqlPool.js';
|
||||||
import { sendSMS } from '../utils/smsService.js';
|
import { sendSMS } from '../utils/smsService.js';
|
||||||
import { query } from '../shared/db/withEncryption.js';
|
|
||||||
|
|
||||||
const BATCH_SIZE = 25; // tune as you like
|
const BATCH_SIZE = 25; // tune as you like
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ import sgMail from '@sendgrid/mail';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { readFile } from 'fs/promises'; // ← needed for /healthz
|
import { readFile } from 'fs/promises'; // ← needed for /healthz
|
||||||
import { requireAuth } from './shared/requireAuth.js';
|
import { requireAuth } from './shared/requireAuth.js';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
const CANARY_SQL = `
|
const CANARY_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS encryption_canary (
|
CREATE TABLE IF NOT EXISTS encryption_canary (
|
||||||
@ -83,6 +84,7 @@ try {
|
|||||||
Express app & middleware
|
Express app & middleware
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
const PORT = process.env.SERVER1_PORT || 5000;
|
const PORT = process.env.SERVER1_PORT || 5000;
|
||||||
|
|
||||||
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
/* ─── 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.disable('x-powered-by');
|
||||||
app.use(bodyParser.json());
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
contentSecurityPolicy: false,
|
contentSecurityPolicy: false,
|
||||||
@ -227,6 +229,7 @@ app.options('*', (req, res) => {
|
|||||||
'Access-Control-Allow-Headers',
|
'Access-Control-Allow-Headers',
|
||||||
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
|
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
|
||||||
);
|
);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -284,6 +287,30 @@ const pwDailyLimiter = rateLimit({
|
|||||||
keyGenerator: (req) => req.ip,
|
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) {
|
async function setPasswordByEmail(email, bcryptHash) {
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE user_auth ua
|
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 (?, ?, ?)`;
|
const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
|
||||||
await pool.query(authQuery, [newProfileId, username, hashedPassword]);
|
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({
|
return res.status(201).json({
|
||||||
message: 'User registered successfully',
|
message: 'User registered successfully',
|
||||||
profileId: newProfileId,
|
profileId: newProfileId,
|
||||||
token,
|
token, // optional; frontend doesn’t need it anymore
|
||||||
user: {
|
user: {
|
||||||
username, firstname, lastname, email: emailNorm, zipcode, state, area,
|
username, firstname, lastname, email: emailNorm, zipcode, state, area,
|
||||||
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
|
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
|
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
|
||||||
if (err.code === 'ER_DUP_ENTRY') {
|
if (err.code === 'ER_DUP_ENTRY') {
|
||||||
@ -665,16 +694,16 @@ app.post('/api/signin', async (req, res) => {
|
|||||||
if (profile?.email) {
|
if (profile?.email) {
|
||||||
try { profile.email = decrypt(profile.email); } catch {}
|
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);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
message: 'Login successful',
|
||||||
|
token, // optional
|
||||||
res.status(200).json({
|
id: row.userProfileId,
|
||||||
message: 'Login successful',
|
user: profile
|
||||||
token,
|
});
|
||||||
id: row.userProfileId,
|
|
||||||
user: profile
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error querying user_auth:', err.message);
|
console.error('Error querying user_auth:', err.message);
|
||||||
return res
|
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
|
START SERVER
|
||||||
|
@ -21,8 +21,10 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import authenticateUser from './utils/authenticateUser.js';
|
import authenticateUser from './utils/authenticateUser.js';
|
||||||
import { vectorSearch } from "./utils/vectorSearch.js";
|
import { vectorSearch } from "./utils/vectorSearch.js";
|
||||||
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.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 sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@ -71,6 +73,8 @@ try {
|
|||||||
|
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(cookieParser());
|
||||||
const PORT = process.env.SERVER2_PORT || 5001;
|
const PORT = process.env.SERVER2_PORT || 5001;
|
||||||
|
|
||||||
function fprPathFromEnv() {
|
function fprPathFromEnv() {
|
||||||
@ -1158,93 +1162,88 @@ chatFreeEndpoint(app, {
|
|||||||
* Returns 429 Too Many Requests if limits exceeded
|
* Returns 429 Too Many Requests if limits exceeded
|
||||||
* Supports deduplication for 10 minutes
|
* Supports deduplication for 10 minutes
|
||||||
* *************************************************/
|
* *************************************************/
|
||||||
app.post(
|
const _supportSeen = new Map();
|
||||||
'/api/support',
|
function _isDupAndRemember(key, ttlMs = 5 * 60 * 1000) {
|
||||||
authenticateUser, // logged-in only
|
const now = Date.now();
|
||||||
supportBurstLimiter,
|
const last = _supportSeen.get(key);
|
||||||
supportDailyLimiter,
|
_supportSeen.set(key, now);
|
||||||
async (req, res) => {
|
// sweep occasionally
|
||||||
try {
|
for (const [k, t] of _supportSeen) if (now - t > ttlMs) _supportSeen.delete(k);
|
||||||
const user = req.user || {};
|
return last && (now - last) < ttlMs;
|
||||||
const userId = user.id || user.user_id || user.sub; // depends on your token
|
}
|
||||||
if (!userId) {
|
function _escape(s) {
|
||||||
return res.status(401).json({ error: 'Auth required' });
|
return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer token email; fall back to DB; last resort: body.email
|
app.post('/api/support', requireAuth, async (req, res) => {
|
||||||
let accountEmail = user.email || user.mail || null;
|
try {
|
||||||
if (!accountEmail) {
|
const userId = req.userId || req.user?.id;
|
||||||
try {
|
if (!userId) return res.status(401).json({ error: 'Auth required' });
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
if (!accountEmail) {
|
||||||
const allowedCats = new Set(['general','billing','technical','data','ux']);
|
try {
|
||||||
const subj = subject.toString().slice(0, 120).trim();
|
const [rows] = await pool.query(
|
||||||
const body = message.toString().trim();
|
'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))) {
|
// 2) validate payload
|
||||||
return res.status(400).json({ error: 'Invalid category' });
|
const subject = String(req.body?.subject || '').slice(0, 120).trim();
|
||||||
}
|
const category = String(req.body?.category || 'general');
|
||||||
if (body.length < 5) {
|
const message = String(req.body?.message || '').trim();
|
||||||
return res.status(400).json({ error: 'Message too short' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedupe
|
const allowed = new Set(['general','billing','technical','data','ux']);
|
||||||
const key = makeKey(userId, subj || '(no subject)', body);
|
if (!allowed.has(category)) return res.status(400).json({ error: 'Invalid category' });
|
||||||
if (isDuplicateAndRemember(key)) {
|
if (message.length < 5) return res.status(400).json({ error: 'Message too short' });
|
||||||
return res.status(202).json({ ok: true, deduped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require mail config
|
// 3) de-dup
|
||||||
const FROM = 'support@aptivaai.com';
|
const dedupeKey = `${userId}::${category}::${subject}::${message}`;
|
||||||
const TO = 'support@aptivaai.com';
|
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' });
|
return res.status(503).json({ error: 'Support email not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const humanSubject =
|
// 5) send
|
||||||
`[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`;
|
const humanSubject = `[Support • ${category}] ${subject || '(no subject)'} — user ${userId}`;
|
||||||
|
const textBody = `User: ${userId}
|
||||||
const textBody =
|
|
||||||
`User: ${userId}
|
|
||||||
Email: ${accountEmail}
|
Email: ${accountEmail}
|
||||||
Category: ${category}
|
Category: ${category}
|
||||||
|
|
||||||
${body}`;
|
${message}`;
|
||||||
|
|
||||||
await sgMail.send({
|
await sgMail.send({
|
||||||
to: TO,
|
to: TO,
|
||||||
from: FROM,
|
from: FROM,
|
||||||
replyTo: accountEmail,
|
replyTo: accountEmail,
|
||||||
subject: humanSubject,
|
subject: humanSubject,
|
||||||
text: textBody,
|
text: textBody,
|
||||||
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${textBody}</pre>`,
|
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${_escape(textBody)}</pre>`,
|
||||||
categories: ['support', String(category || 'general')]
|
categories: ['support', category]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return res.json({ ok: true });
|
||||||
return res.status(200).json({ ok: true });
|
} catch (err) {
|
||||||
} catch (err) {
|
console.error('[support] error:', err?.message || err);
|
||||||
console.error('[support] error:', err?.message || err);
|
return res.status(500).json({ error: 'Failed to send support message' });
|
||||||
return res.status(500).json({ error: 'Failed to send support message' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
||||||
* Start the Express server
|
* Start the Express server
|
||||||
|
@ -8,7 +8,6 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import fs, { readFile } from 'fs/promises'; // <-- add this
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
@ -16,6 +15,9 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pkg from 'pdfjs-dist';
|
import pkg from 'pdfjs-dist';
|
||||||
import pool from './config/mysqlPool.js';
|
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 OpenAI from 'openai';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
@ -27,6 +29,8 @@ import { hashForLookup } from './shared/crypto/encryption.js';
|
|||||||
|
|
||||||
import './jobs/reminderCron.js';
|
import './jobs/reminderCron.js';
|
||||||
import { cacheSummary } from "./utils/ctxCache.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 rootPath = path.resolve(__dirname, '..');
|
||||||
const env = (process.env.NODE_ENV || 'production');
|
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 PORT = process.env.SERVER3_PORT || 5002;
|
||||||
const API_BASE = `http://localhost:${PORT}/api`;
|
const API_BASE = `http://localhost:${PORT}/api`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ─── helper: canonical public origin ─────────────────────────── */
|
/* ─── helper: canonical public origin ─────────────────────────── */
|
||||||
const PUBLIC_BASE = (
|
const PUBLIC_BASE = (
|
||||||
process.env.APTIVA_AI_BASE
|
process.env.APTIVA_AI_BASE
|
||||||
@ -57,7 +63,11 @@ function isSafeRedirect(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(cookieParser());
|
||||||
const { getDocument } = pkg;
|
const { getDocument } = pkg;
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
|
||||||
|
|
||||||
// ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ──
|
// ── 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 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
|
// AI Risk Analysis Helper Functions
|
||||||
async function getRiskAnalysisFromDB(socCode) {
|
async function getRiskAnalysisFromDB(socCode) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
@ -230,6 +251,17 @@ app.post(
|
|||||||
return res.status(400).end();
|
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 upFlags = async (customerId, premium, pro) => {
|
||||||
const h = hashForLookup(customerId);
|
const h = hashForLookup(customerId);
|
||||||
console.log('[Stripe] upFlags', { customerId, premium, pro });
|
console.log('[Stripe] upFlags', { customerId, premium, pro });
|
||||||
@ -260,7 +292,12 @@ app.post(
|
|||||||
default:
|
default:
|
||||||
// Ignore everything else
|
// 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
|
// 3) Authentication middleware
|
||||||
const authenticatePremiumUser = (req, res, next) => {
|
const authenticatePremiumUser = (req, res, next) =>
|
||||||
const token = (req.headers.authorization || '')
|
requireAuth(req, res, () => {
|
||||||
.replace(/^Bearer\s+/i, '') // drop “Bearer ”
|
// preserve existing field name so routes don’t change
|
||||||
.trim(); // strip CR/LF, spaces
|
req.id = req.userId;
|
||||||
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
|
|
||||||
next();
|
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.
|
* Returns the user’s stripe_customer_id (or null) given req.id.
|
||||||
@ -742,180 +768,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/***************************************************
|
app.post('/api/premium/ai/chat', rlPremiumAI, authenticatePremiumUser, async (req, res) => {
|
||||||
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) => {
|
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
userProfile = {},
|
userProfile = {},
|
||||||
@ -1459,7 +1312,7 @@ ${avoidBlock}
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const NEEDS_OPS_CARD = !chatHistory.some(
|
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(
|
const NEEDS_CTX_CARD = !chatHistory.some(
|
||||||
@ -1476,8 +1329,14 @@ if (NEEDS_OPS_CARD) {
|
|||||||
messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
|
messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NEEDS_CTX_CARD || SEND_CTX_CARD)
|
if (SEND_CTX_CARD) {
|
||||||
messagesToSend.push({ role:"system", content: summaryText });
|
const systemPromptDetailedContext = `
|
||||||
|
[DETAILED USER PROFILE & CONTEXT]
|
||||||
|
${summaryText}
|
||||||
|
`.trim();
|
||||||
|
messagesToSend.push({ role: "system", content: systemPromptDetailedContext });
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// ② Per-turn contextual helpers (small!)
|
// ② Per-turn contextual helpers (small!)
|
||||||
messagesToSend.push(
|
messagesToSend.push(
|
||||||
@ -1692,7 +1551,7 @@ Check your Milestones tab. Let me know if you want any changes!
|
|||||||
RETIREMENT AI-CHAT ENDPOINT (clone + patch)
|
RETIREMENT AI-CHAT ENDPOINT (clone + patch)
|
||||||
─────────────────────────────────────────── */
|
─────────────────────────────────────────── */
|
||||||
app.post(
|
app.post(
|
||||||
'/api/premium/retirement/aichat',
|
'/api/premium/retirement/aichat', rlPremiumAI,
|
||||||
authenticatePremiumUser,
|
authenticatePremiumUser,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
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 {
|
try {
|
||||||
const {
|
const {
|
||||||
socCode,
|
socCode,
|
||||||
careerName,
|
careerName,
|
||||||
jobDescription,
|
jobDescription = '',
|
||||||
tasks = []
|
tasks = []
|
||||||
} = req.body;
|
} = 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.' });
|
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 = `
|
const prompt = `
|
||||||
The user has a career named: ${careerName}
|
The user has a career named: ${careerName}
|
||||||
Description: ${jobDescription}
|
Description: ${jd}
|
||||||
Tasks: ${tasks.join('; ')}
|
Tasks: ${safeTasks.join('; ')}
|
||||||
|
|
||||||
Provide AI automation risk analysis for the next 10 years.
|
Provide AI automation risk analysis for the next 10 years.
|
||||||
Return JSON exactly in this format:
|
Return JSON exactly in this format:
|
||||||
@ -3617,7 +3481,7 @@ let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
|
|||||||
(async function loadKsaJson() {
|
(async function loadKsaJson() {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json');
|
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);
|
onetKsaData = JSON.parse(raw);
|
||||||
|
|
||||||
// Build a set of unique KSA names for fuzzy search
|
// Build a set of unique KSA names for fuzzy search
|
||||||
@ -3763,7 +3627,8 @@ function processChatGPTKsa(chatGptKSA, ksaType) {
|
|||||||
// 6) The new route
|
// 6) The new route
|
||||||
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
|
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
|
||||||
const { socCode } = req.params;
|
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 {
|
try {
|
||||||
// 1) Check local data
|
// 1) Check local data
|
||||||
@ -3857,7 +3722,16 @@ return res.json({
|
|||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
|
||||||
// Setup file upload via multer
|
// 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) {
|
function buildResumePrompt(resumeText, jobTitle, jobDescription) {
|
||||||
// Full ChatGPT prompt for resume optimization:
|
// Full ChatGPT prompt for resume optimization:
|
||||||
@ -3902,9 +3776,11 @@ async function extractTextFromPDF(filePath) {
|
|||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/api/premium/resume/optimize',
|
'/api/premium/resume/optimize',
|
||||||
upload.single('resumeFile'),
|
upload.single('resumeFile'),
|
||||||
authenticatePremiumUser,
|
authenticatePremiumUser,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
const tmpPath = req.file?.path;
|
||||||
|
const safeUnlink = () => { try { if (tmpPath) fsSync.unlinkSync(tmpPath); } catch {} };
|
||||||
try {
|
try {
|
||||||
const { jobTitle, jobDescription } = req.body;
|
const { jobTitle, jobDescription } = req.body;
|
||||||
if (!jobTitle || !jobDescription || !req.file) {
|
if (!jobTitle || !jobDescription || !req.file) {
|
||||||
@ -3970,7 +3846,6 @@ app.post(
|
|||||||
const result = await mammoth.extractRawText({ path: filePath });
|
const result = await mammoth.extractRawText({ path: filePath });
|
||||||
resumeText = result.value;
|
resumeText = result.value;
|
||||||
} else {
|
} else {
|
||||||
await fs.unlink(filePath);
|
|
||||||
return res.status(400).json({ error: 'Unsupported or corrupted file upload.' });
|
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);
|
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
|
||||||
|
|
||||||
// remove uploaded file
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
optimizedResume,
|
optimizedResume,
|
||||||
@ -4005,8 +3879,11 @@ app.post(
|
|||||||
resetDate: resetDate.toISOString().slice(0, 10)
|
resetDate: resetDate.toISOString().slice(0, 10)
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error optimizing resume:', err);
|
console.error('Error optimizing resume:', err);
|
||||||
res.status(500).json({ error: 'Failed to optimize resume.' });
|
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 jwt from 'jsonwebtoken';
|
||||||
import pool from '../config/mysqlPool.js';
|
import pool from '../config/mysqlPool.js';
|
||||||
|
|
||||||
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env;
|
const { JWT_SECRET, TOKEN_MAX_AGE_MS, ACCESS_COOKIE_NAME = 'aptiva_access' } = process.env;
|
||||||
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
|
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) {
|
export async function requireAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const authz = req.headers.authorization || '';
|
const cookieToken = req.cookies?.[ACCESS_COOKIE_NAME];
|
||||||
const token = authz.startsWith('Bearer ') ? authz.slice(7) : '';
|
const bearerToken = extractBearer(req.headers.authorization);
|
||||||
|
const token = cookieToken || bearerToken; // cookie always wins
|
||||||
|
|
||||||
if (!token) return res.status(401).json({ error: 'Auth required' });
|
if (!token) return res.status(401).json({ error: 'Auth required' });
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try { payload = jwt.verify(token, JWT_SECRET); }
|
try { payload = jwt.verify(token, JWT_SECRET); }
|
||||||
catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
|
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;
|
const iatMs = (payload.iat || 0) * 1000;
|
||||||
|
|
||||||
// Absolute max token age (optional, off by default)
|
|
||||||
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
|
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
|
||||||
return res.status(401).json({ error: 'Session expired. Please sign in again.' });
|
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(
|
const [rows] = await (pool.raw || pool).query(
|
||||||
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
|
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
const changedAt = rows?.[0]?.password_changed_at || 0;
|
const changedAtMs = rows?.[0]?.password_changed_at ? new Date(rows[0].password_changed_at).getTime() : 0;
|
||||||
if (changedAt && iatMs < changedAt) {
|
if (changedAtMs && iatMs < changedAtMs) {
|
||||||
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
|
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();
|
next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[requireAuth]', e?.message || 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 \
|
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
|
||||||
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
|
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
|
||||||
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
|
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
|
KMS_KEY_NAME DEK_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -32,6 +32,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ENV_NAME: ${ENV_NAME}
|
ENV_NAME: ${ENV_NAME}
|
||||||
APTIVA_API_BASE: ${APTIVA_API_BASE}
|
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}
|
PROJECT: ${PROJECT}
|
||||||
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
||||||
DEK_PATH: ${DEK_PATH}
|
DEK_PATH: ${DEK_PATH}
|
||||||
@ -79,6 +83,10 @@ services:
|
|||||||
PROJECT: ${PROJECT}
|
PROJECT: ${PROJECT}
|
||||||
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
||||||
DEK_PATH: ${DEK_PATH}
|
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_USERNAME: ${ONET_USERNAME}
|
||||||
ONET_PASSWORD: ${ONET_PASSWORD}
|
ONET_PASSWORD: ${ONET_PASSWORD}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
@ -128,6 +136,10 @@ services:
|
|||||||
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
KMS_KEY_NAME: ${KMS_KEY_NAME}
|
||||||
DEK_PATH: ${DEK_PATH}
|
DEK_PATH: ${DEK_PATH}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
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}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||||
|
@ -179,3 +179,7 @@ UPDATE user_auth
|
|||||||
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
|
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
|
||||||
WHERE user_id = ?
|
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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
@ -7328,6 +7329,28 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
|
144
src/App.js
144
src/App.js
@ -41,7 +41,7 @@ import BillingResult from './components/BillingResult.js';
|
|||||||
import SupportModal from './components/SupportModal.js';
|
import SupportModal from './components/SupportModal.js';
|
||||||
import ForgotPassword from './components/ForgotPassword.js';
|
import ForgotPassword from './components/ForgotPassword.js';
|
||||||
import ResetPassword from './components/ResetPassword.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 || '');
|
setUserEmail(user?.email || '');
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// ==============================
|
/* Multi-tab signout listener */
|
||||||
// 1) Single Rehydrate UseEffect
|
|
||||||
// ==============================
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 🚫 Never hydrate auth while on the reset page
|
const onStorage = (e) => {
|
||||||
if (location.pathname.startsWith('/reset-password')) {
|
if (e.key === 'token' && !e.newValue) {
|
||||||
try {
|
// another tab cleared the token
|
||||||
localStorage.removeItem('token');
|
clearToken();
|
||||||
localStorage.removeItem('id');
|
setIsAuthenticated(false);
|
||||||
} catch {}
|
setUser(null);
|
||||||
setIsAuthenticated(false);
|
navigate('/signin?session=expired');
|
||||||
setUser(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
// No token => not authenticated
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
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
|
// 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 = () => {
|
// Reset React state/context
|
||||||
localStorage.removeItem('token');
|
setFinancialProfile(null);
|
||||||
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
|
|
||||||
setScenario(null);
|
setScenario(null);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowLogoutWarning(false);
|
setShowLogoutWarning(false);
|
||||||
|
|
||||||
// Reset auth
|
// Navigate to Sign In
|
||||||
setIsAuthenticated(false);
|
|
||||||
setUser(null);
|
|
||||||
setShowLogoutWarning(false);
|
|
||||||
|
|
||||||
navigate('/signin');
|
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
|
// src/auth/apiFetch.js
|
||||||
import { getToken } from './authMemory.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 DEFAULT_TIMEOUT_MS = 25000; // 25s
|
||||||
const headers = new Headers(init.headers || {});
|
|
||||||
const t = getToken();
|
export async function apiFetch(url, options = {}) {
|
||||||
if (t) headers.set('Authorization', `Bearer ${t}`);
|
const headers = new Headers(options.headers || {});
|
||||||
return fetch(input, { ...init, 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 () => {
|
const fetchUserProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const res = await axios.get('/api/user-profile');
|
||||||
const res = await axios.get('/api/user-profile', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const profileData = res.data;
|
const profileData = res.data;
|
||||||
@ -1054,7 +1051,7 @@ const handleSelectForEducation = async (career) => {
|
|||||||
defaultMeaning={modalData.defaultMeaning}
|
defaultMeaning={modalData.defaultMeaning}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedCareer && (
|
{selectedCareer && careerDetails && (
|
||||||
<CareerModal
|
<CareerModal
|
||||||
career={selectedCareer}
|
career={selectedCareer}
|
||||||
careerDetails={careerDetails}
|
careerDetails={careerDetails}
|
||||||
|
@ -4,6 +4,8 @@ import CareerSearch from './CareerSearch.js';
|
|||||||
import { ONET_DEFINITIONS } from './definitions.js';
|
import { ONET_DEFINITIONS } from './definitions.js';
|
||||||
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
|
||||||
import ChatCtx from '../contexts/ChatCtx.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
|
// Helper to combine IM and LV for each KSA
|
||||||
function combineIMandLV(rows) {
|
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?'
|
'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) {
|
if (proceed) {
|
||||||
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
|
const cur = onboardingState.get();
|
||||||
storedOnboarding.collegeData = storedOnboarding.collegeData || {};
|
const next = {
|
||||||
storedOnboarding.collegeData.selectedSchool = school; // or any property name
|
...cur,
|
||||||
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding));
|
collegeData: { ...(cur.collegeData || {}), selectedSchool: school }
|
||||||
|
};
|
||||||
|
onboardingState.set(next);
|
||||||
navigate('/career-roadmap', { state: { selectedSchool: school } });
|
navigate('/career-roadmap', { state: { selectedSchool: school } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -246,14 +250,7 @@ useEffect(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadUserProfile() {
|
async function loadUserProfile() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const res = await authFetch('/api/user-profile');
|
||||||
if (!token) {
|
|
||||||
console.warn('No token found, cannot load user-profile.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch('/api/user-profile', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch user profile');
|
if (!res.ok) throw new Error('Failed to fetch user profile');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUserZip(data.zipcode || '');
|
setUserZip(data.zipcode || '');
|
||||||
@ -588,19 +585,8 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
|
|||||||
setKsaError(null);
|
setKsaError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const resp = await authFetch(
|
||||||
if (!token) {
|
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`
|
||||||
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}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useState, useEffect, useContext } from 'react';
|
import React, { useRef, useState, useEffect, useContext } from 'react';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { ProfileCtx } from '../App.js';
|
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 }) {
|
function SignIn({ setIsAuthenticated, setUser }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -13,7 +14,6 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if the URL query param has ?session=expired
|
|
||||||
const query = new URLSearchParams(location.search);
|
const query = new URLSearchParams(location.search);
|
||||||
if (query.get('session') === 'expired') {
|
if (query.get('session') === 'expired') {
|
||||||
setShowSessionExpiredMsg(true);
|
setShowSessionExpiredMsg(true);
|
||||||
@ -21,80 +21,80 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
|
|
||||||
const handleSignIn = async (event) => {
|
const handleSignIn = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// 0️⃣ clear everything that belongs to the *previous* user
|
// 0️⃣ Clear anything that might carry over from a previous user/session
|
||||||
localStorage.removeItem('careerSuggestionsCache');
|
try {
|
||||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
[
|
||||||
localStorage.removeItem('aiClickCount');
|
'careerSuggestionsCache',
|
||||||
localStorage.removeItem('aiClickDate');
|
'lastSelectedCareerProfileId',
|
||||||
localStorage.removeItem('aiRecommendations');
|
'aiClickCount',
|
||||||
localStorage.removeItem('premiumOnboardingState');
|
'aiClickDate',
|
||||||
localStorage.removeItem('financialProfile'); // if you cache it
|
'aiRecommendations',
|
||||||
localStorage.removeItem('selectedScenario');
|
'premiumOnboardingState',
|
||||||
|
'financialProfile',
|
||||||
|
'selectedScenario',
|
||||||
|
'token', // legacy cleanup
|
||||||
|
'id' // legacy cleanup
|
||||||
|
].forEach((k) => localStorage.removeItem(k));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const username = usernameRef.current.value;
|
const username = usernameRef.current.value;
|
||||||
const password = passwordRef.current.value;
|
const password = passwordRef.current.value;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError('Please enter both username and password');
|
setError('Please enter both username and password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/signin', {
|
const resp = await fetch('/api/signin', {
|
||||||
method : 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body : JSON.stringify({username, password}),
|
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 ---------------- */
|
// ---------------- success path ----------------
|
||||||
const { token, id, user } = data;
|
|
||||||
|
|
||||||
// fetch current user profile immediately
|
const profile = await apiGetJSON('/api/user-profile');
|
||||||
const profileRes = await fetch('/api/user-profile', {
|
const { user } = data;
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
setFinancialProfile(profile);
|
||||||
});
|
setScenario(null);
|
||||||
const profile = await profileRes.json();
|
|
||||||
setFinancialProfile(profile);
|
|
||||||
setScenario(null); // or fetch latest scenario separately
|
|
||||||
|
|
||||||
/* purge any leftovers from prior session */
|
// Mark auth in your app state
|
||||||
['careerSuggestionsCache',
|
setIsAuthenticated(true);
|
||||||
'lastSelectedCareerProfileId',
|
setUser(profile);
|
||||||
'aiClickCount',
|
|
||||||
'aiClickDate',
|
|
||||||
'aiRecommendations',
|
|
||||||
'premiumOnboardingState',
|
|
||||||
'financialProfile',
|
|
||||||
'selectedScenario'
|
|
||||||
].forEach(k => localStorage.removeItem(k));
|
|
||||||
|
|
||||||
/* store new session data */
|
// Navigate to your post-signin landing
|
||||||
localStorage.setItem('token', token);
|
// Respect `next` and clear ?session=expired
|
||||||
localStorage.setItem('id', id);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const next = params.get('next');
|
||||||
setIsAuthenticated(true);
|
const url = new URL(window.location.href);
|
||||||
setUser(user);
|
url.searchParams.delete('session');
|
||||||
navigate('/signin-landing');
|
window.history.replaceState({}, '', url); // remove banner flag
|
||||||
} catch (err) {
|
navigate(next ? decodeURIComponent(next) : '/signin-landing', { replace: true });
|
||||||
setError(err.message);
|
} catch (err) {
|
||||||
}
|
setError(err?.message || 'Sign in failed');
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
||||||
{showSessionExpiredMsg && (
|
{showSessionExpiredMsg && (
|
||||||
<div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded">
|
<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.
|
Your session has expired. Please sign in again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full max-w-sm rounded-md bg-white p-6 shadow-md">
|
<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>
|
<h1 className="mb-6 text-center text-2xl font-semibold">Sign In</h1>
|
||||||
|
|
||||||
@ -141,7 +141,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ChangePasswordForm from './ChangePasswordForm.js';
|
import ChangePasswordForm from './ChangePasswordForm.js';
|
||||||
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
|
||||||
function UserProfile() {
|
function UserProfile() {
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState('');
|
const [lastName, setLastName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [zipCode, setZipCode] = useState('');
|
const [zipCode, setZipCode] = useState('');
|
||||||
const [selectedState, setSelectedState] = useState('');
|
const [selectedState, setSelectedState] = useState('');
|
||||||
const [areas, setAreas] = useState([]);
|
const [areas, setAreas] = useState([]);
|
||||||
const [selectedArea, setSelectedArea] = useState('');
|
const [selectedArea, setSelectedArea] = useState('');
|
||||||
@ -14,51 +15,20 @@ function UserProfile() {
|
|||||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||||
const [isPremiumUser, setIsPremiumUser] = useState(false);
|
const [isPremiumUser, setIsPremiumUser] = useState(false);
|
||||||
const [phoneE164, setPhoneE164] = useState('');
|
const [phoneE164, setPhoneE164] = useState('');
|
||||||
const [smsOptIn, setSmsOptIn] = useState(false);
|
const [smsOptIn, setSmsOptIn] = useState(false);
|
||||||
const [showChangePw, setShowChangePw] = useState(false);
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Helper to do authorized fetch
|
// --- Load profile (cookies via authFetch) and initial areas (if state present)
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProfileAndAreas = async () => {
|
const fetchProfileAndAreas = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const res = await authFetch('/api/user-profile', { method: 'GET' });
|
||||||
if (!token) return;
|
if (!res || !res.ok) return; // shim will redirect on 401
|
||||||
|
|
||||||
const res = await authFetch('/api/user-profile', {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res || !res.ok) return;
|
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Map exact server fields
|
||||||
setFirstName(data.firstname || '');
|
setFirstName(data.firstname || '');
|
||||||
setLastName(data.lastname || '');
|
setLastName(data.lastname || '');
|
||||||
setEmail(data.email || '');
|
setEmail(data.email || '');
|
||||||
@ -68,21 +38,21 @@ function UserProfile() {
|
|||||||
setCareerSituation(data.career_situation || '');
|
setCareerSituation(data.career_situation || '');
|
||||||
setPhoneE164(data.phone_e164 || '');
|
setPhoneE164(data.phone_e164 || '');
|
||||||
setSmsOptIn(!!data.sms_opt_in);
|
setSmsOptIn(!!data.sms_opt_in);
|
||||||
|
setIsPremiumUser(data.is_premium === 1);
|
||||||
if (data.is_premium === 1) {
|
|
||||||
setIsPremiumUser(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a state, load its areas
|
// If we have a state, load its areas
|
||||||
if (data.state) {
|
if (data.state) {
|
||||||
setLoadingAreas(true);
|
setLoadingAreas(true);
|
||||||
try {
|
try {
|
||||||
const areaRes = await authFetch(`/api/areas?state=${data.state}`);
|
const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`);
|
||||||
if (!areaRes || !areaRes.ok) {
|
if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
|
||||||
throw new Error('Failed to fetch areas');
|
|
||||||
}
|
|
||||||
const areaData = await areaRes.json();
|
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) {
|
} catch (areaErr) {
|
||||||
console.error('Error fetching areas:', areaErr);
|
console.error('Error fetching areas:', areaErr);
|
||||||
setAreas([]);
|
setAreas([]);
|
||||||
@ -96,35 +66,28 @@ function UserProfile() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchProfileAndAreas();
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchAreasByState = async () => {
|
const fetchAreasByState = async () => {
|
||||||
if (!selectedState) {
|
if (!selectedState) {
|
||||||
setAreas([]);
|
setAreas([]);
|
||||||
|
setSelectedArea('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingAreas(true);
|
setLoadingAreas(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const res = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`);
|
||||||
if (!token) return;
|
if (!res || !res.ok) throw new Error('Failed to fetch areas');
|
||||||
|
const data = await res.json();
|
||||||
const areaRes = await fetch(`/api/areas?state=${selectedState}`, {
|
const list = Array.isArray(data.areas) ? data.areas : [];
|
||||||
headers: {
|
setAreas(list);
|
||||||
Authorization: `Bearer ${token}`,
|
if (list.length && !list.includes(selectedArea)) {
|
||||||
},
|
setSelectedArea('');
|
||||||
});
|
|
||||||
|
|
||||||
if (!areaRes.ok) {
|
|
||||||
throw new Error('Failed to fetch areas');
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
const areaData = await areaRes.json();
|
console.error('Error fetching areas:', err);
|
||||||
setAreas(areaData.areas || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching areas:', error);
|
|
||||||
setAreas([]);
|
setAreas([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAreas(false);
|
setLoadingAreas(false);
|
||||||
@ -132,12 +95,14 @@ function UserProfile() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchAreasByState();
|
fetchAreasByState();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedState]);
|
}, [selectedState]);
|
||||||
|
|
||||||
const handleFormSubmit = async (e) => {
|
const handleFormSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const profileData = {
|
const profileData = {
|
||||||
|
// keep the POST field names you’re already using server-side
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
@ -152,9 +117,9 @@ function UserProfile() {
|
|||||||
try {
|
try {
|
||||||
const response = await authFetch('/api/user-profile', {
|
const response = await authFetch('/api/user-profile', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(profileData),
|
body: JSON.stringify(profileData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
if (!response || !response.ok) {
|
||||||
throw new Error('Failed to save profile');
|
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' },
|
{ name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// The updated career situations (same as in SignUp.js)
|
|
||||||
const careerSituations = [
|
const careerSituations = [
|
||||||
{
|
{ id: 'planning', title: 'Planning Your Career' },
|
||||||
id: 'planning',
|
{ id: 'preparing', title: 'Preparing for Your (Next) Career' },
|
||||||
title: 'Planning Your Career',
|
{ id: 'enhancing', title: 'Enhancing Your Career' },
|
||||||
},
|
{ id: 'retirement', title: 'Retirement Planning' },
|
||||||
{
|
|
||||||
id: 'preparing',
|
|
||||||
title: 'Preparing for Your (Next) Career',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'enhancing',
|
|
||||||
title: 'Enhancing Your Career',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'retirement',
|
|
||||||
title: 'Retirement Planning',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -213,9 +165,7 @@ function UserProfile() {
|
|||||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
{/* First Name */}
|
{/* First Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
|
||||||
First Name:
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={firstName}
|
value={firstName}
|
||||||
@ -227,9 +177,7 @@ function UserProfile() {
|
|||||||
|
|
||||||
{/* Last Name */}
|
{/* Last Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
|
||||||
Last Name:
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={lastName}
|
value={lastName}
|
||||||
@ -241,9 +189,7 @@ function UserProfile() {
|
|||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
|
||||||
Email:
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
@ -255,9 +201,7 @@ function UserProfile() {
|
|||||||
|
|
||||||
{/* ZIP Code */}
|
{/* ZIP Code */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
|
||||||
ZIP Code:
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={zipCode}
|
value={zipCode}
|
||||||
@ -267,11 +211,9 @@ function UserProfile() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* State Dropdown */}
|
{/* State */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
|
||||||
State:
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedState}
|
value={selectedState}
|
||||||
onChange={(e) => setSelectedState(e.target.value)}
|
onChange={(e) => setSelectedState(e.target.value)}
|
||||||
@ -288,16 +230,12 @@ function UserProfile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading indicator for areas */}
|
{/* Loading indicator for areas */}
|
||||||
{loadingAreas && (
|
{loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
|
||||||
<p className="text-sm text-gray-500">Loading areas...</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Areas Dropdown */}
|
{/* Areas */}
|
||||||
{!loadingAreas && areas.length > 0 && (
|
{!loadingAreas && areas.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
|
||||||
Area:
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={selectedArea}
|
value={selectedArea}
|
||||||
onChange={(e) => setSelectedArea(e.target.value)}
|
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"
|
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>
|
<option value="">Select an Area</option>
|
||||||
{areas.map((area, index) => (
|
{areas.map((area, idx) => (
|
||||||
<option key={index} value={area}>
|
<option key={idx} value={area}>
|
||||||
{area}
|
{area}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -314,6 +252,7 @@ function UserProfile() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phone + SMS opt-in */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
|
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
|
||||||
<input
|
<input
|
||||||
@ -352,21 +291,22 @@ function UserProfile() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
{/* Change password */}
|
||||||
<button
|
<div className="mt-8">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowChangePw(s => !s)}
|
type="button"
|
||||||
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
|
onClick={() => setShowChangePw(s => !s)}
|
||||||
>
|
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
|
||||||
{showChangePw ? 'Cancel password change' : 'Change password'}
|
>
|
||||||
</button>
|
{showChangePw ? 'Cancel password change' : 'Change password'}
|
||||||
|
</button>
|
||||||
|
|
||||||
{showChangePw && (
|
{showChangePw && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
|
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form Buttons */}
|
{/* Form Buttons */}
|
||||||
<div className="mt-6 flex items-center justify-end space-x-3">
|
<div className="mt-6 flex items-center justify-end space-x-3">
|
||||||
@ -378,7 +318,7 @@ function UserProfile() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="rounded bg-gray-300 px-5 py-2 text-gray-700 hover:bg-gray-400"
|
||||||
>
|
>
|
||||||
Go Back
|
Go Back
|
||||||
|
68
src/index.js
68
src/index.js
@ -1,25 +1,51 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
|
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
|
||||||
import reportWebVitals from './reportWebVitals.js';
|
import reportWebVitals from './reportWebVitals.js';
|
||||||
import { PageFlagsProvider } from './utils/PageFlagsContext.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(); // Initialize storage guard
|
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();
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
installFetchAuthShim({
|
||||||
root.render(
|
debug: false, // flip to true to trace requests
|
||||||
<BrowserRouter>
|
publicPaths: [
|
||||||
<PageFlagsProvider>
|
'/api/signin',
|
||||||
<App />
|
'/api/signup',
|
||||||
</PageFlagsProvider>
|
'/api/register',
|
||||||
</BrowserRouter>
|
'/api/check-username',
|
||||||
);
|
'/api/areas',
|
||||||
|
'/api/auth/password-reset/request',
|
||||||
|
'/api/auth/password-reset/confirm',
|
||||||
|
'/api/public',
|
||||||
|
'/livez',
|
||||||
|
'/readyz',
|
||||||
|
'/healthz',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
installAxiosAuthShim({ debug: false }); // axios → cookies + 401 handler
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
|
||||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|
||||||
reportWebVitals();
|
|
||||||
|
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<PageFlagsProvider>
|
||||||
|
<App />
|
||||||
|
</PageFlagsProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => {
|
let onExpired = null;
|
||||||
onSessionExpiredCallback = callback;
|
|
||||||
};
|
|
||||||
|
|
||||||
const authFetch = async (url, options = {}) => {
|
/** Back-compat: allow callers to register a session-expired handler. */
|
||||||
const token = localStorage.getItem('token');
|
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 {}
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
export default async function authFetch(url, options = {}) {
|
||||||
onSessionExpiredCallback?.();
|
const res = await apiFetch(url, options);
|
||||||
return null;
|
if ([401, 403, 419, 440].includes(res.status) && typeof onExpired === 'function') {
|
||||||
|
try { onExpired({ url, status: res.status, response: res }); } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
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
|
// src/utils/storageGuard.js
|
||||||
const RESTRICTED_SUBSTRINGS = [
|
|
||||||
|
// Default substring guards (case-insensitive)
|
||||||
|
const DEFAULT_DENY_SUBSTRINGS = [
|
||||||
'token','access','refresh','userid','user_id','user','profile','email','phone',
|
'token','access','refresh','userid','user_id','user','profile','email','phone',
|
||||||
'answers','interest','riasec','salary','ssn','auth'
|
'answers','interest','riasec','salary','ssn','auth'
|
||||||
];
|
];
|
||||||
function shouldBlock(key) {
|
|
||||||
const k = String(key || '').toLowerCase();
|
// A small set of explicit keys we know should never hit disk.
|
||||||
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
|
const DEFAULT_DENY_KEYS = [
|
||||||
}
|
'financialProfile',
|
||||||
function wrap(storage) {
|
'premiumOnboardingState',
|
||||||
if (!storage) return;
|
'selectedScenario',
|
||||||
const _set = storage.setItem.bind(storage);
|
'aiRecommendations',
|
||||||
storage.setItem = (k, v) => {
|
'careerSuggestionsCache',
|
||||||
if (shouldBlock(k)) {
|
'lastSelectedCareerProfileId',
|
||||||
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
|
'selectedCareer',
|
||||||
}
|
// legacy
|
||||||
return _set(k, v);
|
'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