Added password reset links and profile, Support email
This commit is contained in:
parent
8c7bcb4696
commit
974585ea6e
2
.env
2
.env
@ -2,7 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
|
||||
SERVER1_PORT=5000
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
IMG_TAG=b0cbb65-202508101532
|
||||
IMG_TAG=bea8671-202508111402
|
||||
|
||||
ENV_NAME=dev
|
||||
PROJECT=aptivaai-dev
|
@ -108,6 +108,8 @@ steps:
|
||||
export KMS_KEY_NAME; \
|
||||
DEK_PATH=$(gcloud secrets versions access latest --secret=DEK_PATH_$ENV --project=$PROJECT); \
|
||||
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; \
|
||||
\
|
||||
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
|
||||
@ -125,9 +127,9 @@ steps:
|
||||
fi; \
|
||||
\
|
||||
cd /home/jcoakley/aptiva-staging-app; \
|
||||
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH \
|
||||
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; \
|
||||
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; \
|
||||
echo "✅ Staging stack refreshed with tag $IMG_TAG"'
|
||||
|
||||
|
@ -9,7 +9,11 @@ import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.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 = `
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
app.use(
|
||||
cors({
|
||||
@ -220,6 +247,220 @@ app.use((req, res, 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)
|
||||
------------------------------------------------------------------ */
|
||||
@ -417,6 +658,8 @@ app.post('/api/user-profile', async (req, res) => {
|
||||
riasec: riasec_scores,
|
||||
career_priorities,
|
||||
career_list,
|
||||
phone_e164,
|
||||
sms_opt_in
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
@ -488,7 +731,10 @@ app.post('/api/user-profile', async (req, res) => {
|
||||
finalRiasec,
|
||||
finalCareerPriorities,
|
||||
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);
|
||||
@ -517,8 +763,10 @@ app.post('/api/user-profile', async (req, res) => {
|
||||
finalRiasec,
|
||||
finalCareerPriorities,
|
||||
finalCareerList,
|
||||
];
|
||||
|
||||
phone_e164 || null,
|
||||
sms_opt_in ? 1 : 0
|
||||
];
|
||||
|
||||
await pool.query(insertQuery, params);
|
||||
return res
|
||||
.status(201)
|
||||
|
@ -4,7 +4,6 @@
|
||||
|
||||
import express from 'express';
|
||||
import axios from 'axios';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
import xlsx from 'xlsx';
|
||||
@ -22,6 +21,8 @@ import rateLimit from 'express-rate-limit';
|
||||
import authenticateUser from './utils/authenticateUser.js';
|
||||
import { vectorSearch } from "./utils/vectorSearch.js";
|
||||
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
|
||||
import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
|
||||
import crypto from 'crypto';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@ -141,6 +142,57 @@ app.get('/healthz', async (_req, res) => {
|
||||
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)
|
||||
**************************************************/
|
||||
@ -1098,6 +1150,102 @@ chatFreeEndpoint(app, {
|
||||
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
|
||||
**************************************************/
|
||||
|
@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { readFile } from 'fs/promises'; // <-- add this
|
||||
import fs, { readFile } from 'fs/promises'; // <-- add this
|
||||
import multer from 'multer';
|
||||
import fetch from 'node-fetch';
|
||||
import mammoth from 'mammoth';
|
||||
@ -426,6 +426,7 @@ async function applyOps(opsObj, req) {
|
||||
if (op === "DELETE" && m.id) {
|
||||
const cleanId = m.id.trim();
|
||||
const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" });
|
||||
if (res.ok) confirmations.push(`Deleted milestone ${cleanId}`);
|
||||
}
|
||||
|
||||
/* ---------- 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.
|
||||
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.
|
||||
`.trim();
|
||||
|
||||
@ -1476,7 +1477,7 @@ if (NEEDS_OPS_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!)
|
||||
messagesToSend.push(
|
||||
@ -2062,13 +2063,13 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
|
||||
}
|
||||
|
||||
// 1) Check if we already have it
|
||||
const cached = await getCachedRiskAnalysis(socCode);
|
||||
const cached = await getRiskAnalysisFromDB(socCode);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
socCode: cached.soc_code,
|
||||
careerName: cached.career_name,
|
||||
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,
|
||||
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({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
@ -2107,13 +2109,14 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r
|
||||
const { riskLevel, reasoning } = parsed;
|
||||
|
||||
// 3) Store in DB
|
||||
await cacheRiskAnalysis({
|
||||
await storeRiskAnalysisInDB({
|
||||
socCode,
|
||||
careerName,
|
||||
jobDescription,
|
||||
tasks,
|
||||
riskLevel,
|
||||
reasoning
|
||||
riskLevel: parsed.riskLevel,
|
||||
reasoning: parsed.reasoning
|
||||
|
||||
});
|
||||
|
||||
// 4) Return the new analysis
|
||||
@ -3396,8 +3399,8 @@ app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
|
||||
cp.career_name
|
||||
FROM tasks t
|
||||
JOIN milestones m ON m.id = t.milestone_id
|
||||
JOIN career_paths cp ON cp.id = m.career_path_id
|
||||
WHERE cp.user_id = ?
|
||||
JOIN career_profiles cp ON cp.id = m.career_profile_id
|
||||
WHERE t.user_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 \
|
||||
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
|
||||
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
|
||||
SUPPORT_SENDGRID_API_KEY \
|
||||
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
|
||||
KMS_KEY_NAME DEK_PATH
|
||||
)
|
||||
|
@ -44,6 +44,7 @@ services:
|
||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||
DB_SSL_CA: ${DB_SSL_CA}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||
SALARY_DB_PATH: /app/salary_info.db
|
||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||
volumes:
|
||||
@ -89,6 +90,7 @@ services:
|
||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||
DB_SSL_CA: ${DB_SSL_CA}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||
SALARY_DB_PATH: /app/salary_info.db
|
||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||
volumes:
|
||||
@ -143,6 +145,7 @@ services:
|
||||
DB_SSL_KEY: ${DB_SSL_KEY}
|
||||
DB_SSL_CA: ${DB_SSL_CA}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
|
||||
SALARY_DB_PATH: /app/salary_info.db
|
||||
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||
volumes:
|
||||
|
@ -57,7 +57,8 @@ http {
|
||||
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
|
||||
location ^~ /api/maps/distance { 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/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-progress": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@sendgrid/mail": "^8.1.5",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.4.7",
|
||||
@ -4003,6 +4004,44 @@
|
||||
"integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==",
|
||||
"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": {
|
||||
"version": "0.24.51",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
|
||||
|
13
package.json
13
package.json
@ -10,6 +10,7 @@
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.9",
|
||||
"@sendgrid/mail": "^8.1.5",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.4.7",
|
||||
@ -59,12 +60,12 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/request": {
|
||||
"form-data": "^4.0.4"
|
||||
},
|
||||
"jsdom": {
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
"@types/request": {
|
||||
"form-data": "^4.0.4"
|
||||
},
|
||||
"jsdom": {
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
|
145
src/App.js
145
src/App.js
@ -38,6 +38,10 @@ import usePageContext from './utils/usePageContext.js';
|
||||
import ChatDrawer from './components/ChatDrawer.js';
|
||||
import ChatCtx from './contexts/ChatCtx.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 [drawerPane, setDrawerPane] = useState('support');
|
||||
const [retireProps, setRetireProps] = useState(null);
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
|
||||
const AUTH_HOME = '/signin-landing';
|
||||
|
||||
/* ------------------------------------------
|
||||
ChatDrawer – route-aware tool handlers
|
||||
@ -101,18 +110,34 @@ const canShowRetireBot =
|
||||
// Check if user can access 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
|
||||
const premiumPaths = [
|
||||
'/career-roadmap',
|
||||
'/paywall',
|
||||
'/financial-profile',
|
||||
'/retirement-planner',
|
||||
'/premium-onboarding',
|
||||
'/enhancing',
|
||||
'/retirement',
|
||||
'/resume-optimizer',
|
||||
];
|
||||
const showPremiumCTA = !premiumPaths.includes(location.pathname);
|
||||
'/career-roadmap',
|
||||
'/paywall',
|
||||
'/financial-profile',
|
||||
'/retirement-planner',
|
||||
'/premium-onboarding',
|
||||
'/enhancing',
|
||||
'/retirement',
|
||||
'/resume-optimizer',
|
||||
];
|
||||
|
||||
const showPremiumCTA = !premiumPaths.some(p =>
|
||||
location.pathname.startsWith(p)
|
||||
);
|
||||
|
||||
|
||||
// Helper to see if user is mid–premium-onboarding
|
||||
function isOnboardingInProgress() {
|
||||
@ -125,6 +150,13 @@ const canShowRetireBot =
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================
|
||||
Support Modal Email
|
||||
===================== */
|
||||
useEffect(() => {
|
||||
setUserEmail(user?.email || '');
|
||||
}, [user]);
|
||||
|
||||
// ==============================
|
||||
// 1) Single Rehydrate UseEffect
|
||||
// ==============================
|
||||
@ -221,6 +253,8 @@ const canShowRetireBot =
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =====================
|
||||
// Main Render / Layout
|
||||
// =====================
|
||||
@ -250,7 +284,7 @@ const canShowRetireBot =
|
||||
AptivaAI - Career Guidance Platform
|
||||
</h1>
|
||||
|
||||
{isAuthenticated && (
|
||||
{showAuthedNav && (
|
||||
<>
|
||||
{/* NAV MENU */}
|
||||
<nav className="flex space-x-6">
|
||||
@ -466,6 +500,20 @@ const canShowRetireBot =
|
||||
</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 */}
|
||||
<button
|
||||
className="text-red-600 hover:text-red-800 bg-transparent border-none"
|
||||
@ -494,41 +542,61 @@ const canShowRetireBot =
|
||||
{/* MAIN CONTENT */}
|
||||
<main className="flex-1 p-6">
|
||||
<Routes>
|
||||
{/* Default to /signin */}
|
||||
<Route path="/" element={<Navigate to="/signin" />} />
|
||||
{/* Default */}
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
|
||||
/>
|
||||
|
||||
|
||||
{/* Public (guest-only) routes */}
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Navigate to={AUTH_HOME} replace />
|
||||
) : (
|
||||
<SignIn
|
||||
setIsAuthenticated={setIsAuthenticated}
|
||||
setUser={setUser}
|
||||
setFinancialProfile={setFinancialProfile}
|
||||
setScenario={setScenario}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/signup"
|
||||
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 />}
|
||||
/>
|
||||
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<SignIn
|
||||
setIsAuthenticated={setIsAuthenticated}
|
||||
setUser={setUser}
|
||||
setFinancialProfile={setFinancialProfile}
|
||||
setScenario={setScenario}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/signup"
|
||||
element={<SignUp setUser={setUser} />}
|
||||
/>
|
||||
<Route path="/paywall" element={<Paywall />} />
|
||||
|
||||
{/* Authenticated routes */}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Route path="/signin-landing" element={<SignInLanding user={user} />}/>
|
||||
<Route path="/signin-landing" element={<SignInLanding user={user} />} />
|
||||
<Route path="/interest-inventory" element={<InterestInventory />} />
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/planning" element={<PlanningLanding />} />
|
||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />} />
|
||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
<Route path="/billing" element={<BillingResult />} />
|
||||
|
||||
{/* Premium-only routes */}
|
||||
{/* Premium-wrapped */}
|
||||
<Route
|
||||
path="/enhancing"
|
||||
element={
|
||||
@ -553,11 +621,10 @@ const canShowRetireBot =
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/profile/careers" element={<CareerProfileList />} />
|
||||
<Route path="/profile/careers" element={<CareerProfileList />} />
|
||||
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
|
||||
|
||||
<Route path="/profile/college/" element={<CollegeProfileList />} />
|
||||
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
|
||||
<Route path="/profile/college" element={<CollegeProfileList />} />
|
||||
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
|
||||
<Route
|
||||
path="/financial-profile"
|
||||
element={
|
||||
@ -593,8 +660,12 @@ const canShowRetireBot =
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* 404 / Fallback */}
|
||||
<Route path="*" element={<Navigate to="/signin" />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don’t have an account?{' '}
|
||||
<Link
|
||||
to="/signup" // <- here
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
Don’t have an account?{' '}
|
||||
<Link to="/signup" className="text-blue-600 hover:underline">
|
||||
Sign Up
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
|
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 { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from './ChangePasswordForm.js';
|
||||
|
||||
function UserProfile() {
|
||||
const [firstName, setFirstName] = useState('');
|
||||
@ -325,6 +326,8 @@ function UserProfile() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ChangePasswordForm />
|
||||
|
||||
{/* Form Buttons */}
|
||||
<div className="mt-6 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
|
Loading…
Reference in New Issue
Block a user