Added password reset links and profile, Support email
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
bea86712cb
commit
ed1fdbbba6
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=b0cbb65-202508101532
|
IMG_TAG=bea8671-202508111402
|
||||||
|
|
||||||
ENV_NAME=dev
|
ENV_NAME=dev
|
||||||
PROJECT=aptivaai-dev
|
PROJECT=aptivaai-dev
|
@ -108,6 +108,8 @@ steps:
|
|||||||
export KMS_KEY_NAME; \
|
export KMS_KEY_NAME; \
|
||||||
DEK_PATH=$(gcloud secrets versions access latest --secret=DEK_PATH_$ENV --project=$PROJECT); \
|
DEK_PATH=$(gcloud secrets versions access latest --secret=DEK_PATH_$ENV --project=$PROJECT); \
|
||||||
export DEK_PATH; \
|
export DEK_PATH; \
|
||||||
|
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
|
||||||
|
export SUPPORT_SENDGRID_API_KEY; \
|
||||||
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 ── \
|
||||||
@ -125,9 +127,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 \
|
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 \
|
||||||
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 \
|
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 \
|
||||||
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"'
|
||||||
|
|
||||||
|
@ -9,7 +9,11 @@ import bcrypt from 'bcrypt';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
|
import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
|
||||||
import pool from './config/mysqlPool.js';
|
import pool from './config/mysqlPool.js';
|
||||||
// import sqlite3 from 'sqlite3'; // (unused here – safe to remove)
|
import sqlite3 from 'sqlite3';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import sgMail from '@sendgrid/mail';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { readFile } from 'fs/promises'; // ← needed for /healthz
|
||||||
|
|
||||||
const CANARY_SQL = `
|
const CANARY_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS encryption_canary (
|
CREATE TABLE IF NOT EXISTS encryption_canary (
|
||||||
@ -167,6 +171,29 @@ app.get('/healthz', async (_req, res) => {
|
|||||||
return res.status(ready ? 200 : 503).json(out);
|
return res.status(ready ? 200 : 503).json(out);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Password reset token table (MySQL)
|
||||||
|
try {
|
||||||
|
const db = pool.raw || pool;
|
||||||
|
await db.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
used_at BIGINT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
ip VARCHAR(64) NULL,
|
||||||
|
KEY (email),
|
||||||
|
KEY (token_hash),
|
||||||
|
KEY (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
console.log('[AUTH] password_resets table ready');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('FATAL creating password_resets table:', e?.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Enable CORS with dynamic origin checking
|
// Enable CORS with dynamic origin checking
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@ -220,6 +247,220 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pwBurstLimiter = rateLimit({
|
||||||
|
windowMs: 30 * 1000, // 1 every 30s
|
||||||
|
max: 1,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
const pwDailyLimiter = rateLimit({
|
||||||
|
windowMs: 24 * 60 * 60 * 1000, // per day
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setPasswordByEmail(email, bcryptHash) {
|
||||||
|
// Update via join to the profile’s email
|
||||||
|
const sql = `
|
||||||
|
UPDATE user_auth ua
|
||||||
|
JOIN user_profile up ON up.id = ua.user_id
|
||||||
|
SET ua.hashed_password = ?
|
||||||
|
WHERE up.email = ?
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, email]);
|
||||||
|
return !!r?.affectedRows;
|
||||||
|
}
|
||||||
|
// ----- Password reset config (zero-config dev mode) -----
|
||||||
|
const RESET_CONFIG = {
|
||||||
|
// accept both spellings just in case
|
||||||
|
BASE_URL: process.env.APTIVA_API_BASE || process.env.APTIV_API_BASE || 'http://localhost:3000',
|
||||||
|
FROM: 'no-reply@aptivaai.com', // edit here if you want
|
||||||
|
TTL_MIN: 60, // edit here if you want
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SendGrid config (safe + simple) ---
|
||||||
|
const SENDGRID_KEY = String(
|
||||||
|
process.env.SUPPORT_SENDGRID_API_KEY ||
|
||||||
|
process.env.SENDGRID_API_KEY || // optional fallback
|
||||||
|
process.env.SUPPORT_SENDGRID_API_KEY_dev || // if you exported _dev
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const SENDGRID_ENABLED = SENDGRID_KEY.length > 0;
|
||||||
|
|
||||||
|
if (SENDGRID_ENABLED) {
|
||||||
|
sgMail.setApiKey(SENDGRID_KEY);
|
||||||
|
console.log('[MAIL] SendGrid enabled');
|
||||||
|
} else {
|
||||||
|
console.log('[MAIL] SendGrid disabled — will log reset links only');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Change password (must be logged in)
|
||||||
|
app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Auth required' });
|
||||||
|
|
||||||
|
let userId;
|
||||||
|
try {
|
||||||
|
({ id: userId } = jwt.verify(token, JWT_SECRET));
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword } = req.body || {};
|
||||||
|
if (
|
||||||
|
typeof currentPassword !== 'string' ||
|
||||||
|
typeof newPassword !== 'string' ||
|
||||||
|
newPassword.length < 8
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||||||
|
}
|
||||||
|
if (newPassword === currentPassword) {
|
||||||
|
return res.status(400).json({ error: 'New password must be different' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch existing hash
|
||||||
|
const [rows] = await (pool.raw || pool).query(
|
||||||
|
'SELECT hashed_password FROM user_auth WHERE user_id = ? LIMIT 1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const existing = rows?.[0];
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Account not found' });
|
||||||
|
|
||||||
|
// verify old password
|
||||||
|
const ok = await bcrypt.compare(currentPassword, existing.hashed_password);
|
||||||
|
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
|
||||||
|
|
||||||
|
// write new hash
|
||||||
|
const newHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await (pool.raw || pool).query(
|
||||||
|
'UPDATE user_auth SET hashed_password = ? WHERE user_id = ? LIMIT 1',
|
||||||
|
[newHash, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[password-change]', e?.message || e);
|
||||||
|
return res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*Password reset request (MySQL)*/
|
||||||
|
|
||||||
|
app.post('/api/auth/password-reset/request', pwBurstLimiter, pwDailyLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body?.email || '').trim().toLowerCase();
|
||||||
|
// Always respond generically to avoid enumeration
|
||||||
|
const generic = () => res.status(200).json({ ok: true });
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return generic();
|
||||||
|
|
||||||
|
// Check if email exists (generic response regardless)
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
const q = `
|
||||||
|
SELECT ua.user_id
|
||||||
|
FROM user_auth ua
|
||||||
|
JOIN user_profile up ON up.id = ua.user_id
|
||||||
|
WHERE up.email = ?
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const [rows] = await (pool.raw || pool).query(q, [email]);
|
||||||
|
exists = !!rows?.length;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Only send if (a) we have SendGrid configured AND (b) email exists
|
||||||
|
if (exists) {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + RESET_CONFIG.TTL_MIN * 60 * 1000;
|
||||||
|
|
||||||
|
await (pool.raw || pool).query(
|
||||||
|
`INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[email, tokenHash, expiresAt, now, req.ip]
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = RESET_CONFIG.BASE_URL.replace(/\/+$/, '');
|
||||||
|
const link = `${base}/reset-password/${token}`;
|
||||||
|
|
||||||
|
const text =
|
||||||
|
`We received a request to reset your Aptiva password.
|
||||||
|
|
||||||
|
If you requested this, use the link below (valid for ${RESET_CONFIG.TTL_MIN} minutes):
|
||||||
|
${link}
|
||||||
|
|
||||||
|
If you didn’t request it, you can ignore this email.`;
|
||||||
|
|
||||||
|
if (SENDGRID_ENABLED) {
|
||||||
|
await sgMail.send({
|
||||||
|
to: email,
|
||||||
|
from: RESET_CONFIG.FROM,
|
||||||
|
subject: 'Reset your Aptiva password',
|
||||||
|
text,
|
||||||
|
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Zero-config dev mode: just log the link so you can click it
|
||||||
|
console.log(`[DEV] Password reset link for ${email}: ${link}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generic();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[password-reset/request]', e?.message || e);
|
||||||
|
// Still generic
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/password-reset/confirm', pwBurstLimiter, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token, password } = req.body || {};
|
||||||
|
const t = String(token || '');
|
||||||
|
const p = String(password || '');
|
||||||
|
|
||||||
|
if (!t || p.length < 8) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(t).digest('hex');
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const [rows] = await (pool.raw || pool).query(
|
||||||
|
`SELECT * FROM password_resets
|
||||||
|
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
|
||||||
|
ORDER BY id DESC LIMIT 1`,
|
||||||
|
[tokenHash, now]
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows?.[0];
|
||||||
|
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(p, 10); // matches your registration cost
|
||||||
|
|
||||||
|
const ok = await setPasswordByEmail(row.email, hashed);
|
||||||
|
if (!ok) return res.status(500).json({ error: 'Password update failed' });
|
||||||
|
|
||||||
|
await (pool.raw || pool).query(
|
||||||
|
`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`,
|
||||||
|
[now, row.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[password-reset/confirm]', e?.message || e);
|
||||||
|
return res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
USER REGISTRATION (MySQL)
|
USER REGISTRATION (MySQL)
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
@ -417,6 +658,8 @@ app.post('/api/user-profile', async (req, res) => {
|
|||||||
riasec: riasec_scores,
|
riasec: riasec_scores,
|
||||||
career_priorities,
|
career_priorities,
|
||||||
career_list,
|
career_list,
|
||||||
|
phone_e164,
|
||||||
|
sms_opt_in
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -488,7 +731,10 @@ app.post('/api/user-profile', async (req, res) => {
|
|||||||
finalRiasec,
|
finalRiasec,
|
||||||
finalCareerPriorities,
|
finalCareerPriorities,
|
||||||
finalCareerList,
|
finalCareerList,
|
||||||
profileId,
|
finalCareerList,
|
||||||
|
phone_e164 ?? existingRow.phone_e164 ?? null,
|
||||||
|
typeof sms_opt_in === 'boolean' ? (sms_opt_in ? 1 : 0) : existingRow.sms_opt_in ?? 0,
|
||||||
|
profileId
|
||||||
];
|
];
|
||||||
|
|
||||||
await pool.query(updateQuery, params);
|
await pool.query(updateQuery, params);
|
||||||
@ -517,6 +763,8 @@ app.post('/api/user-profile', async (req, res) => {
|
|||||||
finalRiasec,
|
finalRiasec,
|
||||||
finalCareerPriorities,
|
finalCareerPriorities,
|
||||||
finalCareerList,
|
finalCareerList,
|
||||||
|
phone_e164 || null,
|
||||||
|
sms_opt_in ? 1 : 0
|
||||||
];
|
];
|
||||||
|
|
||||||
await pool.query(insertQuery, params);
|
await pool.query(insertQuery, params);
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import cors from 'cors';
|
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import xlsx from 'xlsx';
|
import xlsx from 'xlsx';
|
||||||
@ -22,6 +21,8 @@ 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 sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@ -141,6 +142,57 @@ app.get('/healthz', async (_req, res) => {
|
|||||||
return res.status(ready ? 200 : 503).json(out);
|
return res.status(ready ? 200 : 503).json(out);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Support mail config (quote‑safe) ────────────────────────────────
|
||||||
|
const SENDGRID_KEY = (process.env.SUPPORT_SENDGRID_API_KEY || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]+|['"]+$/g, ''); // strip leading/trailing quotes if GCP injects them
|
||||||
|
|
||||||
|
if (SENDGRID_KEY) {
|
||||||
|
sgMail.setApiKey(SENDGRID_KEY);
|
||||||
|
} else {
|
||||||
|
console.warn('[support] SUPPORT_SENDGRID_API_KEY missing/empty; support email disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Small in‑memory dedupe: (userId|subject|message) hash → expiresAt
|
||||||
|
const supportDedupe = new Map();
|
||||||
|
const DEDUPE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
function normalize(s = '') {
|
||||||
|
return String(s).toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function makeKey(userId, subject, message) {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
h.update(`${userId}|${normalize(subject)}|${normalize(message)}`);
|
||||||
|
return h.digest('hex');
|
||||||
|
}
|
||||||
|
function isDuplicateAndRemember(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
// prune expired
|
||||||
|
for (const [k, exp] of supportDedupe.entries()) {
|
||||||
|
if (exp <= now) supportDedupe.delete(k);
|
||||||
|
}
|
||||||
|
if (supportDedupe.has(key)) return true;
|
||||||
|
supportDedupe.set(key, now + DEDUPE_TTL_MS);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportBurstLimiter = rateLimit({
|
||||||
|
windowMs: 30 * 1000, // 1 every 30s
|
||||||
|
max: 1,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.user?.id || req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportDailyLimiter = rateLimit({
|
||||||
|
windowMs: 24 * 60 * 60 * 1000, // per day
|
||||||
|
max: 3, // at most 3 per day
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.user?.id || req.ip
|
||||||
|
});
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
||||||
* DB connections (SQLite)
|
* DB connections (SQLite)
|
||||||
**************************************************/
|
**************************************************/
|
||||||
@ -1098,6 +1150,102 @@ chatFreeEndpoint(app, {
|
|||||||
userProfileDb
|
userProfileDb
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**************************************************
|
||||||
|
* Support email endpoint
|
||||||
|
* Uses rate limiting to prevent abuse
|
||||||
|
* Supports deduplication
|
||||||
|
* Uses burst and daily limits
|
||||||
|
* Returns 429 Too Many Requests if limits exceeded
|
||||||
|
* Supports deduplication for 10 minutes
|
||||||
|
* *************************************************/
|
||||||
|
app.post(
|
||||||
|
'/api/support',
|
||||||
|
authenticateUser, // logged-in only
|
||||||
|
supportBurstLimiter,
|
||||||
|
supportDailyLimiter,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user || {};
|
||||||
|
const userId = user.id || user.user_id || user.sub; // depends on your token
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'Auth required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer token email; fall back to DB; last resort: body.email
|
||||||
|
let accountEmail = user.email || user.mail || null;
|
||||||
|
if (!accountEmail) {
|
||||||
|
try {
|
||||||
|
const row = await userProfileDb.get(
|
||||||
|
'SELECT email FROM user_profile WHERE id = ?',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
accountEmail = row?.email || null;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!accountEmail) {
|
||||||
|
accountEmail = (req.body && req.body.email) || null;
|
||||||
|
}
|
||||||
|
if (!accountEmail) {
|
||||||
|
return res.status(400).json({ error: 'No email on file for this user' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subject = '', category = 'general', message = '' } = req.body || {};
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const allowedCats = new Set(['general','billing','technical','data','ux']);
|
||||||
|
const subj = subject.toString().slice(0, 120).trim();
|
||||||
|
const body = message.toString().trim();
|
||||||
|
|
||||||
|
if (!allowedCats.has(String(category))) {
|
||||||
|
return res.status(400).json({ error: 'Invalid category' });
|
||||||
|
}
|
||||||
|
if (body.length < 5) {
|
||||||
|
return res.status(400).json({ error: 'Message too short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe
|
||||||
|
const key = makeKey(userId, subj || '(no subject)', body);
|
||||||
|
if (isDuplicateAndRemember(key)) {
|
||||||
|
return res.status(202).json({ ok: true, deduped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require mail config
|
||||||
|
const FROM = 'support@aptivaai.com';
|
||||||
|
const TO = 'support@aptivaai.com';
|
||||||
|
|
||||||
|
if (!SENDGRID_KEY) {
|
||||||
|
return res.status(503).json({ error: 'Support email not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanSubject =
|
||||||
|
`[Support • ${category}] ${subj || '(no subject)'} — user ${userId}`;
|
||||||
|
|
||||||
|
const textBody =
|
||||||
|
`User: ${userId}
|
||||||
|
Email: ${accountEmail}
|
||||||
|
Category: ${category}
|
||||||
|
|
||||||
|
${body}`;
|
||||||
|
|
||||||
|
await sgMail.send({
|
||||||
|
to: TO,
|
||||||
|
from: FROM,
|
||||||
|
replyTo: accountEmail,
|
||||||
|
subject: humanSubject,
|
||||||
|
text: textBody,
|
||||||
|
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${textBody}</pre>`,
|
||||||
|
categories: ['support', String(category || 'general')]
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[support] error:', err?.message || err);
|
||||||
|
return res.status(500).json({ error: 'Failed to send support message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
||||||
* Start the Express server
|
* Start the Express server
|
||||||
**************************************************/
|
**************************************************/
|
||||||
|
@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { readFile } from 'fs/promises'; // <-- add this
|
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';
|
||||||
@ -426,6 +426,7 @@ async function applyOps(opsObj, req) {
|
|||||||
if (op === "DELETE" && m.id) {
|
if (op === "DELETE" && m.id) {
|
||||||
const cleanId = m.id.trim();
|
const cleanId = m.id.trim();
|
||||||
const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" });
|
const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" });
|
||||||
|
if (res.ok) confirmations.push(`Deleted milestone ${cleanId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- UPDATE ---------- */
|
/* ---------- UPDATE ---------- */
|
||||||
@ -1278,7 +1279,7 @@ Our mission is to help people grow *with* AI rather than be displaced by it.
|
|||||||
Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation.
|
Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation.
|
||||||
Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator.
|
Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator.
|
||||||
|
|
||||||
+Finish every reply with **one concrete suggestion or question** that moves the plan forward.
|
Finish every reply with **one concrete suggestion or question** that moves the plan forward.
|
||||||
Never ask for info you already have unless you truly need clarification.
|
Never ask for info you already have unless you truly need clarification.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
@ -1476,7 +1477,7 @@ if (NEEDS_OPS_CARD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (NEEDS_CTX_CARD || SEND_CTX_CARD)
|
if (NEEDS_CTX_CARD || SEND_CTX_CARD)
|
||||||
+ messagesToSend.push({ role:"system", content: summaryText });
|
messagesToSend.push({ role:"system", content: summaryText });
|
||||||
|
|
||||||
// ② Per-turn contextual helpers (small!)
|
// ② Per-turn contextual helpers (small!)
|
||||||
messagesToSend.push(
|
messagesToSend.push(
|
||||||
@ -2062,13 +2063,13 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1) Check if we already have it
|
// 1) Check if we already have it
|
||||||
const cached = await getCachedRiskAnalysis(socCode);
|
const cached = await getRiskAnalysisFromDB(socCode);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return res.json({
|
return res.json({
|
||||||
socCode: cached.soc_code,
|
socCode: cached.soc_code,
|
||||||
careerName: cached.career_name,
|
careerName: cached.career_name,
|
||||||
jobDescription: cached.job_description,
|
jobDescription: cached.job_description,
|
||||||
tasks: cached.tasks ? JSON.parse(cached.tasks) : [],
|
tasks: Array.isArray(cached.tasks) ? cached.tasks : (cached.tasks ? String(cached.tasks).split(';') : []),
|
||||||
riskLevel: cached.risk_level,
|
riskLevel: cached.risk_level,
|
||||||
reasoning: cached.reasoning
|
reasoning: cached.reasoning
|
||||||
});
|
});
|
||||||
@ -2089,6 +2090,7 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "gpt-4o-mini",
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
@ -2107,13 +2109,14 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
|
|||||||
const { riskLevel, reasoning } = parsed;
|
const { riskLevel, reasoning } = parsed;
|
||||||
|
|
||||||
// 3) Store in DB
|
// 3) Store in DB
|
||||||
await cacheRiskAnalysis({
|
await storeRiskAnalysisInDB({
|
||||||
socCode,
|
socCode,
|
||||||
careerName,
|
careerName,
|
||||||
jobDescription,
|
jobDescription,
|
||||||
tasks,
|
tasks,
|
||||||
riskLevel,
|
riskLevel: parsed.riskLevel,
|
||||||
reasoning
|
reasoning: parsed.reasoning
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4) Return the new analysis
|
// 4) Return the new analysis
|
||||||
@ -3396,8 +3399,8 @@ app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
|
|||||||
cp.career_name
|
cp.career_name
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN milestones m ON m.id = t.milestone_id
|
JOIN milestones m ON m.id = t.milestone_id
|
||||||
JOIN career_paths cp ON cp.id = m.career_path_id
|
JOIN career_profiles cp ON cp.id = m.career_profile_id
|
||||||
WHERE cp.user_id = ?
|
WHERE t.user_id = ?
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }
|
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }
|
||||||
|
@ -24,6 +24,7 @@ SECRETS=(
|
|||||||
STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR \
|
STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR \
|
||||||
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
|
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
|
||||||
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
|
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
|
||||||
|
SUPPORT_SENDGRID_API_KEY \
|
||||||
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
|
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
|
||||||
KMS_KEY_NAME DEK_PATH
|
KMS_KEY_NAME DEK_PATH
|
||||||
)
|
)
|
||||||
|
@ -44,6 +44,7 @@ services:
|
|||||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||||
DB_SSL_CA: ${DB_SSL_CA}
|
DB_SSL_CA: ${DB_SSL_CA}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||||
SALARY_DB_PATH: /app/salary_info.db
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
volumes:
|
volumes:
|
||||||
@ -89,6 +90,7 @@ services:
|
|||||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||||
DB_SSL_CA: ${DB_SSL_CA}
|
DB_SSL_CA: ${DB_SSL_CA}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||||
SALARY_DB_PATH: /app/salary_info.db
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
volumes:
|
volumes:
|
||||||
@ -143,6 +145,7 @@ services:
|
|||||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||||
DB_SSL_CA: ${DB_SSL_CA}
|
DB_SSL_CA: ${DB_SSL_CA}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||||
SALARY_DB_PATH: /app/salary_info.db
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -57,6 +57,7 @@ http {
|
|||||||
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
|
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
|
||||||
location ^~ /api/maps/distance { proxy_pass http://backend5001; }
|
location ^~ /api/maps/distance { proxy_pass http://backend5001; }
|
||||||
location ^~ /api/schools { proxy_pass http://backend5001; }
|
location ^~ /api/schools { proxy_pass http://backend5001; }
|
||||||
|
location ^~ /api/support { proxy_pass http://backend5001; }
|
||||||
|
|
||||||
location ^~ /api/premium/ { proxy_pass http://backend5002; }
|
location ^~ /api/premium/ { proxy_pass http://backend5002; }
|
||||||
location ^~ /api/public/ { proxy_pass http://backend5002; }
|
location ^~ /api/public/ { proxy_pass http://backend5002; }
|
||||||
|
39
package-lock.json
generated
39
package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-progress": "^1.1.2",
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.9",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
|
"@sendgrid/mail": "^8.1.5",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
@ -4003,6 +4004,44 @@
|
|||||||
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
|
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@sendgrid/client": {
|
||||||
|
"version": "8.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz",
|
||||||
|
"integrity": "sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sendgrid/helpers": "^8.0.0",
|
||||||
|
"axios": "^1.8.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sendgrid/helpers": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sendgrid/mail": {
|
||||||
|
"version": "8.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.5.tgz",
|
||||||
|
"integrity": "sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sendgrid/client": "^8.1.5",
|
||||||
|
"@sendgrid/helpers": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.24.51",
|
"version": "0.24.51",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-progress": "^1.1.2",
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.9",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
|
"@sendgrid/mail": "^8.1.5",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
|
91
src/App.js
91
src/App.js
@ -38,6 +38,10 @@ import usePageContext from './utils/usePageContext.js';
|
|||||||
import ChatDrawer from './components/ChatDrawer.js';
|
import ChatDrawer from './components/ChatDrawer.js';
|
||||||
import ChatCtx from './contexts/ChatCtx.js';
|
import ChatCtx from './contexts/ChatCtx.js';
|
||||||
import BillingResult from './components/BillingResult.js';
|
import BillingResult from './components/BillingResult.js';
|
||||||
|
import SupportModal from './components/SupportModal.js';
|
||||||
|
import ForgotPassword from './components/ForgotPassword.js';
|
||||||
|
import ResetPassword from './components/ResetPassword.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -51,6 +55,11 @@ function App() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [drawerPane, setDrawerPane] = useState('support');
|
const [drawerPane, setDrawerPane] = useState('support');
|
||||||
const [retireProps, setRetireProps] = useState(null);
|
const [retireProps, setRetireProps] = useState(null);
|
||||||
|
const [supportOpen, setSupportOpen] = useState(false);
|
||||||
|
const [userEmail, setUserEmail] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
const AUTH_HOME = '/signin-landing';
|
||||||
|
|
||||||
/* ------------------------------------------
|
/* ------------------------------------------
|
||||||
ChatDrawer – route-aware tool handlers
|
ChatDrawer – route-aware tool handlers
|
||||||
@ -101,6 +110,18 @@ const canShowRetireBot =
|
|||||||
// Check if user can access premium
|
// Check if user can access premium
|
||||||
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
||||||
|
|
||||||
|
const isAuthScreen = React.useMemo(() => {
|
||||||
|
const p = location.pathname;
|
||||||
|
return (
|
||||||
|
p === '/signin' ||
|
||||||
|
p === '/signup' ||
|
||||||
|
p === '/forgot-password' ||
|
||||||
|
p.startsWith('/reset-password')
|
||||||
|
);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const showAuthedNav = isAuthenticated && !isAuthScreen;
|
||||||
|
|
||||||
// List of premium paths for your CTA logic
|
// List of premium paths for your CTA logic
|
||||||
const premiumPaths = [
|
const premiumPaths = [
|
||||||
'/career-roadmap',
|
'/career-roadmap',
|
||||||
@ -112,7 +133,11 @@ const canShowRetireBot =
|
|||||||
'/retirement',
|
'/retirement',
|
||||||
'/resume-optimizer',
|
'/resume-optimizer',
|
||||||
];
|
];
|
||||||
const showPremiumCTA = !premiumPaths.includes(location.pathname);
|
|
||||||
|
const showPremiumCTA = !premiumPaths.some(p =>
|
||||||
|
location.pathname.startsWith(p)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// Helper to see if user is mid–premium-onboarding
|
// Helper to see if user is mid–premium-onboarding
|
||||||
function isOnboardingInProgress() {
|
function isOnboardingInProgress() {
|
||||||
@ -125,6 +150,13 @@ const canShowRetireBot =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
Support Modal Email
|
||||||
|
===================== */
|
||||||
|
useEffect(() => {
|
||||||
|
setUserEmail(user?.email || '');
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// 1) Single Rehydrate UseEffect
|
// 1) Single Rehydrate UseEffect
|
||||||
// ==============================
|
// ==============================
|
||||||
@ -221,6 +253,8 @@ const canShowRetireBot =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Main Render / Layout
|
// Main Render / Layout
|
||||||
// =====================
|
// =====================
|
||||||
@ -250,7 +284,7 @@ const canShowRetireBot =
|
|||||||
AptivaAI - Career Guidance Platform
|
AptivaAI - Career Guidance Platform
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{isAuthenticated && (
|
{showAuthedNav && (
|
||||||
<>
|
<>
|
||||||
{/* NAV MENU */}
|
{/* NAV MENU */}
|
||||||
<nav className="flex space-x-6">
|
<nav className="flex space-x-6">
|
||||||
@ -466,6 +500,20 @@ const canShowRetireBot =
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSupportOpen(true)}
|
||||||
|
className="px-3 py-1 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SupportModal
|
||||||
|
open={supportOpen}
|
||||||
|
onClose={() => setSupportOpen(false)}
|
||||||
|
userEmail={userEmail}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* LOGOUT BUTTON */}
|
{/* LOGOUT BUTTON */}
|
||||||
<button
|
<button
|
||||||
className="text-red-600 hover:text-red-800 bg-transparent border-none"
|
className="text-red-600 hover:text-red-800 bg-transparent border-none"
|
||||||
@ -494,25 +542,45 @@ const canShowRetireBot =
|
|||||||
{/* MAIN CONTENT */}
|
{/* MAIN CONTENT */}
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Default to /signin */}
|
{/* Default */}
|
||||||
<Route path="/" element={<Navigate to="/signin" />} />
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Public routes */}
|
|
||||||
|
{/* Public (guest-only) routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/signin"
|
path="/signin"
|
||||||
element={
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<Navigate to={AUTH_HOME} replace />
|
||||||
|
) : (
|
||||||
<SignIn
|
<SignIn
|
||||||
setIsAuthenticated={setIsAuthenticated}
|
setIsAuthenticated={setIsAuthenticated}
|
||||||
setUser={setUser}
|
setUser={setUser}
|
||||||
setFinancialProfile={setFinancialProfile}
|
setFinancialProfile={setFinancialProfile}
|
||||||
setScenario={setScenario}
|
setScenario={setScenario}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/signup"
|
path="/signup"
|
||||||
element={<SignUp setUser={setUser} />}
|
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <SignUp setUser={setUser} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/forgot-password"
|
||||||
|
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/reset-password/:token"
|
||||||
|
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ResetPassword />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/paywall" element={<Paywall />} />
|
<Route path="/paywall" element={<Paywall />} />
|
||||||
|
|
||||||
{/* Authenticated routes */}
|
{/* Authenticated routes */}
|
||||||
@ -528,7 +596,7 @@ const canShowRetireBot =
|
|||||||
<Route path="/preparing" element={<PreparingLanding />} />
|
<Route path="/preparing" element={<PreparingLanding />} />
|
||||||
<Route path="/billing" element={<BillingResult />} />
|
<Route path="/billing" element={<BillingResult />} />
|
||||||
|
|
||||||
{/* Premium-only routes */}
|
{/* Premium-wrapped */}
|
||||||
<Route
|
<Route
|
||||||
path="/enhancing"
|
path="/enhancing"
|
||||||
element={
|
element={
|
||||||
@ -555,8 +623,7 @@ const canShowRetireBot =
|
|||||||
/>
|
/>
|
||||||
<Route path="/profile/careers" element={<CareerProfileList />} />
|
<Route path="/profile/careers" element={<CareerProfileList />} />
|
||||||
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
|
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
|
||||||
|
<Route path="/profile/college" element={<CollegeProfileList />} />
|
||||||
<Route path="/profile/college/" element={<CollegeProfileList />} />
|
|
||||||
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
|
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
|
||||||
<Route
|
<Route
|
||||||
path="/financial-profile"
|
path="/financial-profile"
|
||||||
@ -593,8 +660,12 @@ const canShowRetireBot =
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* 404 / Fallback */}
|
{/* 404 / Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/signin" />} />
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
142
src/components/ChangePasswordForm.js
Normal file
142
src/components/ChangePasswordForm.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
|
function ChangePasswordForm() {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [msg, setMsg] = useState(null); // { type: 'ok' | 'err', text: string }
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
return 'All fields are required.';
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
return 'New password must be at least 8 characters.';
|
||||||
|
}
|
||||||
|
if (newPassword === currentPassword) {
|
||||||
|
return 'New password must be different from current password.';
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
return 'New password and confirmation do not match.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
|
||||||
|
const err = validate();
|
||||||
|
if (err) {
|
||||||
|
setMsg({ type: 'err', text: err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token') || '';
|
||||||
|
const res = await fetch('/api/auth/password-change', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMsg({ type: 'ok', text: 'Password updated successfully.' });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
} else {
|
||||||
|
let detail = 'Failed to change password.';
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
if (j?.error) detail = j.error;
|
||||||
|
} catch {}
|
||||||
|
if (res.status === 401) detail = 'Session expired. Please sign in again.';
|
||||||
|
if (res.status === 403) detail = 'Current password is incorrect.';
|
||||||
|
if (res.status === 429) detail = 'Too many attempts. Please wait a bit and try again.';
|
||||||
|
setMsg({ type: 'err', text: detail });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ type: 'err', text: 'Network error. Please try again.' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border rounded shadow-sm p-4 md:p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Change Password</h2>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
msg.type === 'ok'
|
||||||
|
? 'mb-4 rounded border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800'
|
||||||
|
: 'mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Updating…' : 'Update Password'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangePasswordForm;
|
77
src/components/ForgotPassword.js
Normal file
77
src/components/ForgotPassword.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
const val = email.trim().toLowerCase();
|
||||||
|
if (!val || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
|
||||||
|
setError('Enter a valid email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/password-reset/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: val })
|
||||||
|
});
|
||||||
|
// Always shows success (endpoint is generic on purpose)
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Still show success message to avoid account enumeration
|
||||||
|
setDone(true);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Check your email</h2>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
If there’s an account for <strong>{email}</strong>, you’ll get a link to reset your password.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button onClick={() => navigate('/signin')}>Back to Sign In</Button>
|
||||||
|
<Link className="text-blue-600 underline text-sm" to="/signup">Create an account</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">Forgot your password?</h2>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="w-full border rounded px-3 py-2"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-600 text-xs mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Sending…' : 'Send reset link'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link className="text-blue-600 underline text-sm" to="/signin">Back to Sign In</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
102
src/components/ResetPassword.js
Normal file
102
src/components/ResetPassword.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const { token } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pw, setPw] = useState('');
|
||||||
|
const [pw2, setPw2] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!pw || pw.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pw !== pw2) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/password-reset/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, password: pw })
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || 'Reset failed');
|
||||||
|
}
|
||||||
|
setOk(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Reset failed');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Password updated</h2>
|
||||||
|
<p className="text-sm text-gray-700">You can now sign in with your new password.</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button onClick={() => navigate('/signin')}>Go to Sign In</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Invalid link</h2>
|
||||||
|
<p className="text-sm text-gray-700">This reset link is missing or malformed.</p>
|
||||||
|
<Link className="text-blue-600 underline text-sm" to="/forgot-password">Request a new link</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto bg-white p-6 rounded shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-3">Set a new password</h2>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border rounded px-3 py-2"
|
||||||
|
value={pw}
|
||||||
|
onChange={(e) => setPw(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border rounded px-3 py-2"
|
||||||
|
value={pw2}
|
||||||
|
onChange={(e) => setPw2(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-600 text-xs">{error}</p>}
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Saving…' : 'Update password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link className="text-blue-600 underline text-sm" to="/forgot-password">Need a new link?</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -124,15 +124,23 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-gray-600">
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-center text-sm text-gray-600">
|
||||||
Don’t have an account?{' '}
|
Don’t have an account?{' '}
|
||||||
<Link
|
<Link to="/signup" className="text-blue-600 hover:underline">
|
||||||
to="/signup" // <- here
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
Sign Up
|
Sign Up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="inline-block text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
127
src/components/SupportModal.js
Normal file
127
src/components/SupportModal.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import authFetch from '../utils/authFetch.js';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
|
export default function SupportModal({ open, onClose, userEmail }) {
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [category, setCategory] = useState('general');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!message || message.trim().length < 5) {
|
||||||
|
setError('Please enter at least 5 characters.');
|
||||||
|
setOk(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await authFetch('/api/support', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: userEmail, subject, category, message })
|
||||||
|
});
|
||||||
|
if (!res) {
|
||||||
|
// authFetch returns null on 401/403
|
||||||
|
throw new Error('Your session expired. Please sign in again.');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(true);
|
||||||
|
setSubject(''); // reset to empty strings, not null
|
||||||
|
setCategory('general');
|
||||||
|
setMessage('');
|
||||||
|
setTimeout(() => onClose(), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to send');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="w-full max-w-lg rounded bg-white p-4 shadow">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-lg font-semibold">Contact Support</h3>
|
||||||
|
<button onClick={onClose} className="text-sm underline">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!userEmail && (
|
||||||
|
<p className="text-sm text-red-600 mb-2">
|
||||||
|
You must be signed in to contact support.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1">We’ll reply to</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded px-2 py-1 bg-gray-100"
|
||||||
|
value={userEmail || ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1">Subject (optional)</label>
|
||||||
|
<input
|
||||||
|
className="w-full border rounded px-2 py-1"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="Brief subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded px-2 py-1"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="general">General</option>
|
||||||
|
<option value="billing">Billing</option>
|
||||||
|
<option value="technical">Technical</option>
|
||||||
|
<option value="data">Data issue</option>
|
||||||
|
<option value="ux">UX/feedback</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm block mb-1">Message</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border rounded px-2 py-1 h-28"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="How can we help?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{ok && <p className="text-sm text-green-600">Sent. We’ll be in touch.</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" className="bg-gray-200 text-black" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={sending || !userEmail}>
|
||||||
|
{sending ? 'Sending…' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
function UserProfile() {
|
function UserProfile() {
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
@ -325,6 +326,8 @@ function UserProfile() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChangePasswordForm />
|
||||||
|
|
||||||
{/* 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">
|
||||||
<button
|
<button
|
||||||
|
Loading…
Reference in New Issue
Block a user