diff --git a/.env b/.env index ac15385..e8af31e 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http:// SERVER1_PORT=5000 SERVER2_PORT=5001 SERVER3_PORT=5002 -IMG_TAG=b0cbb65-202508101532 +IMG_TAG=bea8671-202508111402 ENV_NAME=dev PROJECT=aptivaai-dev \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml index 2e79915..99959c9 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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"' diff --git a/backend/server1.js b/backend/server1.js index c4e32bf..dd5ed51 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -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: `
${text}
` + }); + } 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) diff --git a/backend/server2.js b/backend/server2.js index a4bb11d..f7e7ad5 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -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: `
${textBody}
`, + 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 **************************************************/ diff --git a/backend/server3.js b/backend/server3.js index 2aaf936..3524a4a 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -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); } diff --git a/deploy_all.sh b/deploy_all.sh index 363dc0d..35efd11 100755 --- a/deploy_all.sh +++ b/deploy_all.sh @@ -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 ) diff --git a/docker-compose.yml b/docker-compose.yml index 2b6d698..1c9d6db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/nginx.conf b/nginx.conf index c9c49a6..9dea7ea 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; } diff --git a/package-lock.json b/package-lock.json index f9e66f1..75f4ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e7448d6..b95ffc8 100644 --- a/package.json +++ b/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 .", diff --git a/src/App.js b/src/App.js index 60ac63e..08a23b9 100644 --- a/src/App.js +++ b/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 - {isAuthenticated && ( + {showAuthedNav && ( <> {/* NAV MENU */}