Big one - admin portal and DOB COPPA compliance
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed

This commit is contained in:
Josh 2025-10-30 10:28:38 +00:00
parent 6a58f62075
commit c0a68eb81c
43 changed files with 9306 additions and 380 deletions

View File

@ -1 +1 @@
7525e7b74f06b3341cb73a157afaea13b4af1f5d-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b 7ed237a540a248342b77de971556a895ac91cacc-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -47,3 +47,17 @@ GAETC_PRINT_MATERIALS_FINAL.md
CONFERENCE_MATERIALS.md CONFERENCE_MATERIALS.md
APTIVA_AI_FEATURES_DOCUMENTATION.md APTIVA_AI_FEATURES_DOCUMENTATION.md
# Admin Portal Design Documents (not needed in containers)
ORG_ADMIN_PORTAL_DESIGN.md
ADMIN_PORTAL_DEPLOYMENT.md
SERVER4_SECURITY_REVIEW.md
SERVER4_ACTUAL_SECURITY_PATTERNS.md
# Security Analysis Documents (sensitive - never ship)
.security-notes-*.md
# Migration and SQL files (run manually, not needed in containers)
*.sql
**/*.sql
migrations/

13
.gitignore vendored
View File

@ -38,3 +38,16 @@ ACCURATE_COST_PROJECTIONS.md
GAETC_PRINT_MATERIALS_FINAL.md GAETC_PRINT_MATERIALS_FINAL.md
CONFERENCE_MATERIALS.md CONFERENCE_MATERIALS.md
APTIVA_AI_FEATURES_DOCUMENTATION.md APTIVA_AI_FEATURES_DOCUMENTATION.md
# Admin Portal Design Documents
ORG_ADMIN_PORTAL_DESIGN.md
ADMIN_PORTAL_DEPLOYMENT.md
SERVER4_SECURITY_REVIEW.md
SERVER4_ACTUAL_SECURITY_PATTERNS.md
# Security Analysis Documents
.security-notes-*.md
# Migration and SQL files (run manually on database)
*.sql
migrations/

View File

@ -24,7 +24,7 @@ steps:
# Check which images already exist in PROD (so we don't try to push them) # Check which images already exist in PROD (so we don't try to push them)
MISSING="" MISSING=""
for s in server1 server2 server3 nginx; do for s in server1 server2 server3 server4 nginx; do
REF="docker://$DST/$s:$IMG_TAG" REF="docker://$DST/$s:$IMG_TAG"
if ! skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null 2>&1; then if ! skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null 2>&1; then
MISSING="$MISSING $s" MISSING="$MISSING $s"
@ -67,7 +67,7 @@ steps:
DST="us-central1-docker.pkg.dev/aptivaai-prod/aptiva-repo" DST="us-central1-docker.pkg.dev/aptivaai-prod/aptiva-repo"
apt-get update -qq && apt-get install -y -qq skopeo apt-get update -qq && apt-get install -y -qq skopeo
TOKEN="$(gcloud auth print-access-token)" TOKEN="$(gcloud auth print-access-token)"
for s in server1 server2 server3 nginx; do for s in server1 server2 server3 server4 nginx; do
REF="docker://$DST/$s:$IMG_TAG" REF="docker://$DST/$s:$IMG_TAG"
echo "🔎 verify $REF" echo "🔎 verify $REF"
skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null
@ -93,7 +93,7 @@ steps:
export PATH="$PATH:$(pwd)/bin" export PATH="$PATH:$(pwd)/bin"
TOKEN="$(gcloud auth print-access-token)" TOKEN="$(gcloud auth print-access-token)"
for s in server1 server2 server3 nginx; do for s in server1 server2 server3 server4 nginx; do
REF="$REG/$s:$IMG_TAG" REF="$REG/$s:$IMG_TAG"
echo "🛡 scan $REF" echo "🛡 scan $REF"
trivy image --username oauth2accesstoken --password "$TOKEN" \ trivy image --username oauth2accesstoken --password "$TOKEN" \
@ -170,6 +170,8 @@ steps:
SERVER1_PORT="$(gcloud secrets versions access latest --secret=SERVER1_PORT_$ENV --project="$PROJECT")"; export SERVER1_PORT SERVER1_PORT="$(gcloud secrets versions access latest --secret=SERVER1_PORT_$ENV --project="$PROJECT")"; export SERVER1_PORT
SERVER2_PORT="$(gcloud secrets versions access latest --secret=SERVER2_PORT_$ENV --project="$PROJECT")"; export SERVER2_PORT SERVER2_PORT="$(gcloud secrets versions access latest --secret=SERVER2_PORT_$ENV --project="$PROJECT")"; export SERVER2_PORT
SERVER3_PORT="$(gcloud secrets versions access latest --secret=SERVER3_PORT_$ENV --project="$PROJECT")"; export SERVER3_PORT SERVER3_PORT="$(gcloud secrets versions access latest --secret=SERVER3_PORT_$ENV --project="$PROJECT")"; export SERVER3_PORT
SERVER4_PORT="$(gcloud secrets versions access latest --secret=SERVER4_PORT_$ENV --project="$PROJECT")"; export SERVER4_PORT
ADMIN_PORTAL_URL="$(gcloud secrets versions access latest --secret=ADMIN_PORTAL_URL_$ENV --project="$PROJECT")"; export ADMIN_PORTAL_URL
ENV_NAME="$(gcloud secrets versions access latest --secret=ENV_NAME_$ENV --project="$PROJECT")"; export ENV_NAME ENV_NAME="$(gcloud secrets versions access latest --secret=ENV_NAME_$ENV --project="$PROJECT")"; export ENV_NAME
CORS_ALLOWED_ORIGINS="$(gcloud secrets versions access latest --secret=CORS_ALLOWED_ORIGINS_$ENV --project="$PROJECT")"; export CORS_ALLOWED_ORIGINS CORS_ALLOWED_ORIGINS="$(gcloud secrets versions access latest --secret=CORS_ALLOWED_ORIGINS_$ENV --project="$PROJECT")"; export CORS_ALLOWED_ORIGINS
APTIVA_API_BASE="$(gcloud secrets versions access latest --secret=APTIVA_API_BASE_$ENV --project="$PROJECT")"; export APTIVA_API_BASE APTIVA_API_BASE="$(gcloud secrets versions access latest --secret=APTIVA_API_BASE_$ENV --project="$PROJECT")"; export APTIVA_API_BASE
@ -185,10 +187,10 @@ steps:
gcloud auth configure-docker us-central1-docker.pkg.dev -q gcloud auth configure-docker us-central1-docker.pkg.dev -q
sudo gcloud auth configure-docker us-central1-docker.pkg.dev -q sudo gcloud auth configure-docker us-central1-docker.pkg.dev -q
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,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \ 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,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,SERVER4_PORT,ADMIN_PORTAL_URL,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
docker compose pull docker compose pull
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \ 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,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,SERVER4_PORT,ADMIN_PORTAL_URL,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
docker compose up -d --force-recreate --remove-orphans docker compose up -d --force-recreate --remove-orphans
echo "✅ Prod stack refreshed with tag $IMG_TAG" echo "✅ Prod stack refreshed with tag $IMG_TAG"

24
Dockerfile.server4 Normal file
View File

@ -0,0 +1,24 @@
FROM node:20-bookworm-slim AS base
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
# add curl for healthchecks (+ CA bundle)
RUN apt-get update -y && \
apt-get install -y --no-install-recommends \
build-essential python3 pkg-config curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --unsafe-perm --omit=dev
# app payload (only what runtime needs)
COPY --chown=app:app backend/ ./backend/
COPY --chown=app:app src/ai/ ./src/ai/
COPY --chown=app:app src/assets/ ./src/assets/
COPY --chown=app:app backend/data/ ./backend/data/
RUN mkdir -p /tmp && chmod 1777 /tmp
USER app
CMD ["node", "backend/server4.js"]

View File

@ -475,6 +475,16 @@ function emailLookup(email) {
.digest('hex'); .digest('hex');
} }
// ---- Username index helper (HMAC-SHA256 of normalized username) ----
const USERNAME_INDEX_KEY = process.env.USERNAME_INDEX_SECRET || JWT_SECRET;
function usernameLookup(username) {
return crypto
.createHmac('sha256', USERNAME_INDEX_KEY)
.update(String(username).trim().toLowerCase())
.digest('hex');
}
// ----- Password reset config (zero-config dev mode) ----- // ----- Password reset config (zero-config dev mode) -----
if (!process.env.APTIVA_API_BASE) { if (!process.env.APTIVA_API_BASE) {
@ -871,56 +881,387 @@ app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, as
} }
}); });
/* ------------------------------------------------------------------
VALIDATE INVITATION TOKEN (for B2B student invitations)
------------------------------------------------------------------ */
app.post('/api/validate-invite', async (req, res) => {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
// Verify user still exists and is pending
const [users] = await pool.query(
'SELECT id, email, firstname, lastname, username FROM user_profile WHERE id = ? LIMIT 1',
[decoded.userId]
);
if (!users.length) {
return res.status(404).json({ error: 'Invitation not found or expired' });
}
const user = users[0];
// Check if already completed signup (username is not NULL)
if (user.username) {
return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
}
// Verify enrollment exists and is pending
const [enrollment] = await pool.query(
'SELECT enrollment_status FROM organization_students WHERE organization_id = ? AND user_id = ?',
[decoded.organizationId, decoded.userId]
);
if (!enrollment.length) {
return res.status(404).json({ error: 'Invitation not found' });
}
// Decrypt email and names for pre-fill
let email = user.email;
let firstname = user.firstname;
let lastname = user.lastname;
try {
email = decrypt(email);
} catch (err) {
// Not encrypted or decryption failed
}
try {
if (firstname && firstname.startsWith('gcm:')) {
firstname = decrypt(firstname);
}
} catch (err) {
// Not encrypted or decryption failed
}
try {
if (lastname && lastname.startsWith('gcm:')) {
lastname = decrypt(lastname);
}
} catch (err) {
// Not encrypted or decryption failed
}
return res.json({
valid: true,
email: email,
firstname: firstname,
lastname: lastname,
userId: decoded.userId,
organizationId: decoded.organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
console.error('[validate-invite] Error:', err.message);
return res.status(400).json({ error: 'Invalid invitation token' });
}
});
/* ------------------------------------------------------------------
LINK EXISTING ACCOUNT TO ORGANIZATION (for existing users)
------------------------------------------------------------------ */
app.post('/api/link-account', requireAuth, async (req, res) => {
const { token } = req.body;
const userId = req.userId; // From requireAuth middleware
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
if (decoded.isNewUser !== false) {
return res.status(400).json({ error: 'This invitation is for a new account, not account linking' });
}
// Verify the token's userId matches the logged-in user
if (decoded.userId !== userId) {
return res.status(400).json({ error: 'This invitation is for a different account' });
}
const organizationId = decoded.organizationId;
// Check if already enrolled
const [existing] = await pool.query(
'SELECT id, enrollment_status, invitation_sent_at FROM organization_students WHERE organization_id = ? AND user_id = ? LIMIT 1',
[organizationId, userId]
);
if (existing.length > 0) {
// Already enrolled - update status to active and set invitation_sent_at if null
const invitationSentAt = existing[0].invitation_sent_at || new Date();
await pool.query(
'UPDATE organization_students SET enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE organization_id = ? AND user_id = ?',
['active', invitationSentAt, organizationId, userId]
);
} else {
return res.status(404).json({ error: 'Enrollment record not found. Please contact your administrator.' });
}
// Ensure user has premium access (org students get premium)
await pool.query(
'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
[userId]
);
return res.json({
message: 'Account linked successfully',
organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
console.error('[link-account] Error:', err.message);
return res.status(400).json({ error: 'Invalid invitation token' });
}
});
/* ------------------------------------------------------------------
LINK SECONDARY EMAIL TO EXISTING ACCOUNT (for new user invitations with different email)
------------------------------------------------------------------ */
app.post('/api/link-secondary-email', requireAuth, async (req, res) => {
const { token } = req.body;
const loggedInUserId = req.userId; // From requireAuth middleware
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
if (decoded.isNewUser !== true) {
return res.status(400).json({ error: 'This invitation is for an existing email, not secondary email linking' });
}
const shellUserId = decoded.userId; // The shell user_id created during roster upload
const invitationEmail = decoded.email;
const organizationId = decoded.organizationId;
// Get the shell user's encrypted email and firstname for user_emails
const [shellUser] = await pool.query(
'SELECT email, firstname FROM user_profile WHERE id = ? LIMIT 1',
[shellUserId]
);
if (!shellUser || shellUser.length === 0) {
return res.status(404).json({ error: 'Invitation not found or expired' });
}
// Add the invitation email to user_emails as secondary email for the logged-in user
const emailNorm = String(invitationEmail).trim().toLowerCase();
const emailLookupVal = emailLookup(emailNorm);
const encEmail = encrypt(emailNorm);
await pool.query(
'INSERT INTO user_emails (user_id, email, email_lookup, is_primary, is_verified, verified_at) VALUES (?, ?, ?, 0, 1, NOW())',
[loggedInUserId, encEmail, emailLookupVal]
);
// Get invitation_sent_at from the enrollment record
const [enrollment] = await pool.query(
'SELECT invitation_sent_at FROM organization_students WHERE user_id = ? AND organization_id = ? LIMIT 1',
[shellUserId, organizationId]
);
const invitationSentAt = enrollment[0]?.invitation_sent_at || new Date();
// Update organization_students to point to the real user_id instead of shell
await pool.query(
'UPDATE organization_students SET user_id = ?, enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE user_id = ? AND organization_id = ?',
[loggedInUserId, 'active', invitationSentAt, shellUserId, organizationId]
);
// Ensure user has premium access (org students get premium)
await pool.query(
'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
[loggedInUserId]
);
// Delete the shell user_profile and any related records
await pool.query('DELETE FROM user_profile WHERE id = ?', [shellUserId]);
return res.json({
message: 'Secondary email linked successfully',
organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'This email is already linked to an account.' });
}
console.error('[link-secondary-email] Error:', err.message);
return res.status(400).json({ error: 'Failed to link secondary email' });
}
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
USER REGISTRATION (MySQL) USER REGISTRATION (MySQL)
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
app.post('/api/register', async (req, res) => { app.post('/api/register', async (req, res) => {
const { const {
username, password, firstname, lastname, email, username, password, firstname, lastname, email,
zipcode, state, area, career_situation, phone_e164, sms_opt_in date_of_birth, // NEW: DOB for COPPA compliance
zipcode, state, area, career_situation, phone_e164, sms_opt_in,
inviteToken // NEW: Invitation token from organization admin
} = req.body; } = req.body;
if (!username || !password || !firstname || !lastname || !email || !zipcode || !state || !area) { if (!username || !password || !firstname || !lastname || !email || !date_of_birth || !zipcode || !state || !area) {
return res.status(400).json({ error: 'Missing required fields.' }); return res.status(400).json({ error: 'Missing required fields.' });
} }
// Validate DOB format and age (COPPA compliance - must be 13+)
const dobRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dobRegex.test(date_of_birth)) {
return res.status(400).json({ error: 'Invalid date of birth format. Expected YYYY-MM-DD.' });
}
const birthDate = new Date(date_of_birth);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
if (age < 13) {
return res.status(403).json({ error: 'You must be at least 13 years old to use AptivaAI.' });
}
if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) { if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) {
return res.status(400).json({ error: 'Phone must be +E.164 format.' }); return res.status(400).json({ error: 'Phone must be +E.164 format.' });
} }
try { try {
const hashedPassword = await bcrypt.hash(password, 10); let userId;
let isInvitedStudent = false;
let organizationId = null;
const emailNorm = String(email).trim().toLowerCase(); // Check if this is completing an invitation
const encEmail = encrypt(emailNorm); // if encrypt() is async in your lib, use: await encrypt(...) if (inviteToken) {
const emailLookupVal = emailLookup(emailNorm); try {
const decoded = jwt.verify(inviteToken, JWT_SECRET);
const [resultProfile] = await pool.query(` if (decoded.prp === 'student_invite') {
INSERT INTO user_profile userId = decoded.userId;
(username, firstname, lastname, email, email_lookup, zipcode, state, area, organizationId = decoded.organizationId;
career_situation, phone_e164, sms_opt_in) isInvitedStudent = true;
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
username, firstname, lastname, encEmail, emailLookupVal,
zipcode, state, area, career_situation || null,
phone_e164 || null, sms_opt_in ? 1 : 0
]);
// Verify user still exists and username is NULL
const [existingUser] = await pool.query(
'SELECT id, username FROM user_profile WHERE id = ? LIMIT 1',
[userId]
);
const newProfileId = resultProfile.insertId; if (!existingUser.length) {
return res.status(404).json({ error: 'Invitation not found or expired.' });
}
const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`; if (existingUser[0].username) {
await pool.query(authQuery, [newProfileId, username, hashedPassword]); return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
}
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' }); // Update existing user_profile with username and other details
await pool.query(
'UPDATE user_profile SET username = ?, zipcode = ?, state = ?, area = ?, career_situation = ?, phone_e164 = ?, sms_opt_in = ? WHERE id = ?',
[username, zipcode, state, area, career_situation || null, phone_e164 || null, sms_opt_in ? 1 : 0, userId]
);
// Create user_auth for login (with DOB for COPPA compliance)
const hashedPassword = await bcrypt.hash(password, 10);
const usernameLookupVal = usernameLookup(username);
await pool.query(
'INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())',
[userId, username, usernameLookupVal, hashedPassword, date_of_birth]
);
// Update organization_students status to active
await pool.query(
'UPDATE organization_students SET enrollment_status = ?, invitation_accepted_at = NOW() WHERE organization_id = ? AND user_id = ?',
['active', organizationId, userId]
);
console.log(`[register] Invitation completed for user ${userId} in org ${organizationId}`);
}
} catch (tokenErr) {
console.error('[register] Invalid invite token:', tokenErr.message);
// Continue as normal signup if token is invalid
isInvitedStudent = false;
}
}
// If not invited student, create new user (existing logic)
if (!isInvitedStudent) {
const hashedPassword = await bcrypt.hash(password, 10);
const emailNorm = String(email).trim().toLowerCase();
const encEmail = encrypt(emailNorm);
const emailLookupVal = emailLookup(emailNorm);
// Check for duplicate email (except for test account bypass)
if (emailNorm !== 'jcoakley@aptivaai.com') {
const [existingUser] = await pool.query(
'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1',
[emailLookupVal]
);
if (existingUser.length > 0) {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
} else {
console.log('[register] Test account bypass for jcoakley@aptivaai.com - allowing duplicate');
}
const [resultProfile] = await pool.query(`
INSERT INTO user_profile
(username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
username, firstname, lastname, encEmail, emailLookupVal,
zipcode, state, area, career_situation || null,
phone_e164 || null, sms_opt_in ? 1 : 0
]);
userId = resultProfile.insertId;
const usernameLookupVal = usernameLookup(username);
const authQuery = `INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())`;
await pool.query(authQuery, [userId, username, usernameLookupVal, hashedPassword, date_of_birth]);
}
const token = jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions()); res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(201).json({ return res.status(201).json({
message: 'User registered successfully', message: 'User registered successfully',
profileId: newProfileId, profileId: userId,
token, token,
user: { user: {
username, firstname, lastname, email: emailNorm, zipcode, state, area, username, firstname, lastname, email: email, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
} }
}); });
@ -954,16 +1295,19 @@ app.post('/api/signin', signinLimiter, async (req, res) => {
return res.status(400).json({ error: 'Both username and password are required' }); return res.status(400).json({ error: 'Both username and password are required' });
} }
// Use username_lookup hash for querying (username is encrypted)
const usernameLookupVal = usernameLookup(username);
// Only fetch what you need to verify creds // Only fetch what you need to verify creds
const query = ` const query = `
SELECT ua.user_id AS userProfileId, ua.hashed_password SELECT ua.user_id AS userProfileId, ua.hashed_password
FROM user_auth ua FROM user_auth ua
WHERE ua.username = ? WHERE ua.username_lookup = ?
LIMIT 1 LIMIT 1
`; `;
try { try {
const [results] = await pool.query(query, [username]); const [results] = await pool.query(query, [usernameLookupVal]);
if (!results || results.length === 0) { if (!results || results.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' }); return res.status(401).json({ error: 'Invalid username or password' });
} }
@ -974,6 +1318,9 @@ app.post('/api/signin', signinLimiter, async (req, res) => {
return res.status(401).json({ error: 'Invalid username or password' }); return res.status(401).json({ error: 'Invalid username or password' });
} }
// Update last_login timestamp
await pool.execute('UPDATE user_profile SET last_login = NOW() WHERE id = ?', [userProfileId]);
// Cookie-based session only; do NOT return id/token/user in body // Cookie-based session only; do NOT return id/token/user in body
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' }); const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions()); res.cookie(COOKIE_NAME, token, sessionCookieOptions());
@ -1052,10 +1399,17 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
const finalUserName = (userName !== undefined) const finalUserName = (userName !== undefined)
? userName ? userName
: existing?.username ?? null; : existing?.username ?? null;
const finalRiasec = riasec_scores const finalRiasec = (riasec_scores !== undefined)
? JSON.stringify(riasec_scores) ? JSON.stringify(riasec_scores)
: existing?.riasec_scores ?? null; : existing?.riasec_scores ?? null;
console.log('[user-profile] RIASEC debug:', {
riasec_scores_from_body: riasec_scores,
riasec_scores_undefined: riasec_scores === undefined,
finalRiasec: finalRiasec,
finalRiasec_length: finalRiasec?.length
});
// Normalize email and compute lookup iff email is provided (or keep existing) // Normalize email and compute lookup iff email is provided (or keep existing)
const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } }; const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } };
@ -1241,6 +1595,81 @@ app.get('/api/user-profile', requireAuth, async (req, res) => {
}); });
/* ------------------------------------------------------------------
CHECK ONBOARDING STATUS (MySQL)
------------------------------------------------------------------ */
app.get('/api/onboarding-status', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Check if user is in an organization and if onboarding should be triggered
const [rows] = await pool.query(`
SELECT
os.onboarding_triggered_at,
os.onboarding_completed,
os.grade_level
FROM organization_students os
WHERE os.user_id = ?
AND os.enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
ORDER BY os.enrollment_date DESC
LIMIT 1
`, [userId]);
if (!rows || rows.length === 0) {
// User not in any organization - no onboarding needed
return res.json({ shouldTrigger: false });
}
const { onboarding_triggered_at, onboarding_completed, grade_level } = rows[0];
// Check if trigger date has passed and onboarding not completed
// Note: grade_level check is not needed here as trigger date is only set when appropriate
// (grades 11-12 for K-12 schools, or immediately for colleges/universities)
const now = new Date();
const triggerDate = onboarding_triggered_at ? new Date(onboarding_triggered_at) : null;
const shouldTrigger =
triggerDate &&
triggerDate <= now &&
!onboarding_completed;
return res.json({
shouldTrigger: !!shouldTrigger,
triggerDate,
onboardingCompleted: !!onboarding_completed,
gradeLevel: grade_level
});
} catch (err) {
console.error('Error checking onboarding status:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
MARK ONBOARDING COMPLETED (MySQL)
------------------------------------------------------------------ */
app.post('/api/onboarding-completed', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Mark onboarding as completed for user's active enrollment
await pool.execute(`
UPDATE organization_students
SET onboarding_completed = TRUE,
onboarding_triggered = TRUE,
updated_at = NOW()
WHERE user_id = ?
AND enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
`, [userId]);
return res.json({ message: 'Onboarding marked as completed' });
} catch (err) {
console.error('Error marking onboarding complete:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
SALARY_INFO REMAINS IN SQLITE SALARY_INFO REMAINS IN SQLITE
@ -1308,6 +1737,179 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => {
} }
}); });
/* ------------------------------------------------------------------
STUDENT PRIVACY SETTINGS ENDPOINTS
------------------------------------------------------------------ */
// Get privacy settings for all organizations student belongs to
app.get('/api/privacy-settings', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Get all organizations this user is enrolled in
const [enrollments] = await pool.execute(`
SELECT os.organization_id, o.organization_name
FROM organization_students os
JOIN organizations o ON os.organization_id = o.id
WHERE os.user_id = ? AND os.enrollment_status = 'active'
`, [userId]);
if (enrollments.length === 0) {
return res.json({ organizations: [] });
}
// Get privacy settings for each organization
const settingsPromises = enrollments.map(async (enrollment) => {
const [settings] = await pool.execute(`
SELECT *
FROM student_privacy_settings
WHERE user_id = ? AND organization_id = ?
LIMIT 1
`, [userId, enrollment.organization_id]);
// Default to all false (private) if no settings exist
const hasConfigured = settings.length > 0;
const privacySettings = settings[0] || {
share_career_exploration: false,
share_interest_inventory: false,
share_career_profiles: false,
share_college_profiles: false,
share_financial_profile: false,
share_roadmap: false
};
return {
organization_id: enrollment.organization_id,
organization_name: enrollment.organization_name,
settings: privacySettings,
has_configured: hasConfigured
};
});
const allSettings = await Promise.all(settingsPromises);
res.json({ organizations: allSettings });
} catch (err) {
console.error('[privacy-settings GET] Error:', err.message);
res.status(500).json({ error: 'Failed to load privacy settings' });
}
});
// Update privacy settings for a specific organization
app.post('/api/privacy-settings', requireAuth, async (req, res) => {
const userId = req.userId;
const {
organization_id,
share_career_exploration,
share_interest_inventory,
share_career_profiles,
share_college_profiles,
share_financial_profile,
share_roadmap
} = req.body;
if (!organization_id) {
return res.status(400).json({ error: 'organization_id is required' });
}
try {
// Verify user is enrolled in this organization
const [enrollment] = await pool.execute(`
SELECT id FROM organization_students
WHERE user_id = ? AND organization_id = ? AND enrollment_status = 'active'
LIMIT 1
`, [userId, organization_id]);
if (!enrollment || enrollment.length === 0) {
return res.status(403).json({ error: 'Not enrolled in this organization' });
}
// Check if settings already exist
const [existing] = await pool.execute(`
SELECT id FROM student_privacy_settings
WHERE user_id = ? AND organization_id = ?
LIMIT 1
`, [userId, organization_id]);
if (existing && existing.length > 0) {
// Update existing settings
await pool.execute(`
UPDATE student_privacy_settings
SET
share_career_exploration = ?,
share_interest_inventory = ?,
share_career_profiles = ?,
share_college_profiles = ?,
share_financial_profile = ?,
share_roadmap = ?,
updated_at = UTC_TIMESTAMP()
WHERE user_id = ? AND organization_id = ?
`, [
share_career_exploration ?? false,
share_interest_inventory ?? false,
share_career_profiles ?? false,
share_college_profiles ?? false,
share_financial_profile ?? false,
share_roadmap ?? false,
userId,
organization_id
]);
} else {
// Insert new settings (default to false for privacy)
await pool.execute(`
INSERT INTO student_privacy_settings
(user_id, organization_id, share_career_exploration, share_interest_inventory,
share_career_profiles, share_college_profiles, share_financial_profile, share_roadmap,
updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())
`, [
userId,
organization_id,
share_career_exploration ?? false,
share_interest_inventory ?? false,
share_career_profiles ?? false,
share_college_profiles ?? false,
share_financial_profile ?? false,
share_roadmap ?? false
]);
}
res.json({ message: 'Privacy settings updated successfully' });
} catch (err) {
console.error('[privacy-settings POST] Error:', err.message);
res.status(500).json({ error: 'Failed to update privacy settings' });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// CAREER VIEW TRACKING
// ═══════════════════════════════════════════════════════════════════════════
app.post('/api/track-career-view', requireAuth, async (req, res) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { career_soc_code, career_name } = req.body;
if (!career_soc_code || !career_name) {
return res.status(400).json({ error: 'career_soc_code and career_name are required' });
}
// Insert career view (fire and forget - don't need to wait for response)
pool.execute(
'INSERT INTO career_views (user_id, career_soc_code, career_name) VALUES (?, ?, ?)',
[userId, career_soc_code, career_name]
).catch(err => console.error('[track-career-view] Failed to insert:', err.message));
res.json({ success: true });
} catch (err) {
console.error('[track-career-view] Error:', err.message);
res.status(500).json({ error: 'Failed to track career view' });
}
});
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
if (res.headersSent) return; if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res); const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);

View File

@ -1067,25 +1067,37 @@ app.post('/api/onet/submit_answers', async (req, res) => {
const filtered = filterHigherEducationCareers(careerSuggestions); const filtered = filterHigherEducationCareers(careerSuggestions);
const riasecCode = convertToRiasecCode(riaSecScores); const riasecCode = convertToRiasecCode(riaSecScores);
// Convert RIASEC scores to object format: {"R":23,"I":25,"A":23,"S":16,"E":15,"C":22}
const riasecScoresObject = {};
riaSecScores.forEach(item => {
riasecScoresObject[item.area[0].toUpperCase()] = item.score;
});
console.log('[submit_answers] RIASEC scores object:', riasecScoresObject);
// Pass the caller's Bearer straight through to server1 (if present) // Pass the caller's Bearer straight through to server1 (if present)
const bearer = req.headers.authorization; // e.g. "Bearer eyJ..." const bearer = req.headers.authorization; // e.g. "Bearer eyJ..."
if (bearer) { if (bearer) {
console.log('[submit_answers] Sending to /api/user-profile with RIASEC:', riasecScoresObject);
try { try {
await axios.post( const response = await axios.post(
`${API_BASE}/api/user-profile`, `${API_BASE}/api/user-profile`,
{ {
interest_inventory_answers: answers, interest_inventory_answers: answers,
riasec: riasecCode, riasec: riasecScoresObject,
}, },
{ headers: { Authorization: bearer } } { headers: { Authorization: bearer } }
); );
console.log('[submit_answers] Successfully saved RIASEC scores');
} catch (err) { } catch (err) {
console.error( console.error(
'Error storing RIASEC in user_profile =>', '[submit_answers] Error storing RIASEC in user_profile =>',
err.response?.data || err.message err.response?.data || err.message
); );
// non-fatal for the O*NET response // non-fatal for the O*NET response
} }
} else {
console.log('[submit_answers] No bearer token, skipping RIASEC save');
} }
res.status(200).json({ res.status(200).json({
@ -1141,7 +1153,7 @@ function convertToRiasecCode(riaSecScores) {
} }
// ONet career details // ONet career details
app.get('/api/onet/career-details/:socCode', async (req, res) => { app.get('/api/onet/career-details/:socCode', authenticateUser, async (req, res) => {
const { socCode } = req.params; const { socCode } = req.params;
if (!socCode) { if (!socCode) {
return res.status(400).json({ error: 'SOC code is required' }); return res.status(400).json({ error: 'SOC code is required' });
@ -1154,6 +1166,7 @@ app.get('/api/onet/career-details/:socCode', async (req, res) => {
}, },
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
res.status(200).json(response.data); res.status(200).json(response.data);
} catch (err) { } catch (err) {
console.error('Error fetching career details:', err); console.error('Error fetching career details:', err);

View File

@ -1149,6 +1149,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
const { const {
scenario_title, scenario_title,
career_name, career_name,
career_soc_code,
status, status,
start_date, start_date,
college_enrollment_status, college_enrollment_status,
@ -1174,13 +1175,14 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
try { try {
const finalId = req.body.id || uuidv4(); const finalId = req.body.id || uuidv4();
// 1) Insert includes career_goals // 1) Insert includes career_goals and career_soc_code
const sql = ` const sql = `
INSERT INTO career_profiles ( INSERT INTO career_profiles (
id, id,
user_id, user_id,
scenario_title, scenario_title,
career_name, career_name,
career_soc_code,
status, status,
start_date, start_date,
college_enrollment_status, college_enrollment_status,
@ -1196,8 +1198,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_surplus_retirement_pct, planned_surplus_retirement_pct,
planned_additional_income planned_additional_income
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
career_soc_code = VALUES(career_soc_code),
status = VALUES(status), status = VALUES(status),
start_date = VALUES(start_date), start_date = VALUES(start_date),
college_enrollment_status = VALUES(college_enrollment_status), college_enrollment_status = VALUES(college_enrollment_status),
@ -1220,6 +1223,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
req.id, req.id,
scenario_title || null, scenario_title || null,
career_name, career_name,
career_soc_code || null,
status || 'planned', status || 'planned',
start_date || null, start_date || null,
college_enrollment_status || null, college_enrollment_status || null,
@ -1479,11 +1483,7 @@ const lastAssistantIsOneShotQ =
const _scenarioRow = scenarioRow || {}; const _scenarioRow = scenarioRow || {};
const _financialProfile = financialProfile || {}; const _financialProfile = financialProfile || {};
const _collegeProfile = collegeProfile || {}; const _collegeProfile = collegeProfile || {};
// 1) USER PROFILE // 1) USER PROFILE (PII removed - no names/username sent to AI)
const firstName = userProfile.firstname || "N/A";
const lastName = userProfile.lastname || "N/A";
const fullName = `${firstName} ${lastName}`;
const username = _userProfile.username || "N/A";
const location = _userProfile.area || _userProfile.state || "Unknown Region"; const location = _userProfile.area || _userProfile.state || "Unknown Region";
const careerSituation = _userProfile.career_situation || "Not provided"; const careerSituation = _userProfile.career_situation || "Not provided";
@ -1604,11 +1604,9 @@ Occupation: ${economicProjections.national.occupationName}
`.trim(); `.trim();
} }
// 8) BUILD THE FINAL TEXT // 8) BUILD THE FINAL TEXT (PII removed - no student names/usernames)
return ` return `
[USER PROFILE] [USER PROFILE]
- Full Name: ${fullName}
- Username: ${username}
- Location: ${location} - Location: ${location}
- Career Situation: ${careerSituation} - Career Situation: ${careerSituation}
- RIASEC: - RIASEC:

3129
backend/server4.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,9 @@ const WRITE_RE = /^\s*(insert|update|replace)\s/i;
/* ── map of columns that must be protected ─────────────────── */ /* ── map of columns that must be protected ─────────────────── */
const TABLE_MAP = { const TABLE_MAP = {
user_auth : [
'username', 'date_of_birth'
],
user_profile : [ user_profile : [
'username', 'firstname', 'lastname', 'email', 'phone_e164', 'username', 'firstname', 'lastname', 'email', 'phone_e164',
'zipcode', 'stripe_customer_id', 'zipcode', 'stripe_customer_id',

View File

@ -18,7 +18,7 @@ echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)"
SECRETS=( SECRETS=(
ENV_NAME PROJECT CORS_ALLOWED_ORIGINS ENV_NAME PROJECT CORS_ALLOWED_ORIGINS
TOKEN_MAX_AGE_MS COOKIE_SECURE COOKIE_SAMESITE ACCESS_COOKIE_NAME TOKEN_MAX_AGE_MS COOKIE_SECURE COOKIE_SAMESITE ACCESS_COOKIE_NAME
SERVER1_PORT SERVER2_PORT SERVER3_PORT SERVER1_PORT SERVER2_PORT SERVER3_PORT SERVER4_PORT
JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD
STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET
STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR
@ -27,7 +27,7 @@ SECRETS=(
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA DB_SSL_CERT DB_SSL_KEY DB_SSL_CA
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID
GOOGLE_MAPS_API_KEY GOOGLE_MAPS_API_KEY ADMIN_PORTAL_URL
KMS_KEY_NAME DEK_PATH KMS_KEY_NAME DEK_PATH
) )
@ -103,7 +103,7 @@ build_and_push () {
docker push "${REG}/${svc}:${TAG}" docker push "${REG}/${svc}:${TAG}"
} }
SERVICES=(server1 server2 server3 nginx) SERVICES=(server1 server2 server3 server4 nginx)
# Build & push to DEV registry first (source of truth) # Build & push to DEV registry first (source of truth)
for svc in "${SERVICES[@]}"; do for svc in "${SERVICES[@]}"; do

View File

@ -173,12 +173,56 @@ services:
retries: 5 retries: 5
start_period: 25s start_period: 25s
# ───────────────────────────── server4 ─────────────────────────────
server4:
depends_on: [dek-init]
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server4:${IMG_TAG}
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
expose: ["${SERVER4_PORT}"]
environment:
ENV_NAME: ${ENV_NAME}
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET}
APTIVA_API_BASE: ${APTIVA_API_BASE}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
DB_SSL_CERT: ${DB_SSL_CERT}
DB_SSL_KEY: ${DB_SSL_KEY}
DB_SSL_CA: ${DB_SSL_CA}
DB_POOL_SIZE: "6"
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
ADMIN_PORTAL_URL: ${ADMIN_PORTAL_URL}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
- dek-vol:/run/secrets/dev:ro
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER4_PORT}/healthz || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 25s
# ───────────────────────────── nginx ─────────────────────────────── # ───────────────────────────── nginx ───────────────────────────────
nginx: nginx:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/nginx:${IMG_TAG} image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/nginx:${IMG_TAG}
command: ["nginx", "-g", "daemon off;"] command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3] depends_on: [server1, server2, server3, server4]
networks: [default, aptiva-shared] networks: [default, aptiva-shared]
environment: environment:
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}

View File

@ -1,281 +0,0 @@
/* ───────────────────────── user_profile ───────────────────────── */
ALTER TABLE user_profile
MODIFY firstname VARCHAR(400),
MODIFY lastname VARCHAR(400),
MODIFY email VARCHAR(512),
MODIFY phone_e164 VARCHAR(128),
MODIFY zipcode VARCHAR(64),
MODIFY stripe_customer_id VARCHAR(128),
MODIFY interest_inventory_answers MEDIUMTEXT,
MODIFY riasec_scores VARCHAR(768),
MODIFY career_priorities MEDIUMTEXT,
MODIFY career_list MEDIUMTEXT;
/* ───────────────────────── financial_profiles ─────────────────── */
ALTER TABLE financial_profiles
MODIFY current_salary VARCHAR(128),
MODIFY additional_income VARCHAR(128),
MODIFY monthly_expenses VARCHAR(128),
MODIFY monthly_debt_payments VARCHAR(128),
MODIFY retirement_savings VARCHAR(128),
MODIFY emergency_fund VARCHAR(128),
MODIFY retirement_contribution VARCHAR(128),
MODIFY emergency_contribution VARCHAR(128),
MODIFY extra_cash_emergency_pct VARCHAR(64),
MODIFY extra_cash_retirement_pct VARCHAR(64);
/* ───────────────────────── career_profiles ────────────────────── */
ALTER TABLE career_profiles
MODIFY COLUMN career_name VARCHAR(255) NULL,
MODIFY COLUMN start_date VARCHAR(32) NULL,
MODIFY COLUMN retirement_start_date VARCHAR(32) NULL,
MODIFY COLUMN planned_monthly_expenses VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_debt_payments VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_retirement_contribution VARCHAR(128) NULL,
MODIFY COLUMN planned_monthly_emergency_contribution VARCHAR(128) NULL,
MODIFY COLUMN planned_surplus_emergency_pct VARCHAR(128) NULL,
MODIFY COLUMN planned_surplus_retirement_pct VARCHAR(128) NULL,
MODIFY COLUMN planned_additional_income VARCHAR(128) NULL,
MODIFY COLUMN career_goals MEDIUMTEXT NULL,
MODIFY COLUMN desired_retirement_income_monthly VARCHAR(128) NULL,
MODIFY COLUMN scenario_title VARCHAR(255) NULL;
encryption_canary(id INTEGER PRIMARY KEY, value TEXT)
ALTER TABLE college_profiles
MODIFY COLUMN selected_school VARCHAR(512) NULL,
MODIFY COLUMN selected_program VARCHAR(512) NULL,
MODIFY COLUMN annual_financial_aid VARCHAR(128) NULL,
MODIFY COLUMN existing_college_debt VARCHAR(128) NULL,
MODIFY COLUMN tuition VARCHAR(128) NULL,
MODIFY COLUMN tuition_paid VARCHAR(128) NULL,
MODIFY COLUMN loan_deferral_until_graduation VARCHAR(128) NULL,
MODIFY COLUMN loan_term VARCHAR(128) NULL,
MODIFY COLUMN interest_rate VARCHAR(128) NULL,
MODIFY COLUMN extra_payment VARCHAR(128) NULL,
MODIFY COLUMN expected_salary VARCHAR(128) NULL;
ALTER TABLE user_profile
ADD COLUMN stripe_customer_id_hash CHAR(64) NULL,
ADD INDEX idx_customer_hash (stripe_customer_id_hash);
/*───────────────────
STEP 1 drop old indexes
*/
SHOW INDEX FROM college_profiles\G
ALTER TABLE college_profiles
DROP FOREIGN KEY fk_college_profiles_user,
DROP FOREIGN KEY fk_college_profiles_career;
ALTER TABLE college_profiles
DROP INDEX user_id;
/*───────────────────
STEP 2 widen columns
(512byte text columns 684B once encrypted/Base64encoded)
*/
ALTER TABLE college_profiles
MODIFY selected_school VARCHAR(512),
MODIFY selected_program VARCHAR(512),
MODIFY annual_financial_aid VARCHAR(128),
MODIFY existing_college_debt VARCHAR(128),
MODIFY tuition VARCHAR(128),
MODIFY tuition_paid VARCHAR(128),
MODIFY loan_deferral_until_graduation VARCHAR(64),
MODIFY loan_term VARCHAR(64),
MODIFY interest_rate VARCHAR(64),
MODIFY extra_payment VARCHAR(128),
MODIFY expected_salary VARCHAR(128);
ALTER TABLE college_profiles
ADD UNIQUE KEY ux_user_school_prog (
user_id,
career_profile_id,
selected_school(192),
selected_program(192),
program_type
);
ALTER TABLE college_profiles
ADD CONSTRAINT fk_college_profiles_user
FOREIGN KEY (user_id) REFERENCES user_profile(id)
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT fk_college_profiles_career
FOREIGN KEY (career_profile_id) REFERENCES career_profiles(id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE college_profiles
ADD INDEX idx_college_user (user_id),
ADD INDEX idx_college_career (career_profile_id);
/*───────────────────
STEP 3 recreate indexes with safe prefixes (optional)
If you *dont* need to query by these columns any more,
just commentout or delete this block.
*/
CREATE INDEX idx_school ON college_profiles (selected_school(191));
CREATE INDEX idx_program ON college_profiles (selected_program(191));
CREATE INDEX idx_school_prog ON college_profiles (selected_school(191),
selected_program(191));
/* ───────────────────────── misc small tables ──────────────────── */
ALTER TABLE milestones
MODIFY COLUMN title VARCHAR(255) NULL,
MODIFY COLUMN description MEDIUMTEXT NULL,
MODIFY COLUMN date VARCHAR(32) NULL,
MODIFY COLUMN progress VARCHAR(16) NULL;
ALTER TABLE tasks
MODIFY COLUMN title VARCHAR(255) NULL,
MODIFY COLUMN description MEDIUMTEXT NULL,
MODIFY COLUMN due_date VARCHAR(32) NULL;
ALTER TABLE reminders
MODIFY COLUMN phone_e164 VARCHAR(128) NULL,
MODIFY COLUMN message_body MEDIUMTEXT NULL;
ALTER TABLE milestone_impacts
MODIFY COLUMN impact_type VARCHAR(64) NULL,
MODIFY COLUMN direction VARCHAR(32) NULL,
MODIFY COLUMN amount VARCHAR(128) NULL;
ALTER TABLE user_profile
ADD UNIQUE KEY ux_email_lookup (email_lookup);
ALTER TABLE ai_risk_analysis
MODIFY reasoning MEDIUMTEXT,
MODIFY raw_prompt MEDIUMTEXT;
ALTER TABLE ai_generated_ksa
MODIFY knowledge_json MEDIUMTEXT,
MODIFY abilities_json MEDIUMTEXT,
MODIFY skills_json MEDIUMTEXT;
ALTER TABLE ai_suggested_milestones
MODIFY suggestion_text MEDIUMTEXT;
ALTER TABLE context_cache
MODIFY ctx_text MEDIUMTEXT;
ALTER TABLE user_profile
ADD COLUMN email_lookup CHAR(64) NOT NULL DEFAULT '' AFTER email;
CREATE INDEX idx_user_profile_email_lookup
ON user_profile (email_lookup);
CREATE INDEX idx_password_resets_token_hash ON password_resets (token_hash);
ALTER TABLE user_auth
ADD COLUMN password_changed_at BIGINT UNSIGNED NULL AFTER hashed_password;
-- Optional but useful:
CREATE INDEX ix_user_auth_userid_changedat ON user_auth (user_id, password_changed_at);
UPDATE user_auth
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
WHERE user_id = ?
-- MySQL
CREATE TABLE IF NOT EXISTS onboarding_drafts (
user_id BIGINT NOT NULL,
id CHAR(36) NOT NULL,
step TINYINT NOT NULL DEFAULT 0,
data JSON NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
UNIQUE KEY uniq_id (id)
);
-- ai_chat_threads: one row per conversation
CREATE TABLE IF NOT EXISTS ai_chat_threads (
id CHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
bot_type ENUM('support','retire','coach') NOT NULL,
title VARCHAR(200) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (user_id, bot_type, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ai_chat_messages: ordered messages in a thread
CREATE TABLE IF NOT EXISTS ai_chat_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id CHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
role ENUM('user','assistant','system') NOT NULL,
content MEDIUMTEXT NOT NULL,
meta_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (thread_id, created_at),
CONSTRAINT fk_chat_thread
FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Orphan message thread_ids (no matching thread row)
SELECT DISTINCT m.thread_id
FROM ai_chat_messages m
LEFT JOIN ai_chat_threads t ON t.id = m.thread_id
WHERE t.id IS NULL;
INSERT INTO ai_chat_threads (id, user_id, bot_type, title)
SELECT m.thread_id, 58, 'coach', 'CareerCoach chat'
FROM ai_chat_messages m
LEFT JOIN ai_chat_threads t ON t.id = m.thread_id
WHERE t.id IS NULL;
ALTER TABLE ai_chat_messages
ADD CONSTRAINT fk_messages_thread
FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id)
ON DELETE CASCADE;
mysqldump \
--host=34.67.180.54 \
--port=3306 \
--user=sqluser \
-p \
--no-data \
--routines \
--triggers \
--events \
user_profile_db > full_schema.sql
-- /home/jcoakley/sql/2025-09-11_add_verification_flags.sql
ALTER TABLE user_profile
ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup,
ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164;
ALTER TABLE user_profile
ADD COLUMN sms_reminders_opt_in TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN sms_reminders_opt_in_at DATETIME NULL;
-- 005_alter_career_profiles__attach_resume.sql
ALTER TABLE career_profiles
ADD COLUMN resume_storage_key VARCHAR(255) NULL AFTER career_goals,
ADD COLUMN resume_filename VARCHAR(160) NULL AFTER resume_storage_key,
ADD COLUMN resume_filesize INT UNSIGNED NULL AFTER resume_filename,
ADD COLUMN resume_uploaded_at DATETIME NULL AFTER resume_filesize;
CREATE TABLE IF NOT EXISTS demo_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(512) NOT NULL,
email VARCHAR(512) NOT NULL,
organization_name VARCHAR(512) NOT NULL,
phone VARCHAR(512),
message TEXT,
source VARCHAR(512) DEFAULT 'conference',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at),
INDEX idx_source (source(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -19,6 +19,7 @@ http {
upstream backend5001 { server server2:5001; upstream backend5001 { server server2:5001;
keepalive 1024;} # onet, distance, etc. keepalive 1024;} # onet, distance, etc.
upstream backend5002 { server server3:5002; } # premium upstream backend5002 { server server3:5002; } # premium
upstream backend5003 { server server4:5003; } # admin portal
upstream gitea_backend { server gitea:3000; } # gitea service (shared network) upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
upstream woodpecker_backend { server woodpecker-server:8000; } upstream woodpecker_backend { server woodpecker-server:8000; }
@ -195,10 +196,23 @@ http {
limit_req zone=reqperip burst=10 nodelay; limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; } proxy_pass http://backend5000; }
location = /api/privacy-settings { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location = /api/track-career-view { limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5000; }
location = /api/demo-request { limit_conn perip 5; location = /api/demo-request { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay; limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5001; } proxy_pass http://backend5001; }
# Admin portal API routes (server4)
location ^~ /api/admin/ { limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5003; }
# General API (anything not matched above) rate-limited # General API (anything not matched above) rate-limited
location ^~ /api/ { proxy_pass http://backend5000; } location ^~ /api/ { proxy_pass http://backend5000; }
@ -212,6 +226,69 @@ http {
location = /50x.html { root /usr/share/nginx/html; } location = /50x.html { root /usr/share/nginx/html; }
} }
########################################################################
# 2.5 Admin Portal (HTTPS) dev1.admin.aptivaai.com
########################################################################
server {
listen 443 ssl;
http2 on;
server_name dev1.admin.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/dev1.admin.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.admin.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options DENY always;
client_max_body_size 10m;
# Host validation
if ($host !~* ^(dev1\.admin\.aptivaai\.com)$) { return 444; }
# Serve same React app (admin routes protected by React Router + auth)
root /usr/share/nginx/html;
index index.html;
# React SPA - serve index.html for all routes
location / {
try_files $uri $uri/index.html /index.html;
}
# Static assets
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
expires 6M;
access_log off;
}
# Admin API routes to server4
location ^~ /api/admin/ {
limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Other API routes still work (for shared endpoints if needed)
location ^~ /api/ {
proxy_pass http://backend5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
error_page 502 503 504 /50x.html;
location = /50x.html { root /usr/share/nginx/html; }
}
######################################################################## ########################################################################
# 3. Gitea virtual host (HTTPS) gitea.dev1.aptivaai.com # 3. Gitea virtual host (HTTPS) gitea.dev1.aptivaai.com
######################################################################## ########################################################################

View File

@ -15,6 +15,8 @@ import SessionExpiredHandler from './components/SessionExpiredHandler.js';
import SignInLanding from './components/SignInLanding.js'; import SignInLanding from './components/SignInLanding.js';
import SignIn from './components/SignIn.js'; import SignIn from './components/SignIn.js';
import SignUp from './components/SignUp.js'; import SignUp from './components/SignUp.js';
import InviteResponse from './components/InviteResponse.js';
import LinkSecondaryEmail from './components/LinkSecondaryEmail.js';
import PlanningLanding from './components/PlanningLanding.js'; import PlanningLanding from './components/PlanningLanding.js';
import CareerExplorer from './components/CareerExplorer.js'; import CareerExplorer from './components/CareerExplorer.js';
import PreparingLanding from './components/PreparingLanding.js'; import PreparingLanding from './components/PreparingLanding.js';
@ -49,11 +51,21 @@ import VerificationGate from './components/VerificationGate.js';
import Verify from './components/Verify.js'; import Verify from './components/Verify.js';
import { initNetObserver } from './utils/net.js'; import { initNetObserver } from './utils/net.js';
import PrivacyPolicy from './components/PrivacyPolicy.js'; import PrivacyPolicy from './components/PrivacyPolicy.js';
import PrivacySettings from './components/PrivacySettings.js';
import PrivacySettingsModal from './components/PrivacySettingsModal.js';
import TermsOfService from './components/TermsOfService.js'; import TermsOfService from './components/TermsOfService.js';
import HomePage from './components/HomePage.js'; import HomePage from './components/HomePage.js';
import DemoRequest from './components/DemoRequest.js'; import DemoRequest from './components/DemoRequest.js';
// Admin Portal Components
import { AdminProvider, useAdmin } from './contexts/AdminContext.js';
import AdminLogin from './components/Admin/AdminLogin.js';
import Dashboard from './components/Admin/Dashboard.js';
import StudentList from './components/Admin/StudentList.js';
import AddStudent from './components/Admin/AddStudent.js';
import StudentDetail from './components/Admin/StudentDetail.js';
import Settings from './components/Admin/Settings.js';
import RosterUpload from './components/Admin/RosterUpload.js';
export const ProfileCtx = React.createContext(); export const ProfileCtx = React.createContext();
@ -185,6 +197,9 @@ const canShowRetireBot =
// Logout warning modal // Logout warning modal
const [showLogoutWarning, setShowLogoutWarning] = useState(false); const [showLogoutWarning, setShowLogoutWarning] = useState(false);
// Privacy settings modal (show once after organization signup)
const [showPrivacyModal, setShowPrivacyModal] = useState(false);
const [hasOrgEnrollment, setHasOrgEnrollment] = useState(false);
// 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;
@ -222,19 +237,19 @@ const showPremiumCTA = !premiumPaths.some(p =>
// ============================== // ==============================
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (loggingOut) return; if (loggingOut) return;
// Skip auth probe on all public auth routes // Skip auth probe on all public auth routes
if ( if (
location.pathname.startsWith('/reset-password') || location.pathname.startsWith('/reset-password') ||
location.pathname === '/signin' || location.pathname === '/signin' ||
location.pathname === '/signup' || location.pathname === '/signup' ||
location.pathname === '/forgot-password' || location.pathname === '/forgot-password' ||
location.pathname === '/privacy' || location.pathname === '/privacy' ||
location.pathname === '/terms' || location.pathname === '/terms' ||
location.pathname === '/home' || location.pathname === '/home' ||
location.pathname === '/demo' location.pathname === '/demo'
) { ) {
try { localStorage.removeItem('id'); } catch {} try { localStorage.removeItem('id'); } catch {}
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
@ -255,7 +270,28 @@ if (loggingOut) return;
is_premium : !!data?.is_premium, is_premium : !!data?.is_premium,
is_pro_premium: !!data?.is_pro_premium, is_pro_premium: !!data?.is_pro_premium,
})); }));
// Clear user-specific localStorage on login to prevent cross-user contamination
try {
localStorage.removeItem('selectedCareer');
localStorage.removeItem('lastSelectedCareerProfileId');
localStorage.removeItem('aiRecommendations');
localStorage.removeItem('premiumOnboardingPointer');
} catch {}
setIsAuthenticated(true); setIsAuthenticated(true);
// Check if onboarding should be triggered for roster students
try {
const { data: onboardingStatus } = await api.get('/api/onboarding-status');
if (onboardingStatus?.shouldTrigger && !IN_OB(location.pathname)) {
// Redirect to premium onboarding if trigger date has passed
navigate('/premium-onboarding', { replace: true });
}
} catch (obErr) {
// Silently fail - onboarding check is non-critical
console.warn('Failed to check onboarding status:', obErr);
}
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
clearToken(); clearToken();
@ -272,7 +308,9 @@ if (loggingOut) return;
p === '/privacy' || p === '/privacy' ||
p === '/terms' || p === '/terms' ||
p === '/home' || p === '/home' ||
p === '/demo'; p === '/demo' ||
p === '/invite-response' ||
p === '/link-secondary-email';
if (!onPublic) navigate('/signin?session=expired', { replace: true }); if (!onPublic) navigate('/signin?session=expired', { replace: true });
} finally { } finally {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
@ -285,6 +323,40 @@ if (loggingOut) return;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, navigate, loggingOut]); }, [location.pathname, navigate, loggingOut]);
// ==========================
// Check for org enrollment and show privacy modal
// ==========================
useEffect(() => {
if (!isAuthenticated || !user) return;
// Check if user signed up via invitation (has org enrollment) and hasn't configured privacy yet
const checkPrivacySettings = async () => {
try {
const { data } = await api.get('/api/privacy-settings');
if (data.organizations && data.organizations.length > 0) {
setHasOrgEnrollment(true);
// Check if privacy settings haven't been configured yet
// (has_configured flag indicates if they've saved settings at least once)
const allConfigured = data.organizations.every(org => org.has_configured);
// Only show modal if they haven't configured settings yet
if (!allConfigured) {
// Small delay to let the UI settle after login/signup
setTimeout(() => setShowPrivacyModal(true), 500);
}
} else {
setHasOrgEnrollment(false);
}
} catch (err) {
console.error('[App] Error checking privacy settings:', err);
setHasOrgEnrollment(false);
}
};
checkPrivacySettings();
}, [isAuthenticated, user]);
// ========================== // ==========================
// 2) Logout Handler + Modal // 2) Logout Handler + Modal
// ========================== // ==========================
@ -561,6 +633,14 @@ const cancelLogout = () => {
> >
Account Account
</Link> </Link>
{hasOrgEnrollment && (
<Link
to="/privacy-settings"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Privacy Settings
</Link>
)}
<Link <Link
to="/financial-profile" to="/financial-profile"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700" className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
@ -772,6 +852,9 @@ const cancelLogout = () => {
<div className="pl-3 space-y-1"> <div className="pl-3 space-y-1">
<Link to="/profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Account</Link> <Link to="/profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Account</Link>
{hasOrgEnrollment && (
<Link to="/privacy-settings" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Privacy Settings</Link>
)}
<Link to="/financial-profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Financial Profile</Link> <Link to="/financial-profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Financial Profile</Link>
{canAccessPremium ? ( {canAccessPremium ? (
<Link to="/profile/careers" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Career Profiles</Link> <Link to="/profile/careers" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Career Profiles</Link>
@ -846,6 +929,16 @@ const cancelLogout = () => {
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <SignUp setUser={setUser} />} element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <SignUp setUser={setUser} />}
/> />
<Route
path="/invite-response"
element={<InviteResponse />}
/>
<Route
path="/link-secondary-email"
element={<LinkSecondaryEmail />}
/>
<Route <Route
path="/forgot-password" path="/forgot-password"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />} element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />}
@ -863,6 +956,7 @@ const cancelLogout = () => {
<Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} /> <Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} />
<Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} /> <Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} />
<Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} /> <Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} />
<Route path="/privacy-settings" element={<VerificationGate><PrivacySettings /></VerificationGate>} />
<Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} /> <Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} />
<Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} /> <Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
<Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} /> <Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
@ -935,10 +1029,66 @@ const cancelLogout = () => {
{/* Session Handler (Optional) */} {/* Session Handler (Optional) */}
<SessionExpiredHandler /> <SessionExpiredHandler />
{/* Privacy Settings Modal (for organization students) */}
<PrivacySettingsModal
isOpen={showPrivacyModal}
onClose={() => setShowPrivacyModal(false)}
/>
</div> </div>
</ChatCtx.Provider> </ChatCtx.Provider>
</ProfileCtx.Provider> </ProfileCtx.Provider>
); );
} }
export default App; // Admin Portal Routes (separate from main app)
function AdminApp() {
const { admin, loading, isAdminPortal } = useAdmin();
// If not on admin subdomain, don't render admin app
if (!isAdminPortal) return null;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva"></div>
</div>
);
}
return (
<Routes>
<Route path="/admin/login" element={!admin ? <AdminLogin /> : <Navigate to="/admin/dashboard" replace />} />
<Route path="/admin/dashboard" element={admin ? <Dashboard /> : <Navigate to="/admin/login" replace />} />
<Route path="/admin/students" element={admin ? <StudentList /> : <Navigate to="/admin/login" replace />} />
<Route path="/admin/students/add" element={admin ? <AddStudent /> : <Navigate to="/admin/login" replace />} />
<Route path="/admin/students/:studentId" element={admin ? <StudentDetail /> : <Navigate to="/admin/login" replace />} />
<Route path="/admin/roster" element={admin ? <RosterUpload /> : <Navigate to="/admin/login" replace />} />
<Route path="/admin/settings" element={admin ? <Settings /> : <Navigate to="/admin/login" replace />} />
<Route path="/" element={<Navigate to={admin ? "/admin/dashboard" : "/admin/login"} replace />} />
<Route path="*" element={<Navigate to={admin ? "/admin/dashboard" : "/admin/login"} replace />} />
</Routes>
);
}
// Main App Wrapper
function AppWithProvider() {
return (
<AdminProvider>
<AdminAppSwitcher />
</AdminProvider>
);
}
// Switch between admin and student apps based on subdomain
function AdminAppSwitcher() {
const { isAdminPortal } = useAdmin();
if (isAdminPortal) {
return <AdminApp />;
}
return <App />;
}
export default AppWithProvider;

View File

@ -0,0 +1,155 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Button } from '../ui/button.js';
import { Input } from '../ui/input.js';
import { ArrowLeft } from 'lucide-react';
export default function AddStudent() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: '',
firstname: '',
lastname: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await axios.post('/api/admin/students', formData, {
withCredentials: true
});
// Success - redirect to student list
navigate('/admin/students', {
state: { message: 'Student added successfully!' }
});
} catch (err) {
setError(err.response?.data?.error || 'Failed to add student');
setLoading(false);
}
};
return (
<AdminLayout>
<div className="max-w-2xl">
<div className="mb-6">
<Link
to="/admin/students"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeft size={16} className="mr-1" />
Back to Students
</Link>
<h1 className="text-2xl font-bold text-gray-900">Add Student</h1>
<p className="mt-1 text-sm text-gray-500">
Add a new student to your organization
</p>
</div>
<div className="bg-white shadow rounded-lg p-6">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address <span className="text-red-500">*</span>
</label>
<Input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
placeholder="student@example.com"
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500">
Student will receive an invitation email at this address
</p>
</div>
<div>
<label htmlFor="firstname" className="block text-sm font-medium text-gray-700 mb-1">
First Name <span className="text-red-500">*</span>
</label>
<Input
id="firstname"
name="firstname"
type="text"
required
value={formData.firstname}
onChange={handleChange}
placeholder="John"
disabled={loading}
/>
</div>
<div>
<label htmlFor="lastname" className="block text-sm font-medium text-gray-700 mb-1">
Last Name <span className="text-red-500">*</span>
</label>
<Input
id="lastname"
name="lastname"
type="text"
required
value={formData.lastname}
onChange={handleChange}
placeholder="Doe"
disabled={loading}
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/students')}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? 'Adding Student...' : 'Add Student'}
</Button>
</div>
</form>
</div>
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">
What happens next?
</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Student will be added to your organization with "active" status</li>
<li> An invitation email will be sent to the student</li>
<li> Student can register and link their account using this email</li>
<li> Default privacy settings allow you to see career exploration data</li>
</ul>
</div>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts/AdminContext.js';
import { Button } from '../ui/button.js';
import {
LayoutDashboard,
Users,
Settings,
Upload,
LogOut,
Menu,
X
} from 'lucide-react';
export default function AdminLayout({ children }) {
const { admin, logout, isSuperAdmin } = useAdmin();
const location = useLocation();
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const handleLogout = async () => {
await logout();
navigate('/admin/login');
};
const navItems = [
{ path: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/admin/students', label: 'Students', icon: Users },
{ path: '/admin/roster', label: 'Roster Upload', icon: Upload },
...(isSuperAdmin ? [{ path: '/admin/settings', label: 'Settings', icon: Settings }] : [])
];
return (
<div className="min-h-screen bg-gray-50">
{/* Top Navigation Bar */}
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100"
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
<div className="flex-shrink-0 flex items-center ml-2 md:ml-0">
<span className="text-xl font-bold text-aptiva">AptivaAI</span>
<span className="ml-2 text-sm text-gray-500">Admin Portal</span>
</div>
</div>
{/* Desktop Navigation */}
<div className="hidden md:flex md:space-x-8 items-center">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
return (
<Link
key={item.path}
to={item.path}
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
isActive
? 'border-aptiva text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
}`}
>
<Icon size={18} className="mr-2" />
{item.label}
</Link>
);
})}
</div>
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="text-sm text-gray-700 mr-4">
<div className="font-medium">{admin?.organizationName}</div>
<div className="text-xs text-gray-500">
{admin?.role === 'super_admin' ? 'Super Admin' : 'Staff Admin'}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="ml-4"
>
<LogOut size={18} className="mr-2" />
Sign Out
</Button>
</div>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="md:hidden border-t">
<div className="pt-2 pb-3 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
return (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center px-4 py-2 text-base font-medium ${
isActive
? 'bg-aptiva-50 border-l-4 border-aptiva text-aptiva'
: 'border-l-4 border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300'
}`}
>
<Icon size={20} className="mr-3" />
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdmin } from '../../contexts/AdminContext.js';
import { Button } from '../ui/button.js';
import { Input } from '../ui/input.js';
export default function AdminLogin() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAdmin();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/admin/dashboard');
} else {
setError(result.error);
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
<div>
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900">
Organization Admin Portal
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to manage your organization
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="rounded-md shadow-sm space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</Button>
</div>
</form>
<div className="text-center text-xs text-gray-500">
© {new Date().getFullYear()} AptivaAI LLC
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,244 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Button } from '../ui/button.js';
import { Input } from '../ui/input.js';
import { ArrowLeft, AlertCircle, Edit2, X } from 'lucide-react';
export default function BouncedInvitations() {
const [students, setStudents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingStudent, setEditingStudent] = useState(null);
const [newEmail, setNewEmail] = useState('');
const [updating, setUpdating] = useState(false);
const fetchBouncedInvitations = async () => {
try {
setLoading(true);
const { data } = await axios.get('/api/admin/students?status=invitation_bounced', {
withCredentials: true
});
setStudents(data.students || []);
setError(null);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load bounced invitations');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchBouncedInvitations();
}, []);
const handleEditEmail = (student) => {
setEditingStudent(student);
setNewEmail(student.email);
};
const handleCancelEdit = () => {
setEditingStudent(null);
setNewEmail('');
};
const handleUpdateEmail = async () => {
if (!newEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
alert('Please enter a valid email address');
return;
}
setUpdating(true);
try {
await axios.post(`/api/admin/students/${editingStudent.user_id}/update-email`, {
email: newEmail
}, {
withCredentials: true
});
alert('Email updated and invitation resent successfully');
setEditingStudent(null);
setNewEmail('');
fetchBouncedInvitations(); // Refresh the list
} catch (err) {
alert(err.response?.data?.error || 'Failed to update email');
} finally {
setUpdating(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1 day ago';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<Link to="/admin/students" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft size={16} className="mr-1" />
Back to Students
</Link>
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<AlertCircle className="h-6 w-6 mr-2 text-red-500" />
Bounced Invitations
{students.length > 0 && (
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
{students.length}
</span>
)}
</h1>
<p className="mt-1 text-sm text-gray-500">
These email addresses are invalid or unreachable. Update the email and resend.
</p>
</div>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Loading bounced invitations...</p>
</div>
) : students.length === 0 ? (
<div className="text-center py-12 bg-white shadow rounded-lg">
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No bounced invitations</h3>
<p className="text-sm text-gray-500">
All invitation emails were delivered successfully
</p>
</div>
) : (
<div className="bg-white shadow overflow-hidden rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bounced
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reason
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{students.map((student) => (
<tr key={student.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{student.firstname} {student.lastname}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{student.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(student.status_changed_date || student.updated_at)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<span className="line-clamp-1">
{student.bounce_reason || 'Email address invalid'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button
variant="outline"
size="sm"
onClick={() => handleEditEmail(student)}
>
<Edit2 size={14} className="mr-1" />
Edit Email
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Edit Email Modal */}
{editingStudent && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Update Email Address</h3>
<button onClick={handleCancelEdit} className="text-gray-400 hover:text-gray-500">
<X size={20} />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Student
</label>
<p className="text-sm text-gray-900">
{editingStudent.firstname} {editingStudent.lastname}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Current (bounced)
</label>
<p className="text-sm text-red-600">{editingStudent.email}</p>
</div>
<div>
<label htmlFor="newEmail" className="block text-sm font-medium text-gray-700 mb-1">
New Email Address *
</label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="student@example.com"
className="w-full"
/>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 rounded-b-lg flex justify-end gap-3">
<Button variant="outline" onClick={handleCancelEdit} disabled={updating}>
Cancel
</Button>
<Button onClick={handleUpdateEmail} disabled={updating}>
{updating ? 'Updating...' : 'Update & Resend Invitation'}
</Button>
</div>
</div>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,538 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Users, UserCheck, TrendingUp, Shield, Clock, DollarSign, ArrowUpRight, AlertCircle, Upload as UploadIcon } from 'lucide-react';
export default function Dashboard() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Career Interests filters
const [careerInterests, setCareerInterests] = useState([]);
const [signalStrength, setSignalStrength] = useState('viewed');
const [timePeriod, setTimePeriod] = useState('90');
const [loadingCareers, setLoadingCareers] = useState(false);
useEffect(() => {
const fetchStats = async () => {
try {
const { data } = await axios.get('/api/admin/dashboard/stats', {
withCredentials: true
});
setStats(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load dashboard stats');
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
useEffect(() => {
const fetchCareerInterests = async () => {
setLoadingCareers(true);
try {
const { data } = await axios.get('/api/admin/dashboard/career-interests', {
params: { signalStrength, timePeriod },
withCredentials: true
});
setCareerInterests(data.careers || []);
} catch (err) {
console.error('Failed to load career interests:', err);
setCareerInterests([]);
} finally {
setLoadingCareers(false);
}
};
if (!loading) {
fetchCareerInterests();
}
}, [signalStrength, timePeriod, loading]);
if (loading) {
return (
<AdminLayout>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Loading dashboard...</p>
</div>
</AdminLayout>
);
}
if (error) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mt-1 text-sm text-gray-500">
Overview of your organization's student engagement
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{/* Total Students */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<Users className="h-6 w-6 text-gray-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Total Students
</dt>
<dd className="text-3xl font-semibold text-gray-900">
{stats?.totalStudents || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
{/* Active Students (30 days) */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UserCheck className="h-6 w-6 text-green-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Active Students (30 days)
</dt>
<dd className="text-3xl font-semibold text-gray-900">
{stats?.activeStudents || 0}
</dd>
</dl>
</div>
</div>
<div className="mt-2">
<div className="text-sm text-gray-500">
{stats?.totalStudents > 0
? `${Math.round((stats.activeStudents / stats.totalStudents) * 100)}% of total`
: '0% of total'}
</div>
</div>
</div>
</div>
{/* Engagement Rate */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-6 w-6 text-aptiva" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Engagement Rate
</dt>
<dd className="text-3xl font-semibold text-gray-900">
{stats?.totalStudents > 0
? `${Math.round((stats.activeStudents / stats.totalStudents) * 100)}%`
: '0%'}
</dd>
</dl>
</div>
</div>
</div>
</div>
{/* Admin Count */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-6 w-6 text-blue-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Admin Users
</dt>
<dd className="text-3xl font-semibold text-gray-900">
{stats?.adminCount || 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Student Career Interests */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Student Career Interests
</h3>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Show:</label>
<select
value={signalStrength}
onChange={(e) => setSignalStrength(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:ring-aptiva focus:border-aptiva"
>
<option value="viewed">Careers students viewed</option>
<option value="compared">Careers students compared</option>
<option value="profiled">Careers students built profiles for</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Time:</label>
<select
value={timePeriod}
onChange={(e) => setTimePeriod(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm focus:ring-aptiva focus:border-aptiva"
>
<option value="30">Past 30 Days</option>
<option value="90">Past 90 Days</option>
<option value="365">Past Year</option>
<option value="all">All Time</option>
</select>
</div>
</div>
{/* Career List */}
{loadingCareers ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
</div>
) : careerInterests.length > 0 ? (
<div className="space-y-3">
{careerInterests.slice(0, 10).map((career, index) => (
<div key={career.career_soc_code || career.career_soc || index} className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center text-sm font-medium text-purple-800">
{index + 1}
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-900">
{career.career_name}
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{career.student_count} {career.student_count === 1 ? 'student' : 'students'}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 py-4">
No career exploration data available for the selected filters.
</p>
)}
</div>
</div>
{/* Highest Paying Careers - moved up from below */}
{stats?.topPayingCareers && stats.topPayingCareers.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-1">
Highest Paying Careers
</h3>
<p className="text-sm text-gray-600 mb-4">Regional Salary</p>
<div className="space-y-3">
{stats.topPayingCareers.map((career, index) => (
<div key={career.soc_code} className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center text-sm font-medium text-green-800">
{index + 1}
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-900">
{career.career_name}
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<div className="flex items-center">
<DollarSign className="h-4 w-4 text-green-600 mr-1" />
<span className="text-sm font-semibold text-gray-900">
{career.median_salary ? `${(career.median_salary / 1000).toFixed(0)}k` : 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Most Jobs Projected */}
{stats?.mostJobsCareers && stats.mostJobsCareers.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-1">
Most Jobs Projected (Next 10 Years)
</h3>
<p className="text-sm text-gray-600 mb-4">{stats.orgState}</p>
<div className="space-y-3">
{stats.mostJobsCareers.map((career, index) => (
<div key={career.soc_code} className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-sm font-medium text-blue-800">
{index + 1}
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-900">
{career.career_name}
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<span className="text-sm font-semibold text-gray-900">
{career.projected_jobs ? career.projected_jobs.toLocaleString() : 'N/A'}
</span>
<span className="text-xs text-gray-500 ml-1">jobs</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Career Outcome Metrics */}
{stats?.careerOutcomes && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Career Planning Outcomes
</h3>
<div className="space-y-4">
{/* Career Profile Creation */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
<div>
<p className="text-sm font-medium text-gray-900">Students with Career Profiles</p>
<p className="text-xs text-gray-500 mt-1">Created at least one career profile</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-aptiva">{stats.careerOutcomes.studentsWithCareerProfiles}</p>
<p className="text-xs text-gray-500">
{stats.totalStudents > 0
? `${Math.round((stats.careerOutcomes.studentsWithCareerProfiles / stats.totalStudents) * 100)}%`
: '0%'} of total
</p>
</div>
</div>
{/* Career Exploration Match */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
<div>
<p className="text-sm font-medium text-gray-900">Career Profiles Matching Exploration</p>
<p className="text-xs text-gray-500 mt-1">Chose a career they explored in Career Explorer</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-green-600">{stats.careerOutcomes.careerProfilesMatchingExploration}</p>
<p className="text-xs text-gray-500">
{stats.careerOutcomes.studentsWithCareerProfiles > 0
? `${Math.round((stats.careerOutcomes.careerProfilesMatchingExploration / stats.careerOutcomes.studentsWithCareerProfiles) * 100)}%`
: '0%'} match rate
</p>
</div>
</div>
{/* Career List Match */}
<div className="flex items-center justify-between pb-4 border-b border-gray-200">
<div>
<p className="text-sm font-medium text-gray-900">Career Profiles from Career Comparison</p>
<p className="text-xs text-gray-500 mt-1">Chose a career from their comparison list</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-blue-600">{stats.careerOutcomes.careerProfilesMatchingCareerComparison}</p>
<p className="text-xs text-gray-500">
{stats.careerOutcomes.studentsWithCareerProfiles > 0
? `${Math.round((stats.careerOutcomes.careerProfilesMatchingCareerComparison / stats.careerOutcomes.studentsWithCareerProfiles) * 100)}%`
: '0%'} match rate
</p>
</div>
</div>
{/* College Profile Creation */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">Students with College Profiles</p>
<p className="text-xs text-gray-500 mt-1">Created at least one college/program profile</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-purple-600">{stats.careerOutcomes.studentsWithCollegeProfiles}</p>
<p className="text-xs text-gray-500">
{stats.totalStudents > 0
? `${Math.round((stats.careerOutcomes.studentsWithCollegeProfiles / stats.totalStudents) * 100)}%`
: '0%'} of total
</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Recent Roster Uploads */}
{stats?.recentUploads && stats.recentUploads.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Recent Roster Uploads
</h3>
<div className="space-y-3">
{stats.recentUploads.map((upload, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b border-gray-200 last:border-0">
<div className="flex items-center">
<Clock className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="text-sm font-medium text-gray-900">
{new Date(upload.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
<p className="text-xs text-gray-500">
Added {upload.students_added} student{upload.students_added !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{upload.total_roster_size} total
</p>
<p className="text-xs text-gray-500">roster size</p>
</div>
</div>
))}
</div>
<div className="mt-4">
<Link
to="/admin/roster"
className="text-sm font-medium text-aptiva hover:text-aptiva-dark"
>
View upload history
</Link>
</div>
</div>
</div>
)}
{/* Roster Update Reminders */}
{stats?.rosterReminders && stats.rosterReminders.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Roster Update Reminders
</h3>
<div className="space-y-3">
{stats.rosterReminders.map((reminder, index) => (
<div
key={index}
className={`p-4 rounded-lg border-l-4 ${
reminder.status === 'overdue'
? 'bg-red-50 border-red-500'
: 'bg-yellow-50 border-yellow-500'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<AlertCircle
className={`h-5 w-5 mr-3 ${
reminder.status === 'overdue' ? 'text-red-600' : 'text-yellow-600'
}`}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{reminder.term} Term Roster Update {reminder.status === 'overdue' ? 'Overdue' : 'Due Soon'}
</p>
<p className="text-xs text-gray-600 mt-1">
{reminder.status === 'overdue'
? `${reminder.daysUntil} days overdue`
: `Due in ${reminder.daysUntil} days`} - Deadline: {new Date(reminder.deadline).toLocaleDateString()}
</p>
</div>
</div>
<Link
to="/admin/roster"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-aptiva hover:bg-aptiva-dark"
>
<UploadIcon className="h-4 w-4 mr-2" />
Update Roster
</Link>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Fastest Growing Careers */}
{stats?.fastestGrowingCareers && stats.fastestGrowingCareers.length > 0 && (
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-1">
Fastest Growing Careers
</h3>
<p className="text-sm text-gray-600 mb-4">{stats.orgState}</p>
<div className="space-y-3">
{stats.fastestGrowingCareers.map((career, index) => (
<div key={career.soc_code} className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-sm font-medium text-blue-800">
{index + 1}
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-900">
{career.career_name}
</p>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<div className="flex items-center">
<ArrowUpRight className="h-4 w-4 text-blue-600 mr-1" />
<span className="text-sm font-semibold text-gray-900">
{career.growth_rate ? `${career.growth_rate.toFixed(1)}%` : 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,188 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Button } from '../ui/button.js';
import { ArrowLeft, Mail, RefreshCw } from 'lucide-react';
export default function PendingInvitations() {
const [students, setStudents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [resending, setResending] = useState(null);
const fetchPendingInvitations = async () => {
try {
setLoading(true);
const { data } = await axios.get('/api/admin/students?status=pending_invitation', {
withCredentials: true
});
setStudents(data.students || []);
setError(null);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load pending invitations');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPendingInvitations();
}, []);
const handleResend = async (userId, email) => {
setResending(userId);
try {
await axios.post(`/api/admin/students/${userId}/resend-invitation`, {}, {
withCredentials: true
});
alert(`Invitation resent to ${email}`);
} catch (err) {
alert(err.response?.data?.error || 'Failed to resend invitation');
} finally {
setResending(null);
}
};
const handleResendAll = async () => {
if (!window.confirm(`Resend invitations to all ${students.length} pending students?`)) return;
try {
await axios.post('/api/admin/students/resend-all-invitations', {}, {
withCredentials: true
});
alert(`Invitations resent to ${students.length} students`);
} catch (err) {
alert(err.response?.data?.error || 'Failed to resend invitations');
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return '1 day ago';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<Link to="/admin/students" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft size={16} className="mr-1" />
Back to Students
</Link>
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<Mail className="h-6 w-6 mr-2 text-gray-400" />
Pending Invitations
{students.length > 0 && (
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
{students.length}
</span>
)}
</h1>
<p className="mt-1 text-sm text-gray-500">
These students have been invited but haven't created their accounts yet
</p>
</div>
{students.length > 0 && (
<Button onClick={handleResendAll}>
<RefreshCw size={16} className="mr-2" />
Resend All Invitations
</Button>
)}
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Loading pending invitations...</p>
</div>
) : students.length === 0 ? (
<div className="text-center py-12 bg-white shadow rounded-lg">
<Mail className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No pending invitations</h3>
<p className="text-sm text-gray-500">
All invited students have created their accounts
</p>
</div>
) : (
<div className="bg-white shadow overflow-hidden rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Invited
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{students.map((student) => (
<tr key={student.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{student.firstname} {student.lastname}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{student.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(student.invitation_sent_at || student.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button
variant="outline"
size="sm"
onClick={() => handleResend(student.user_id, student.email)}
disabled={resending === student.user_id}
>
{resending === student.user_id ? (
<>
<RefreshCw size={14} className="mr-1 animate-spin" />
Sending...
</>
) : (
<>
<RefreshCw size={14} className="mr-1" />
Resend
</>
)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,438 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Upload, FileText, AlertCircle, CheckCircle, Download, History } from 'lucide-react';
import AdminLayout from './AdminLayout.js';
export default function RosterUpload() {
const [file, setFile] = useState(null);
const [preview, setPreview] = useState([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(true);
const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'history'
const [orgType, setOrgType] = useState(null);
const [loadingOrgType, setLoadingOrgType] = useState(true);
useEffect(() => {
fetchHistory();
fetchOrgType();
}, []);
const fetchHistory = async () => {
try {
const { data } = await axios.get('/api/admin/roster/history', {
withCredentials: true
});
setHistory(data);
} catch (err) {
console.error('Failed to load roster history:', err);
} finally {
setLoadingHistory(false);
}
};
const fetchOrgType = async () => {
try {
const { data } = await axios.get('/api/admin/organization/profile', {
withCredentials: true
});
setOrgType(data.organization_type);
} catch (err) {
console.error('Failed to load organization type:', err);
setOrgType(''); // Default to empty if can't fetch
} finally {
setLoadingOrgType(false);
}
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) return;
if (!selectedFile.name.endsWith('.csv')) {
setError('Please select a CSV file');
return;
}
setFile(selectedFile);
setError(null);
setSuccess(null);
// Parse CSV for preview
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target.result;
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) {
setError('CSV file is empty');
setPreview([]);
return;
}
// Parse header
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
// Validate required columns (grade_level required for K-12 schools only)
const requiredCols = orgType === 'K-12 School'
? ['email', 'firstname', 'lastname', 'grade_level']
: ['email', 'firstname', 'lastname'];
const missing = requiredCols.filter(col => !headers.includes(col));
if (missing.length > 0) {
setError(`Missing required columns: ${missing.join(', ')}`);
setPreview([]);
return;
}
// Parse rows (limit preview to first 10)
const rows = [];
for (let i = 1; i < Math.min(lines.length, 11); i++) {
const values = lines[i].split(',').map(v => v.trim());
const row = {};
headers.forEach((header, idx) => {
row[header] = values[idx] || '';
});
// Basic validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
let valid = emailRegex.test(row.email) && row.firstname && row.lastname;
// For K-12 schools, grade_level is required and must be 9-12
if (orgType === 'K-12 School') {
if (row.grade_level && row.grade_level.trim()) {
const gradeLevel = parseInt(row.grade_level);
valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12;
} else {
valid = false; // Missing required grade_level
}
} else {
// For non-K12, grade_level is optional but if provided must be 9-12
if (row.grade_level && row.grade_level.trim()) {
const gradeLevel = parseInt(row.grade_level);
valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12;
}
}
row.valid = valid;
rows.push(row);
}
setPreview(rows);
if (lines.length > 11) {
setError(null);
}
};
reader.readAsText(selectedFile);
};
const handleUpload = async () => {
if (!file || preview.length === 0) {
setError('Please select a file first');
return;
}
setUploading(true);
setError(null);
setSuccess(null);
try {
// Use the already-parsed preview data but parse entire file
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim());
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
// Parse ALL rows (not just preview)
const students = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
const student = {};
headers.forEach((header, idx) => {
student[header] = values[idx] || '';
});
// Only include if has required fields
if (student.email && student.firstname && student.lastname) {
const studentData = {
email: student.email,
firstname: student.firstname,
lastname: student.lastname,
status: student.status || 'active'
};
// Include grade_level only if provided and valid
if (student.grade_level && student.grade_level.trim()) {
const gradeLevel = parseInt(student.grade_level);
if (!isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12) {
studentData.grade_level = gradeLevel;
}
}
students.push(studentData);
}
}
if (students.length === 0) {
setError('No valid students found in CSV');
setUploading(false);
return;
}
// Send parsed JSON to backend
const { data } = await axios.post('/api/admin/roster/upload',
{ students },
{ withCredentials: true }
);
setSuccess(`Successfully added ${data.results.added} students, updated ${data.results.updated} existing students${data.results.errors.length > 0 ? `, ${data.results.errors.length} errors` : ''}`);
setFile(null);
setPreview([]);
// Refresh history
fetchHistory();
// Reset file input
const fileInput = document.getElementById('roster-file-input');
if (fileInput) fileInput.value = '';
} catch (err) {
setError(err.response?.data?.error || 'Failed to upload roster');
} finally {
setUploading(false);
}
};
const downloadTemplate = () => {
const csv = orgType === 'K-12 School'
? 'email,firstname,lastname,grade_level\nstudent1@example.com,John,Doe,11\nstudent2@example.com,Jane,Smith,12'
: 'email,firstname,lastname\nstudent1@example.com,John,Doe\nstudent2@example.com,Jane,Smith';
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'roster_template.csv';
a.click();
URL.revokeObjectURL(url);
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Roster Management</h1>
<p className="mt-1 text-sm text-gray-500">
Upload student rosters and view upload history
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('upload')}
className={`${
activeTab === 'upload'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<Upload className="inline-block w-4 h-4 mr-2" />
Upload Roster
</button>
<button
onClick={() => setActiveTab('history')}
className={`${
activeTab === 'history'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<History className="inline-block w-4 h-4 mr-2" />
Upload History
</button>
</nav>
</div>
{/* Upload Tab */}
{activeTab === 'upload' && (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">CSV Format Requirements</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Required columns: <code className="bg-blue-100 px-1 rounded">email</code>, <code className="bg-blue-100 px-1 rounded">firstname</code>, <code className="bg-blue-100 px-1 rounded">lastname</code>{orgType === 'K-12 School' && <>, <code className="bg-blue-100 px-1 rounded">grade_level</code></>}</li>
{orgType === 'K-12 School' ? (
<li> Grade level must be 9-12 (9th-12th grade)</li>
) : (
<li> Optional column: <code className="bg-blue-100 px-1 rounded">grade_level</code> (if provided, must be 9-12)</li>
)}
<li> Header row must be included</li>
<li> Email addresses must be valid format</li>
<li> Duplicate emails will be skipped automatically</li>
</ul>
<button
onClick={downloadTemplate}
className="mt-3 inline-flex items-center text-sm text-aptiva hover:text-aptiva-dark font-medium"
>
<Download className="w-4 h-4 mr-1" />
Download CSV Template
</button>
</div>
{/* File Upload */}
<div className="bg-white shadow rounded-lg p-6">
<label
htmlFor="roster-file-input"
className="block w-full border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-gray-400 cursor-pointer transition-colors"
>
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm font-medium text-gray-900">
{file ? file.name : 'Click to select CSV file'}
</p>
<p className="mt-1 text-xs text-gray-500">or drag and drop</p>
<input
id="roster-file-input"
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
</label>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
<AlertCircle className="h-5 w-5 text-red-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start">
<CheckCircle className="h-5 w-5 text-green-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800">{success}</p>
</div>
)}
{preview.length > 0 && (
<div className="mt-6">
<h3 className="text-sm font-medium text-gray-900 mb-3">
Preview ({preview.length} rows shown)
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">First Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Last Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Grade</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{preview.map((row, idx) => (
<tr key={idx}>
<td className="px-3 py-2 text-sm text-gray-900">{row.email}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.firstname}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.lastname}</td>
<td className="px-3 py-2 text-sm text-gray-900">{row.grade_level}</td>
<td className="px-3 py-2 text-sm">
{row.valid ? (
<span className="text-green-600 font-medium">Valid</span>
) : (
<span className="text-red-600 font-medium">Invalid</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={handleUpload}
disabled={uploading || preview.some(r => !r.valid)}
className="mt-4 w-full bg-aptiva text-white py-2 px-4 rounded-lg hover:bg-aptiva-dark disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{uploading ? 'Uploading...' : 'Upload Roster'}
</button>
</div>
)}
</div>
</div>
)}
{/* History Tab */}
{activeTab === 'history' && (
<div className="bg-white shadow rounded-lg">
{loadingHistory ? (
<div className="p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-2 text-sm text-gray-500">Loading history...</p>
</div>
) : history.length === 0 ? (
<div className="p-12 text-center">
<History className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No roster uploads yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Upload Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Students Added
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Already Existed
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Roster Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Change %
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{history.map((upload) => (
<tr key={upload.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(upload.uploaded_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{upload.students_added}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{upload.students_existing}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{upload.total_students_after}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`font-medium ${
upload.change_percentage > 0 ? 'text-green-600' : 'text-gray-400'
}`}>
{upload.change_percentage > 0 ? '+' : ''}{upload.change_percentage}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,945 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Building2, Calendar, Users, CreditCard, Save, Trash2, UserPlus, AlertCircle, CheckCircle } from 'lucide-react';
import AdminLayout from './AdminLayout.js';
import { useAdmin } from '../../contexts/AdminContext.js';
export default function Settings() {
const { admin } = useAdmin();
const [activeTab, setActiveTab] = useState('profile');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
// Organization Profile State
const [orgProfile, setOrgProfile] = useState({
organization_name: '',
organization_type: '',
address: '',
city: '',
state: '',
zip_code: '',
primary_contact_name: '',
primary_contact_email: '',
primary_contact_phone: '',
onboarding_delay_days: 14
});
// Academic Calendar State
const [calendar, setCalendar] = useState({
calendar_type: 'semester', // semester, quarter, trimester
fall_term_start_month: 8,
fall_term_start_day: 15,
fall_add_drop_deadline_days: 14,
winter_term_start_month: 1,
winter_term_start_day: 7,
winter_add_drop_deadline_days: 14,
spring_term_start_month: 3,
spring_term_start_day: 25,
spring_add_drop_deadline_days: 14,
summer_term_start_month: 6,
summer_term_start_day: 1,
summer_add_drop_deadline_days: 7
});
// Admin Users State
const [adminUsers, setAdminUsers] = useState([]);
const [newAdmin, setNewAdmin] = useState({
email: '',
firstname: '',
lastname: '',
role: 'staff_admin'
});
const [showAddAdmin, setShowAddAdmin] = useState(false);
// Billing State
const [billing, setBilling] = useState(null);
useEffect(() => {
if (activeTab === 'profile') {
fetchOrgProfile();
} else if (activeTab === 'calendar') {
fetchCalendar();
} else if (activeTab === 'admins') {
fetchAdminUsers();
} else if (activeTab === 'billing') {
fetchBilling();
}
}, [activeTab]);
const fetchOrgProfile = async () => {
setLoading(true);
try {
const { data } = await axios.get('/api/admin/organization/profile', {
withCredentials: true
});
setOrgProfile(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load organization profile');
} finally {
setLoading(false);
}
};
const saveOrgProfile = async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
await axios.put('/api/admin/organization/profile', orgProfile, {
withCredentials: true
});
setSuccess('Organization profile updated successfully');
} catch (err) {
setError(err.response?.data?.error || 'Failed to update organization profile');
} finally {
setLoading(false);
}
};
const fetchCalendar = async () => {
setLoading(true);
try {
const { data } = await axios.get('/api/admin/organization/calendar', {
withCredentials: true
});
if (data) {
setCalendar(data);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to load academic calendar');
} finally {
setLoading(false);
}
};
const saveCalendar = async () => {
setLoading(true);
setError(null);
setSuccess(null);
try {
await axios.put('/api/admin/organization/calendar', calendar, {
withCredentials: true
});
setSuccess('Academic calendar updated successfully');
} catch (err) {
setError(err.response?.data?.error || 'Failed to update academic calendar');
} finally {
setLoading(false);
}
};
const fetchAdminUsers = async () => {
setLoading(true);
try {
const { data } = await axios.get('/api/admin/organization/admins', {
withCredentials: true
});
setAdminUsers(Array.isArray(data) ? data : []);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load admin users');
setAdminUsers([]);
} finally {
setLoading(false);
}
};
const addAdmin = async () => {
if (!newAdmin.email || !newAdmin.firstname || !newAdmin.lastname) {
setError('All fields are required');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
await axios.post('/api/admin/organization/admins', newAdmin, {
withCredentials: true
});
setSuccess('Admin user added successfully');
setNewAdmin({ email: '', firstname: '', lastname: '', role: 'staff_admin' });
setShowAddAdmin(false);
fetchAdminUsers();
} catch (err) {
setError(err.response?.data?.error || 'Failed to add admin user');
} finally {
setLoading(false);
}
};
const removeAdmin = async (userId) => {
if (!window.confirm('Are you sure you want to remove this admin?')) return;
setLoading(true);
setError(null);
setSuccess(null);
try {
await axios.delete(`/api/admin/organization/admins/${userId}`, {
withCredentials: true
});
setSuccess('Admin user removed successfully');
fetchAdminUsers();
} catch (err) {
setError(err.response?.data?.error || 'Failed to remove admin user');
} finally {
setLoading(false);
}
};
const fetchBilling = async () => {
setLoading(true);
try {
const { data } = await axios.get('/api/admin/organization/billing', {
withCredentials: true
});
setBilling(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load billing information');
} finally {
setLoading(false);
}
};
return (
<AdminLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="mt-1 text-sm text-gray-500">
Manage your organization settings and configuration
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('profile')}
className={`${
activeTab === 'profile'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<Building2 className="inline-block w-4 h-4 mr-2" />
Organization Profile
</button>
<button
onClick={() => setActiveTab('calendar')}
className={`${
activeTab === 'calendar'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<Calendar className="inline-block w-4 h-4 mr-2" />
Academic Calendar
</button>
<button
onClick={() => setActiveTab('admins')}
className={`${
activeTab === 'admins'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<Users className="inline-block w-4 h-4 mr-2" />
Admin Users
</button>
{admin?.role === 'super_admin' && (
<button
onClick={() => setActiveTab('billing')}
className={`${
activeTab === 'billing'
? 'border-aptiva text-aptiva'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium`}
>
<CreditCard className="inline-block w-4 h-4 mr-2" />
Billing
</button>
)}
</nav>
</div>
{/* Alert Messages */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
<AlertCircle className="h-5 w-5 text-red-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-start">
<CheckCircle className="h-5 w-5 text-green-600 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800">{success}</p>
</div>
)}
{/* Organization Profile Tab */}
{activeTab === 'profile' && (
<div className="bg-white shadow rounded-lg p-6">
{loading && !orgProfile.organization_name ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Organization Name
</label>
<input
type="text"
value={orgProfile.organization_name}
onChange={(e) => setOrgProfile({ ...orgProfile, organization_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Organization Type
</label>
<select
value={orgProfile.organization_type}
onChange={(e) => setOrgProfile({ ...orgProfile, organization_type: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="">Select type...</option>
<option value="K-12 School">K-12 School</option>
<option value="Community College">Community College</option>
<option value="University">University</option>
<option value="Vocational School">Vocational School</option>
<option value="Other">Other</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Address
</label>
<input
type="text"
value={orgProfile.address}
onChange={(e) => setOrgProfile({ ...orgProfile, address: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
City
</label>
<input
type="text"
value={orgProfile.city}
onChange={(e) => setOrgProfile({ ...orgProfile, city: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
State
</label>
<input
type="text"
value={orgProfile.state}
onChange={(e) => setOrgProfile({ ...orgProfile, state: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ZIP Code
</label>
<input
type="text"
value={orgProfile.zip_code}
onChange={(e) => setOrgProfile({ ...orgProfile, zip_code: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
</div>
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Primary Contact</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contact Name
</label>
<input
type="text"
value={orgProfile.primary_contact_name}
onChange={(e) => setOrgProfile({ ...orgProfile, primary_contact_name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contact Email
</label>
<input
type="email"
value={orgProfile.primary_contact_email}
onChange={(e) => setOrgProfile({ ...orgProfile, primary_contact_email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contact Phone
</label>
<input
type="tel"
value={orgProfile.primary_contact_phone}
onChange={(e) => setOrgProfile({ ...orgProfile, primary_contact_phone: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
</div>
</div>
{orgProfile.organization_type === 'K-12 School' && (
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Student Onboarding</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-800">
Configure how long after roster upload students in grades 11-12 should be prompted to complete Premium Onboarding. This delay gives students time to explore free features before creating career and college profiles.
</p>
</div>
<div className="max-w-md">
<label className="block text-sm font-medium text-gray-700 mb-2">
Onboarding Delay (Days)
</label>
<input
type="number"
min="1"
max="90"
value={orgProfile.onboarding_delay_days}
onChange={(e) => setOrgProfile({ ...orgProfile, onboarding_delay_days: parseInt(e.target.value) || 14 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
<p className="text-xs text-gray-500 mt-1">
Juniors and seniors (grades 11-12) will be prompted to complete Premium Onboarding {orgProfile.onboarding_delay_days} days after being added to your roster. Freshmen and sophomores are never prompted.
</p>
</div>
</div>
)}
<div className="flex justify-end">
<button
onClick={saveOrgProfile}
disabled={loading}
className="flex items-center px-4 py-2 bg-aptiva text-white rounded-lg hover:bg-aptiva-dark disabled:bg-gray-300 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
)}
</div>
)}
{/* Academic Calendar Tab */}
{activeTab === 'calendar' && (
<div className="bg-white shadow rounded-lg p-6">
{loading && !calendar.calendar_type ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
</div>
) : (
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
Configure your institution's academic calendar. This information helps AptivaAI align roster updates with your academic schedule.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Calendar Type
</label>
<select
value={calendar.calendar_type}
onChange={(e) => setCalendar({ ...calendar, calendar_type: e.target.value })}
className="w-full max-w-md px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="semester">Semester (Fall/Spring)</option>
<option value="quarter">Quarter (Fall/Winter/Spring/Summer)</option>
<option value="trimester">Trimester (Fall/Winter/Spring)</option>
</select>
</div>
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Academic Year</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Academic Year Start Date
</label>
<input
type="date"
value={calendar.academic_year_start}
onChange={(e) => setCalendar({ ...calendar, academic_year_start: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Academic Year End Date
</label>
<input
type="date"
value={calendar.academic_year_end}
onChange={(e) => setCalendar({ ...calendar, academic_year_end: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
</div>
</div>
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Academic Term Schedule</h3>
<p className="text-sm text-gray-600 mb-4">
Enter recurring term start dates (month/day) and add/drop deadlines. Roster uploads must occur AFTER the add/drop deadline each term.
</p>
{/* Fall Term */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="text-md font-medium text-gray-900 mb-3">Fall Term</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Term Start Date (recurring annually)
</label>
<div className="flex gap-2">
<select
value={calendar.fall_term_start_month}
onChange={(e) => setCalendar({ ...calendar, fall_term_start_month: parseInt(e.target.value) })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
</select>
<select
value={calendar.fall_term_start_day}
onChange={(e) => setCalendar({ ...calendar, fall_term_start_day: parseInt(e.target.value) })}
className="w-20 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
{[...Array(31)].map((_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add/Drop Deadline (days after term start)
</label>
<input
type="number"
min="1"
max="30"
value={calendar.fall_add_drop_deadline_days}
onChange={(e) => setCalendar({ ...calendar, fall_add_drop_deadline_days: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
<p className="text-xs text-gray-500 mt-1">Roster uploads must occur after this deadline</p>
</div>
</div>
</div>
{/* Winter Term (Quarter/Trimester only) */}
{(calendar.calendar_type === 'quarter' || calendar.calendar_type === 'trimester') && (
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="text-md font-medium text-gray-900 mb-3">Winter Term</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Term Start Date (recurring annually)
</label>
<div className="flex gap-2">
<select
value={calendar.winter_term_start_month}
onChange={(e) => setCalendar({ ...calendar, winter_term_start_month: parseInt(e.target.value) })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="1">January</option>
<option value="2">February</option>
</select>
<select
value={calendar.winter_term_start_day}
onChange={(e) => setCalendar({ ...calendar, winter_term_start_day: parseInt(e.target.value) })}
className="w-20 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
{[...Array(31)].map((_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add/Drop Deadline (days after term start)
</label>
<input
type="number"
min="1"
max="30"
value={calendar.winter_add_drop_deadline_days}
onChange={(e) => setCalendar({ ...calendar, winter_add_drop_deadline_days: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
<p className="text-xs text-gray-500 mt-1">Roster uploads must occur after this deadline</p>
</div>
</div>
</div>
)}
{/* Spring Term (Semester/Trimester/Quarter) */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<h4 className="text-md font-medium text-gray-900 mb-3">Spring Term</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Term Start Date (recurring annually)
</label>
<div className="flex gap-2">
<select
value={calendar.spring_term_start_month}
onChange={(e) => setCalendar({ ...calendar, spring_term_start_month: parseInt(e.target.value) })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
</select>
<select
value={calendar.spring_term_start_day}
onChange={(e) => setCalendar({ ...calendar, spring_term_start_day: parseInt(e.target.value) })}
className="w-20 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
{[...Array(31)].map((_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add/Drop Deadline (days after term start)
</label>
<input
type="number"
min="1"
max="30"
value={calendar.spring_add_drop_deadline_days}
onChange={(e) => setCalendar({ ...calendar, spring_add_drop_deadline_days: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
<p className="text-xs text-gray-500 mt-1">Roster uploads must occur after this deadline</p>
</div>
</div>
</div>
{/* Summer Term (Quarter only) */}
{calendar.calendar_type === 'quarter' && (
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="text-md font-medium text-gray-900 mb-3">Summer Term</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Term Start Date (recurring annually)
</label>
<div className="flex gap-2">
<select
value={calendar.summer_term_start_month}
onChange={(e) => setCalendar({ ...calendar, summer_term_start_month: parseInt(e.target.value) })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
</select>
<select
value={calendar.summer_term_start_day}
onChange={(e) => setCalendar({ ...calendar, summer_term_start_day: parseInt(e.target.value) })}
className="w-20 px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
>
{[...Array(31)].map((_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add/Drop Deadline (days after term start)
</label>
<input
type="number"
min="1"
max="30"
value={calendar.summer_add_drop_deadline_days}
onChange={(e) => setCalendar({ ...calendar, summer_add_drop_deadline_days: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
<p className="text-xs text-gray-500 mt-1">Roster uploads must occur after this deadline</p>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={saveCalendar}
disabled={loading}
className="flex items-center px-4 py-2 bg-aptiva text-white rounded-lg hover:bg-aptiva-dark disabled:bg-gray-300 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
)}
</div>
)}
{/* Admin Users Tab */}
{activeTab === 'admins' && (
<div className="bg-white shadow rounded-lg p-6">
{loading && adminUsers.length === 0 ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
</div>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Admin Users</h3>
{admin?.isSuperAdmin && (
<button
onClick={() => setShowAddAdmin(!showAddAdmin)}
className="flex items-center px-4 py-2 bg-aptiva text-white rounded-lg hover:bg-aptiva-dark"
>
<UserPlus className="w-4 h-4 mr-2" />
Add Admin
</button>
)}
</div>
{showAddAdmin && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-900 mb-4">Add New Admin</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={newAdmin.email}
onChange={(e) => setNewAdmin({ ...newAdmin, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
<select
value={newAdmin.role}
onChange={(e) => setNewAdmin({ ...newAdmin, role: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
disabled
>
<option value="staff_admin">Staff Admin</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Super Admin access is managed by AptivaAI. Contact support for additional Super Admins.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
<input
type="text"
value={newAdmin.firstname}
onChange={(e) => setNewAdmin({ ...newAdmin, firstname: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
<input
type="text"
value={newAdmin.lastname}
onChange={(e) => setNewAdmin({ ...newAdmin, lastname: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva"
/>
</div>
</div>
<div className="mt-4 flex justify-end space-x-2">
<button
onClick={() => {
setShowAddAdmin(false);
setNewAdmin({ email: '', firstname: '', lastname: '', role: 'staff_admin' });
}}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={addAdmin}
disabled={loading}
className="px-4 py-2 bg-aptiva text-white rounded-lg hover:bg-aptiva-dark disabled:bg-gray-300"
>
{loading ? 'Adding...' : 'Add Admin'}
</button>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Added</th>
{admin?.isSuperAdmin && (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{adminUsers.map((user) => (
<tr key={user.user_id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.firstname} {user.lastname}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
user.role === 'super_admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{user.role === 'super_admin' ? 'Super Admin' : 'Staff Admin'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
{admin?.isSuperAdmin && (
<td className="px-6 py-4 whitespace-nowrap text-sm">
{user.user_id !== admin.userId && (
<button
onClick={() => removeAdmin(user.user_id)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="bg-white shadow rounded-lg p-6">
{loading && !billing ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-aptiva mx-auto"></div>
</div>
) : billing ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">Subscription Plan</p>
<p className="mt-1 text-xl font-semibold text-gray-900">{billing.subscription_plan}</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">Status</p>
<p className="mt-1 text-xl font-semibold">
<span className={`${
billing.subscription_status === 'active' ? 'text-green-600' : 'text-red-600'
}`}>
{billing.subscription_status}
</span>
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500">Discount Eligible</p>
<p className="mt-1 text-xl font-semibold">
<span className={`${
billing.discount_eligible ? 'text-green-600' : 'text-gray-600'
}`}>
{billing.discount_eligible ? 'Yes' : 'No'}
</span>
</p>
</div>
</div>
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Billing Information</h3>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Billing Contact</dt>
<dd className="mt-1 text-sm text-gray-900">{billing.billing_contact_name || 'Not set'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Billing Email</dt>
<dd className="mt-1 text-sm text-gray-900">{billing.billing_contact_email || 'Not set'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Invoice Date</dt>
<dd className="mt-1 text-sm text-gray-900">
{billing.last_invoice_date ? new Date(billing.last_invoice_date).toLocaleDateString() : 'N/A'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Invoice Amount</dt>
<dd className="mt-1 text-sm text-gray-900">
{billing.last_invoice_amount ? `$${parseFloat(billing.last_invoice_amount).toFixed(2)}` : 'N/A'}
</dd>
</div>
</dl>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
For billing changes or questions, please contact your AptivaAI account representative.
</p>
</div>
</div>
) : (
<div className="text-center py-12">
<CreditCard className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No billing information available</p>
</div>
)}
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,576 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Button } from '../ui/button.js';
import { ArrowLeft, CheckCircle, XCircle, Lock, Activity, ChevronDown, ChevronUp } from 'lucide-react';
import RiaSecChart from '../RiaSecChart.js';
export default function StudentDetail() {
const { studentId } = useParams();
const navigate = useNavigate();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showDeactivateModal, setShowDeactivateModal] = useState(false);
const [deactivateReason, setDeactivateReason] = useState('graduated');
const [expandedCareerProfile, setExpandedCareerProfile] = useState(null);
const [expandedCollegeProfile, setExpandedCollegeProfile] = useState(null);
useEffect(() => {
const fetchStudent = async () => {
try {
const { data } = await axios.get(`/api/admin/students/${studentId}`, {
withCredentials: true
});
setData(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load student details');
} finally {
setLoading(false);
}
};
fetchStudent();
}, [studentId]);
const handleResetPassword = async () => {
if (!window.confirm('Send password reset email to this student?')) return;
try {
await axios.post(`/api/admin/students/${studentId}/reset-password`, {}, {
withCredentials: true
});
alert('Password reset email sent successfully');
} catch (err) {
alert(err.response?.data?.error || 'Failed to send password reset');
}
};
const handleDeactivateClick = () => {
setShowDeactivateModal(true);
};
const handleDeactivateConfirm = async () => {
try {
await axios.post(`/api/admin/students/${studentId}/deactivate`, {
status: deactivateReason
}, {
withCredentials: true
});
alert(`Student marked as ${deactivateReason}`);
navigate('/admin/students');
} catch (err) {
alert(err.response?.data?.error || 'Failed to update student status');
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
if (loading) {
return (
<AdminLayout>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
</div>
</AdminLayout>
);
}
if (error || !data) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error || 'Student not found'}</div>
</div>
</AdminLayout>
);
}
const { student, privacy, careers, riasecScores, careerProfiles, collegeProfiles, roadmapMilestones } = data;
return (
<AdminLayout>
<div className="space-y-6">
{/* Header */}
<div>
<Link to="/admin/students" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4">
<ArrowLeft size={16} className="mr-1" />
Back to Students
</Link>
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{student.firstname} {student.lastname}
</h1>
<p className="text-sm text-gray-500 mt-1">{student.email}</p>
<div className="flex items-center gap-4 mt-2">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
student.enrollment_status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{student.enrollment_status}
</span>
<span className="text-sm text-gray-500">
Enrolled: {formatDate(student.invitation_accepted_at || student.enrollment_date)}
</span>
<span className="text-sm text-gray-500">
Last Active: {formatDate(student.last_login)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleResetPassword}>
Reset Password
</Button>
<Button variant="outline" onClick={handleDeactivateClick} className="text-red-600 hover:text-red-700">
Deactivate Account
</Button>
</div>
</div>
</div>
{/* Deactivate Modal */}
{showDeactivateModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50" onClick={() => setShowDeactivateModal(false)}>
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white" onClick={(e) => e.stopPropagation()}>
<div className="mt-3">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Deactivate Student Account
</h3>
<p className="text-sm text-gray-500 mb-4">
This will revoke the student's access immediately. Please select the reason:
</p>
<div className="space-y-2 mb-6">
<label className="flex items-center">
<input
type="radio"
name="deactivateReason"
value="graduated"
checked={deactivateReason === 'graduated'}
onChange={(e) => setDeactivateReason(e.target.value)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Graduated</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="deactivateReason"
value="withdrawn"
checked={deactivateReason === 'withdrawn'}
onChange={(e) => setDeactivateReason(e.target.value)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Withdrawn</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="deactivateReason"
value="transferred"
checked={deactivateReason === 'transferred'}
onChange={(e) => setDeactivateReason(e.target.value)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Transferred</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="deactivateReason"
value="inactive"
checked={deactivateReason === 'inactive'}
onChange={(e) => setDeactivateReason(e.target.value)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Inactive / Other</span>
</label>
</div>
<div className="flex gap-3 justify-end">
<Button variant="outline" onClick={() => setShowDeactivateModal(false)}>
Cancel
</Button>
<Button onClick={handleDeactivateConfirm} className="bg-red-600 hover:bg-red-700 text-white">
Confirm Deactivation
</Button>
</div>
</div>
</div>
</div>
)}
{/* Activity Overview */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<Activity className="h-5 w-5 mr-2" />
Activity Overview
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="flex items-center">
{student.inventory_completed_at ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">Interest Inventory</span>
</div>
<div className="flex items-center">
{student.career_comparison_count > 0 ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">
{student.career_comparison_count || 0} Career Comparison
</span>
</div>
<div className="flex items-center">
{student.career_profiles_count > 0 ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">
{student.career_profiles_count || 0} Career Profile{student.career_profiles_count !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center">
{student.college_profiles_count > 0 ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">
{student.college_profiles_count || 0} College Profile{student.college_profiles_count !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center">
{student.financial_profiles_count > 0 ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">Financial Profile</span>
</div>
<div className="flex items-center">
{student.roadmaps_count > 0 ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-gray-300 mr-2" />
)}
<span className="text-sm text-gray-700">Career Roadmap</span>
</div>
</div>
</div>
{/* Careers Explored */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Careers Explored</h2>
{!privacy.share_career_exploration ? (
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<Lock className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Student has not shared career exploration data with your organization.
</span>
</div>
) : careers && careers.length > 0 ? (
<div className="space-y-2">
{careers.map((career, index) => (
<div key={index} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50">
<div>
<p className="text-sm font-medium text-gray-900">{career.career_name}</p>
</div>
<span className="text-xs text-gray-500">
{career.view_count} {career.view_count === 1 ? 'view' : 'views'}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No careers explored yet</p>
)}
</div>
{/* Interest Inventory Results (RIASEC Chart) */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Interest Inventory Results</h2>
{!privacy.share_interest_inventory ? (
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<Lock className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Student has not shared interest inventory results with your organization.
</span>
</div>
) : riasecScores && riasecScores.length > 0 ? (
<div>
<RiaSecChart riaSecScores={riasecScores} />
</div>
) : (
<p className="text-sm text-gray-500">Interest inventory not completed yet</p>
)}
</div>
{/* Career Profiles */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Career Profiles</h2>
{!privacy.share_career_profiles ? (
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<Lock className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Student has not shared career profiles with your organization.
</span>
</div>
) : careerProfiles && careerProfiles.length > 0 ? (
<div className="space-y-2">
{careerProfiles.map((profile) => (
<div key={profile.id} className="border border-gray-200 rounded-lg">
<button
onClick={() => setExpandedCareerProfile(expandedCareerProfile === profile.id ? null : profile.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 transition-colors"
>
<div className="flex-1 text-left">
<p className="text-sm font-medium text-gray-900">{profile.career_name}</p>
<p className="text-xs text-gray-500 mt-1">
Created: {formatDate(profile.created_at)}
</p>
</div>
{expandedCareerProfile === profile.id ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
{expandedCareerProfile === profile.id && (
<div className="px-3 pb-3 pt-2 border-t border-gray-200 space-y-2">
{profile.scenario_title && (
<p className="text-sm text-gray-600">
<span className="font-medium">Scenario:</span> {profile.scenario_title}
</p>
)}
<p className="text-sm text-gray-600">
<span className="font-medium">Status:</span> {profile.status || 'N/A'}
</p>
{profile.currently_working && (
<p className="text-sm text-gray-600">
<span className="font-medium">Currently Working:</span> {profile.currently_working}
</p>
)}
{profile.start_date && (
<p className="text-sm text-gray-600">
<span className="font-medium">Start Date:</span> {formatDate(profile.start_date)}
</p>
)}
{profile.retirement_start_date && (
<p className="text-sm text-gray-600">
<span className="font-medium">Retirement Date:</span> {formatDate(profile.retirement_start_date)}
</p>
)}
{profile.college_enrollment_status && (
<p className="text-sm text-gray-600">
<span className="font-medium">College Status:</span> {profile.college_enrollment_status}
</p>
)}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No career profiles created yet</p>
)}
</div>
{/* College Profiles */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">College Profiles</h2>
{!privacy.share_college_profiles ? (
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<Lock className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Student has not shared college profiles with your organization.
</span>
</div>
) : collegeProfiles && collegeProfiles.length > 0 ? (
<div className="space-y-2">
{collegeProfiles.map((profile) => (
<div key={profile.id} className="border border-gray-200 rounded-lg">
<button
onClick={() => setExpandedCollegeProfile(expandedCollegeProfile === profile.id ? null : profile.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 transition-colors"
>
<div className="flex-1 text-left">
<p className="text-sm font-medium text-gray-900">{profile.selected_school}</p>
<p className="text-xs text-gray-500 mt-1">
Created: {formatDate(profile.created_at)}
</p>
</div>
{expandedCollegeProfile === profile.id ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
{expandedCollegeProfile === profile.id && (
<div className="px-3 pb-3 pt-2 border-t border-gray-200 space-y-2">
{profile.selected_program && (
<p className="text-sm text-gray-600">
<span className="font-medium">Program:</span> {profile.selected_program}
</p>
)}
{profile.program_type && (
<p className="text-sm text-gray-600">
<span className="font-medium">Program Type:</span> {profile.program_type}
</p>
)}
{profile.college_enrollment_status && (
<p className="text-sm text-gray-600">
<span className="font-medium">Enrollment Status:</span> {profile.college_enrollment_status}
</p>
)}
{profile.tuition && (
<p className="text-sm text-gray-600">
<span className="font-medium">Tuition:</span> ${parseFloat(profile.tuition).toLocaleString()}
</p>
)}
{profile.expected_graduation && (
<p className="text-sm text-gray-600">
<span className="font-medium">Expected Graduation:</span> {formatDate(profile.expected_graduation)}
</p>
)}
{profile.expected_salary && (
<p className="text-sm text-gray-600">
<span className="font-medium">Expected Salary:</span> ${parseFloat(profile.expected_salary).toLocaleString()}
</p>
)}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No college profiles created yet</p>
)}
</div>
{/* Financial Profile */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Financial Profile</h2>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center mb-2">
<Lock className="h-5 w-5 text-blue-600 mr-2" />
<span className="text-sm font-semibold text-gray-900">
Financial information is never shared with organizations to protect student privacy.
</span>
</div>
<p className="text-xs text-gray-600 ml-7">
Note: Expected salary projections from College Profiles are visible above as they are considered career planning data, not personal financial information.
</p>
</div>
</div>
{/* Career Roadmap */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Career Roadmap</h2>
{!privacy.share_roadmap ? (
<div className="flex items-center p-4 bg-gray-50 rounded-lg">
<Lock className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-600">
Student has not shared career roadmap with your organization.
</span>
</div>
) : roadmapMilestones && roadmapMilestones.length > 0 ? (
<div className="space-y-3">
{roadmapMilestones.map((milestone) => (
<div key={milestone.id} className="flex items-start p-3 border border-gray-200 rounded-lg">
<div className="flex-shrink-0 mr-3 mt-1">
{milestone.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<div className="h-5 w-5 border-2 border-gray-300 rounded-full" />
)}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{milestone.title}</p>
{milestone.description && (
<p className="text-sm text-gray-600 mt-1">{milestone.description}</p>
)}
<p className="text-xs text-gray-500 mt-1">
Career: {milestone.career_name} | Target: {formatDate(milestone.date)}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No career roadmap milestones created yet</p>
)}
</div>
{/* Privacy Settings */}
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Privacy Settings</h2>
<p className="text-sm text-gray-600 mb-4">What this student has chosen to share with your organization:</p>
<div className="space-y-2">
<div className="flex items-center">
{privacy.share_career_exploration ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">Career Exploration Data</span>
</div>
<div className="flex items-center">
{privacy.share_interest_inventory ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">Interest Inventory Results</span>
</div>
<div className="flex items-center">
{privacy.share_career_profiles ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">Career Profiles</span>
</div>
<div className="flex items-center">
{privacy.share_college_profiles ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">College Profiles</span>
</div>
<div className="flex items-center">
{privacy.share_financial_profile ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">Financial Profile</span>
</div>
<div className="flex items-center">
{privacy.share_roadmap ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2" />
)}
<span className="text-sm text-gray-700">Career Roadmap</span>
</div>
</div>
</div>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,402 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import AdminLayout from './AdminLayout.js';
import { Input } from '../ui/input.js';
import { Button } from '../ui/button.js';
import { Search, UserPlus, ChevronLeft, ChevronRight } from 'lucide-react';
export default function StudentList() {
const [allStudents, setAllStudents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [activity, setActivity] = useState('');
const [engagement, setEngagement] = useState('');
const [page, setPage] = useState(0);
const limit = 50;
const fetchStudents = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (status) params.append('status', status);
const { data } = await axios.get(`/api/admin/students?${params}`, {
withCredentials: true
});
setAllStudents(data.students || []);
setError(null);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load students');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStudents();
}, [status]);
// Client-side filtering based on search, activity, and engagement
const filteredStudents = allStudents.filter((student) => {
// Search filter
if (search) {
const searchLower = search.toLowerCase();
const fullName = `${student.firstname} ${student.lastname}`.toLowerCase();
const email = (student.email || '').toLowerCase();
if (!fullName.includes(searchLower) && !email.includes(searchLower)) {
return false;
}
}
// Activity filter (based on last_login)
if (activity) {
const now = new Date();
const lastLogin = student.last_login ? new Date(student.last_login) : null;
if (activity === 'today') {
if (!lastLogin || (now - lastLogin) > 24 * 60 * 60 * 1000) return false;
} else if (activity === 'week') {
if (!lastLogin || (now - lastLogin) > 7 * 24 * 60 * 60 * 1000) return false;
} else if (activity === 'month') {
if (!lastLogin || (now - lastLogin) > 30 * 24 * 60 * 60 * 1000) return false;
} else if (activity === 'inactive_30_90') {
const daysSince = lastLogin ? (now - lastLogin) / (24 * 60 * 60 * 1000) : 9999;
if (daysSince < 30 || daysSince > 90) return false;
} else if (activity === 'inactive_90plus') {
const daysSince = lastLogin ? (now - lastLogin) / (24 * 60 * 60 * 1000) : 9999;
if (daysSince < 90) return false;
} else if (activity === 'never') {
if (lastLogin) return false;
}
}
// Engagement filter
if (engagement) {
if (engagement === 'inventory' && !student.inventory_completed_at) return false;
if (engagement === 'career_profiles' && student.career_profiles_count === 0) return false;
if (engagement === 'college_profiles' && student.college_profiles_count === 0) return false;
if (engagement === 'financial_profile' && student.financial_profiles_count === 0) return false;
if (engagement === 'roadmap' && student.roadmaps_count === 0) return false;
if (engagement === 'no_activity') {
// No activity means they haven't used any features (but may have logged in)
if (student.inventory_completed_at || student.career_profiles_count > 0 ||
student.college_profiles_count > 0 || student.financial_profiles_count > 0 ||
student.roadmaps_count > 0) {
return false;
}
}
}
return true;
});
// Paginate filtered results
const paginatedStudents = filteredStudents.slice(page * limit, (page + 1) * limit);
const handleSearch = (e) => {
e.preventDefault();
// Search happens automatically via filteredStudents
};
// Reset to page 0 when search term changes
useEffect(() => {
setPage(0);
}, [search]);
const formatDate = (dateString) => {
if (!dateString) return 'Never';
// Extract just the date portion (YYYY-MM-DD) if it's a full timestamp
// This prevents timezone conversion issues
const datePart = dateString.split('T')[0];
// Parse as local date to avoid timezone shift
const date = new Date(datePart + 'T00:00:00');
// Check for invalid date
if (isNaN(date.getTime())) return 'Invalid Date';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const getStatusBadge = (status) => {
const styles = {
active: 'bg-green-100 text-green-800',
graduated: 'bg-blue-100 text-blue-800',
withdrawn: 'bg-red-100 text-red-800',
transferred: 'bg-yellow-100 text-yellow-800'
};
return styles[status] || 'bg-gray-100 text-gray-800';
};
return (
<AdminLayout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Students</h1>
<p className="mt-1 text-sm text-gray-500">
Manage your organization's student roster
</p>
</div>
<Link to="/admin/students/add">
<Button>
<UserPlus size={18} className="mr-2" />
Add Student
</Button>
</Link>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-4 flex-wrap">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<Input
type="text"
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex gap-4 flex-wrap items-center">
<div className="w-48">
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-aptiva focus:border-aptiva"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="pending_invitation">Pending Invitation</option>
<option value="invitation_bounced">Bounced Invitation</option>
<option value="graduated">Graduated</option>
<option value="withdrawn">Withdrawn</option>
<option value="transferred">Transferred</option>
</select>
</div>
<div className="w-48">
<label className="block text-xs font-medium text-gray-700 mb-1">Activity</label>
<select
value={activity}
onChange={(e) => setActivity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-aptiva focus:border-aptiva"
>
<option value="">All</option>
<option value="today">Active Today</option>
<option value="week">Active This Week</option>
<option value="month">Active This Month</option>
<option value="inactive_30_90">Inactive 30-90 Days</option>
<option value="inactive_90plus">Inactive 90+ Days</option>
<option value="never">Never Logged In</option>
</select>
</div>
<div className="w-48">
<label className="block text-xs font-medium text-gray-700 mb-1">Engagement</label>
<select
value={engagement}
onChange={(e) => setEngagement(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-aptiva focus:border-aptiva"
>
<option value="">All</option>
<option value="inventory">Completed Interest Inventory</option>
<option value="career_profiles">Created Career Profiles</option>
<option value="college_profiles">Created College Profiles</option>
<option value="financial_profile">Created Financial Profile</option>
<option value="roadmap">Has Career Roadmap</option>
<option value="no_activity">No Activity</option>
</select>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setStatus('');
setActivity('');
setEngagement('');
setPage(0);
}}
>
Clear All
</Button>
</div>
</div>
</form>
</div>
{/* Students Table */}
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Loading students...</p>
</div>
) : filteredStudents.length === 0 ? (
<div className="text-center py-12 bg-white shadow rounded-lg">
<p className="text-gray-500">No students found</p>
</div>
) : (
<div className="bg-white shadow overflow-hidden rounded-lg">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{filteredStudents.length}</span> student{filteredStudents.length !== 1 ? 's' : ''}
</p>
<Button
variant="outline"
onClick={() => {
// Export to CSV
const headers = ['Name', 'Email', 'Status', 'Enrollment Date', 'Last Active'];
const csvContent = [
headers.join(','),
...filteredStudents.map(s => [
`"${s.firstname} ${s.lastname}"`,
`"${s.email}"`,
s.enrollment_status,
s.enrollment_date || '',
s.last_login || 'Never'
].join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `students-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
}}
>
Export Current View
</Button>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Enrolled
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedStudents.map((student) => (
<tr key={student.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{student.firstname} {student.lastname}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{student.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadge(student.enrollment_status)}`}>
{student.enrollment_status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(student.last_login)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(student.enrollment_date)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
to={`/admin/students/${student.id}`}
className="text-aptiva hover:text-aptiva-dark"
>
View Details
</Link>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<Button
variant="outline"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</Button>
<Button
variant="outline"
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * limit >= filteredStudents.length}
>
Next
</Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
<span className="font-medium">{Math.min((page + 1) * limit, filteredStudents.length)}</span> of{' '}
<span className="font-medium">{filteredStudents.length}</span> students
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<Button
variant="outline"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-r-none"
>
<ChevronLeft size={18} />
</Button>
<Button
variant="outline"
onClick={() => setPage(p => p + 1)}
disabled={(page + 1) * limit >= filteredStudents.length}
className="rounded-l-none"
>
<ChevronRight size={18} />
</Button>
</nav>
</div>
</div>
</div>
</div>
)}
</div>
</AdminLayout>
);
}

View File

@ -339,6 +339,14 @@ function CareerExplorer() {
setSelectedCareer(career); setSelectedCareer(career);
setCareerDetails(null); setCareerDetails(null);
// Track career view (fire and forget)
api.post('/api/track-career-view', {
career_soc_code: socCode,
career_name: career.title
}, {
withCredentials: true
}).catch(err => console.error('[CareerExplorer] Failed to track view:', err));
try { try {
let cipCode = null; let cipCode = null;
try { const { data } = await api.get(`/api/cip/${socCode}`); cipCode = data?.cipCode ?? null; } catch {} try { const { data } = await api.get(`/api/cip/${socCode}`); cipCode = data?.cipCode ?? null; } catch {}

View File

@ -54,7 +54,7 @@ export default function CareerProfileForm() {
...prev, ...prev,
scenario_title : d.scenario_title ?? '', scenario_title : d.scenario_title ?? '',
career_name : d.career_name ?? '', career_name : d.career_name ?? '',
soc_code : d.soc_code ?? '', soc_code : d.career_soc_code ?? d.soc_code ?? '',
status : d.status ?? 'current', status : d.status ?? 'current',
start_date : (d.start_date || '').slice(0, 10), // ← trim start_date : (d.start_date || '').slice(0, 10), // ← trim
retirement_start_date : (d.retirement_start_date || '').slice(0, 10), retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
@ -78,6 +78,7 @@ export default function CareerProfileForm() {
headers : { 'Content-Type': 'application/json' }, headers : { 'Content-Type': 'application/json' },
body : JSON.stringify({ body : JSON.stringify({
...form, ...form,
career_soc_code : form.soc_code, // map to backend field name
start_date : form.start_date?.slice(0, 10) || null, start_date : form.start_date?.slice(0, 10) || null,
retirement_start_date : form.retirement_start_date?.slice(0, 10) || null, retirement_start_date : form.retirement_start_date?.slice(0, 10) || null,
id: id === 'new' ? undefined : id // upsert id: id === 'new' ? undefined : id // upsert

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo, useCallback, useContext } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback, useContext } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2'; import { Line, Bar } from 'react-chartjs-2';
import { format } from 'date-fns'; // ⬅ install if not already import { format } from 'date-fns'; // ⬅ install if not already
import zoomPlugin from 'chartjs-plugin-zoom'; import zoomPlugin from 'chartjs-plugin-zoom';
@ -335,6 +335,7 @@ function getYearsInCareer(startDateString) {
export default function CareerRoadmap({ selectedCareer: initialCareer }) { export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const { careerId } = useParams(); const { careerId } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'RANDOM' const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'RANDOM'
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
@ -864,6 +865,9 @@ useEffect(() => {
setCareerProfileId(latest.id); setCareerProfileId(latest.id);
setSelectedCareer(latest); setSelectedCareer(latest);
localStorage.setItem('lastSelectedCareerProfileId', latest.id); localStorage.setItem('lastSelectedCareerProfileId', latest.id);
} else if (!careerProfiles.length && !cancelled) {
// No profiles exist - redirect to billing success screen to set up premium features
navigate('/billing?ck=success', { replace: true });
} }
})(); })();

View File

@ -171,7 +171,7 @@ const InterestInventory = () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
riasec_scores: scoresMap // store in DB as a JSON string riasec: scoresMap // server1 expects 'riasec', not 'riasec_scores'
}), }),
}); });

View File

@ -0,0 +1,235 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import axios from 'axios';
export default function InviteResponse() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [tokenData, setTokenData] = useState(null);
const [error, setError] = useState(null);
const [processing, setProcessing] = useState(false);
const shouldAutoLink = useRef(false);
const handleLinkAccount = async () => {
setProcessing(true);
setError(null);
try {
// Try to link the account directly - if not authenticated, backend will return 401
await axios.post('/api/link-account', {
token: tokenData.token
}, {
withCredentials: true
});
// Force a page reload to refresh user profile and trigger privacy settings check
window.location.href = '/signin-landing';
} catch (err) {
// If not authenticated, redirect to signin
if (err.response?.status === 401 || err.response?.status === 403) {
const returnUrl = encodeURIComponent(`/invite-response?token=${tokenData.token}&autolink=true`);
navigate(`/signin?redirect=${returnUrl}`);
return;
}
setError(err.response?.data?.error || 'Failed to link account. Please try again.');
setProcessing(false);
}
};
useEffect(() => {
validateToken();
}, []);
// Auto-link when tokenData is set and autolink flag is true
useEffect(() => {
if (tokenData && shouldAutoLink.current && !processing) {
shouldAutoLink.current = false; // Prevent double-trigger
handleLinkAccount();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokenData]);
const validateToken = async () => {
const token = searchParams.get('token');
const autoLink = searchParams.get('autolink'); // Check if we should auto-link after signin
if (!token) {
setError('No invitation token provided');
setLoading(false);
return;
}
try {
// Decode token to check if it's for existing user
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
if (payload.prp !== 'student_invite') {
setError('Invalid invitation token');
setLoading(false);
return;
}
if (payload.isNewUser !== false) {
// This is for a new user, redirect to signup
navigate(`/signup?invite=${token}`, { replace: true });
return;
}
// Token is valid for existing user
setTokenData({
token,
organizationId: payload.organizationId,
email: payload.email
});
setLoading(false);
// If autolink param is present, set flag to trigger auto-link
if (autoLink === 'true') {
shouldAutoLink.current = true;
}
} catch (err) {
setError('Invalid invitation token');
setLoading(false);
}
};
const handleCreateSeparateAccount = () => {
// User needs to contact admin for a new invitation with different email
navigate('/', { replace: true });
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Validating invitation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Invalid Invitation</h2>
<p className="mt-2 text-gray-600">{error}</p>
<button
onClick={() => navigate('/')}
className="mt-6 w-full bg-aptiva text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Home
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-8">
<div className="max-w-2xl w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">You're Invited!</h1>
<p className="mt-2 text-gray-600">Choose how you'd like to proceed</p>
</div>
<div className="bg-blue-50 border-l-4 border-aptiva p-4 mb-6">
<p className="text-sm text-blue-800">
<strong>We noticed you already have an AptivaAI account.</strong> You can either link your existing account or create a separate one for your organization.
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4">
{/* Option 1: Link Existing Account */}
<div className="border-2 border-gray-200 rounded-lg p-6 hover:border-aptiva transition-colors">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Option 1: Link Your Existing Account</h3>
<ul className="space-y-2 mb-4 text-sm text-gray-600">
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Your existing data and progress will be preserved
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Your organization can view your activity based on your privacy settings
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
You'll gain premium access through your organization
</li>
</ul>
<button
onClick={handleLinkAccount}
disabled={processing}
className="w-full bg-aptiva text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing ? 'Processing...' : 'Link My Existing Account'}
</button>
</div>
{/* Option 2: Create Separate Account */}
<div className="border-2 border-gray-200 rounded-lg p-6 hover:border-gray-400 transition-colors">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Option 2: Create a Separate Account</h3>
<ul className="space-y-2 mb-4 text-sm text-gray-600">
<li className="flex items-start">
<svg className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Keep your personal AptivaAI account completely separate
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Start fresh with a new profile for school/organization use
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-orange-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span><strong>Requires a different email address</strong></span>
</li>
</ul>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4 text-xs text-yellow-800">
<strong>Note:</strong> To create a separate account, you'll need to contact your administrator and provide a different email address. They can then send you a new invitation.
</div>
<button
onClick={handleCreateSeparateAccount}
disabled={processing}
className="w-full bg-white border-2 border-gray-300 text-gray-700 py-3 px-4 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Contact Administrator
</button>
</div>
</div>
<p className="mt-6 text-center text-sm text-gray-500">
Questions? Contact your administrator for help.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import SignUp from './SignUp.js';
function InviteSignup() {
const { token } = useParams();
const navigate = useNavigate();
const [validating, setValidating] = useState(true);
const [inviteData, setInviteData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
console.log('[InviteSignup] Component mounted, token:', token);
if (!token) {
setError('Invalid invitation link');
setValidating(false);
return;
}
// Validate the invitation token
fetch('/api/validate-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
})
.then(res => res.json())
.then(data => {
console.log('[InviteSignup] Validation response:', data);
if (data.valid) {
setInviteData({
email: data.email,
userId: data.userId,
organizationId: data.organizationId,
token: token
});
setError(null);
} else {
setError(data.error || 'Invalid or expired invitation link');
}
})
.catch(err => {
console.error('[InviteSignup] Error validating invitation:', err);
setError('Failed to validate invitation. Please try again or contact your administrator.');
})
.finally(() => {
setValidating(false);
});
}, [token]);
// Show loading state
if (validating) {
return (
<div className="flex min-h-[100dvh] items-center justify-center bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-gray-600">Validating invitation...</p>
</div>
</div>
);
}
// Show error state
if (error) {
return (
<div className="flex min-h-[100dvh] items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">Invitation Error</h2>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => navigate('/signin')}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
Go to Sign In
</button>
</div>
</div>
</div>
);
}
// Valid invitation - render SignUp with pre-filled data
console.log('[InviteSignup] Rendering SignUp with inviteData:', inviteData);
return <SignUp inviteData={inviteData} />;
}
export default InviteSignup;

View File

@ -0,0 +1,227 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import axios from 'axios';
export default function LinkSecondaryEmail() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [tokenData, setTokenData] = useState(null);
const [error, setError] = useState(null);
const [processing, setProcessing] = useState(false);
const shouldAutoLink = useRef(false);
const handleLinkAccount = async () => {
setProcessing(true);
setError(null);
try {
// Try to link the secondary email directly - if not authenticated, backend will return 401
await axios.post('/api/link-secondary-email', {
token: tokenData.token
}, {
withCredentials: true
});
// Force a page reload to refresh user profile and trigger privacy settings check
window.location.href = '/signin-landing';
} catch (err) {
// If not authenticated, redirect to signin
if (err.response?.status === 401 || err.response?.status === 403) {
const returnUrl = encodeURIComponent(`/link-secondary-email?token=${tokenData.token}&autolink=true`);
navigate(`/signin?redirect=${returnUrl}`);
return;
}
setError(err.response?.data?.error || 'Failed to link account. Please try again.');
setProcessing(false);
}
};
useEffect(() => {
validateToken();
}, []);
// Auto-link when tokenData is set and autolink flag is true
useEffect(() => {
if (tokenData && shouldAutoLink.current && !processing) {
shouldAutoLink.current = false; // Prevent double-trigger
handleLinkAccount();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tokenData]);
const validateToken = async () => {
const token = searchParams.get('token');
const autoLink = searchParams.get('autolink'); // Check if we should auto-link after signin
if (!token) {
setError('No invitation token provided');
setLoading(false);
return;
}
try {
// Decode token to get invitation details
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
if (payload.prp !== 'student_invite') {
setError('Invalid invitation token');
setLoading(false);
return;
}
if (payload.isNewUser !== true) {
// This invitation is for existing user (same email), redirect to invite-response
navigate(`/invite-response?token=${token}`, { replace: true });
return;
}
// Token is valid for new user invitation
setTokenData({
token,
organizationId: payload.organizationId,
email: payload.email,
userId: payload.userId // This is the shell user_id
});
setLoading(false);
// If autolink param is present, set flag to trigger auto-link
if (autoLink === 'true') {
shouldAutoLink.current = true;
}
} catch (err) {
setError('Invalid invitation token');
setLoading(false);
}
};
const handleCreateNewAccount = () => {
// Redirect to signup with the invitation token
navigate(`/signup?invite=${tokenData.token}`, { replace: true });
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
<p className="mt-4 text-gray-600">Validating invitation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold text-gray-900">Invalid Invitation</h2>
<p className="mt-2 text-gray-600">{error}</p>
<button
onClick={() => navigate('/')}
className="mt-6 w-full bg-aptiva text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Home
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-8">
<div className="max-w-2xl w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Link Your Account</h1>
<p className="mt-2 text-gray-600">You were invited as: <strong>{tokenData.email}</strong></p>
</div>
<div className="bg-blue-50 border-l-4 border-aptiva p-4 mb-6">
<p className="text-sm text-blue-800">
If you already have an AptivaAI account with a different email address, you can link this invitation to your existing account.
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4">
{/* Option 1: Link to Existing Account */}
<div className="border-2 border-gray-200 rounded-lg p-6 hover:border-aptiva transition-colors">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Link to Your Existing Account</h3>
<ul className="space-y-2 mb-4 text-sm text-gray-600">
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Keep all your existing data and progress
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Access your account using either email address
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gain premium access through your organization
</li>
</ul>
<button
onClick={handleLinkAccount}
disabled={processing}
className="w-full bg-aptiva text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing ? 'Processing...' : 'Sign In to Link Account'}
</button>
</div>
{/* Option 2: Create New Account */}
<div className="border-2 border-gray-200 rounded-lg p-6 hover:border-gray-400 transition-colors">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Create a New Account</h3>
<ul className="space-y-2 mb-4 text-sm text-gray-600">
<li className="flex items-start">
<svg className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Start fresh with a new profile
</li>
<li className="flex items-start">
<svg className="h-5 w-5 text-blue-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Keep personal and school accounts separate
</li>
</ul>
<button
onClick={handleCreateNewAccount}
disabled={processing}
className="w-full bg-white border-2 border-gray-300 text-gray-700 py-3 px-4 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Create New Account
</button>
</div>
</div>
<p className="mt-6 text-center text-sm text-gray-500">
Questions? Contact your administrator for help.
</p>
</div>
</div>
);
}

View File

@ -112,6 +112,7 @@ function handleSubmit() {
setData(prev => ({ setData(prev => ({
...prev, ...prev,
career_name : selectedCareerTitle, career_name : selectedCareerTitle,
soc_code : careerObj?.soc_code || prev.soc_code || '',
college_enrollment_status : collegeStatus, college_enrollment_status : collegeStatus,
currently_working : currentlyWorking, currently_working : currentlyWorking,
inCollege, inCollege,

View File

@ -164,7 +164,11 @@ export default function OnboardingContainer() {
async function handleFinalSubmit() { async function handleFinalSubmit() {
try { try {
// 1) scenario upsert // 1) scenario upsert
const scenarioPayload = { ...careerData, id: careerData.career_profile_id || undefined }; const scenarioPayload = {
...careerData,
id: careerData.career_profile_id || undefined,
career_soc_code: careerData.soc_code || careerData.career_soc_code || undefined
};
const scenarioRes = await authFetch('/api/premium/career-profile', { const scenarioRes = await authFetch('/api/premium/career-profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scenarioPayload) body: JSON.stringify(scenarioPayload)
@ -214,6 +218,14 @@ export default function OnboardingContainer() {
await clearDraft(); await clearDraft();
localStorage.removeItem(POINTER_KEY); localStorage.removeItem(POINTER_KEY);
sessionStorage.setItem('suppressOnboardingGuard', '1'); sessionStorage.setItem('suppressOnboardingGuard', '1');
// Mark onboarding as completed for roster students
try {
await authFetch('/api/onboarding-completed', { method: 'POST' });
} catch (markErr) {
console.warn('Failed to mark onboarding complete:', markErr);
}
navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } }); navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } });
} catch (err) { } catch (err) {
console.error('Error in final submit =>', err); console.error('Error in final submit =>', err);

View File

@ -49,15 +49,13 @@ function PrivacyPolicy() {
<h2 className="text-xl font-semibold mb-2">Information We Collect</h2> <h2 className="text-xl font-semibold mb-2">Information We Collect</h2>
<ul className="list-disc list-inside mb-4 text-gray-700"> <ul className="list-disc list-inside mb-4 text-gray-700">
<li>Account information (username, password, profile details).</li> <li>Account information (username, password, email, phone, location).</li>
<li> <li>
Payment information handled by third-party <strong>payment processors</strong> <strong>Date of birth</strong> for age verification (COPPA compliance). We encrypt and
+ (we do not store full card details). securely store this information for legal compliance only and never share it with third parties.
</li> </li>
<li> <li>Payment information (handled by third-party payment processors; we do not store full card details).</li>
Messaging information (phone/email for notifications) handled by <strong>communications providers</strong>. <li>Career and education data you enter (career interests, goals, financial planning data).</li>
</li>
<li>Career and education data you enter into your profile.</li>
<li> <li>
Technical information: necessary cookies and local storage (for Technical information: necessary cookies and local storage (for
authentication, security, and preferences). authentication, security, and preferences).
@ -90,9 +88,45 @@ function PrivacyPolicy() {
<h2 className="text-xl font-semibold mb-2">Data Sharing</h2> <h2 className="text-xl font-semibold mb-2">Data Sharing</h2>
<p className="mb-4 text-gray-700"> <p className="mb-4 text-gray-700">
We only share information with providers required to run AptivaAI. We only share information with service providers required to run AptivaAI.
These providers are bound by their These providers are bound by their own security and privacy obligations.
own security/privacy obligations. We do not sell or rent your data. We do not sell or rent your data.
</p>
<h3 className="text-lg font-semibold mb-2 mt-4">Third-Party Service Providers</h3>
<p className="mb-2 text-gray-700">
AptivaAI uses the following third-party service providers to deliver our services:
</p>
<ul className="list-disc list-inside mb-4 text-gray-700 ml-4">
<li>
<strong>OpenAI</strong> (Career Coach chat functionality) - We use OpenAI's API
to power our Career Coach chat feature. Per OpenAI's data policy, API data is
not used to train or improve their models. Data sent via the API is retained
for 30 days solely for abuse and misuse monitoring, after which it is
permanently deleted. Student names and contact information are not sent to
OpenAI; only career-related data (location, career interests, goals) is shared
for providing coaching guidance. View{' '}
<a
href="https://openai.com/enterprise-privacy/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
OpenAI's privacy policy
</a>.
</li>
<li>
<strong>Payment processors</strong> - We use third-party payment processors
(Stripe) to handle subscription payments. We do not store full credit card details.
</li>
<li>
<strong>Communication providers</strong> - We use third-party services for
email and SMS notifications when you opt in to receive them.
</li>
</ul>
<p className="mb-4 text-gray-700">
We require all service providers to maintain appropriate security measures and
to use your information only for the purposes we specify.
</p> </p>
<h2 className="text-xl font-semibold mb-2">Data Security</h2> <h2 className="text-xl font-semibold mb-2">Data Security</h2>
@ -102,6 +136,19 @@ function PrivacyPolicy() {
your data. your data.
</p> </p>
<h2 className="text-xl font-semibold mb-2">Children's Privacy (COPPA)</h2>
<p className="mb-4 text-gray-700">
AptivaAI is designed for users aged 13 and older. We comply with the Children's
Online Privacy Protection Act (COPPA). We require all users to verify they are at
least 13 years old during registration. We collect date of birth solely for age
verification and legal compliance purposes. This information is encrypted and stored
securely, and is never shared with third parties including educational organizations.
If we become aware that a user under 13 has provided us with personal information,
we will take steps to delete such information. If you believe your child under 13
has created an account, please contact us immediately at{' '}
<EmailReveal className="ml-1" />.
</p>
<h2 className="text-xl font-semibold mb-2">Your Rights</h2> <h2 className="text-xl font-semibold mb-2">Your Rights</h2>
<p className="mb-4 text-gray-700"> <p className="mb-4 text-gray-700">
Depending on your location, you may have the right to access, correct, Depending on your location, you may have the right to access, correct,

View File

@ -0,0 +1,238 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function PrivacySettings() {
const [organizations, setOrganizations] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState('');
useEffect(() => {
fetchPrivacySettings();
}, []);
const fetchPrivacySettings = async () => {
try {
setLoading(true);
const { data } = await axios.get('/api/privacy-settings', {
withCredentials: true
});
setOrganizations(data.organizations || []);
setError(null);
} catch (err) {
console.error('[PrivacySettings] Error loading settings:', err);
setError('Failed to load privacy settings');
} finally {
setLoading(false);
}
};
const updateSetting = async (orgId, field, value) => {
try {
setSaving(true);
setSuccessMessage('');
setError(null);
// Find the organization's current settings
const org = organizations.find(o => o.organization_id === orgId);
if (!org) return;
const updatedSettings = {
...org.settings,
[field]: value
};
await axios.post('/api/privacy-settings', {
organization_id: orgId,
...updatedSettings
}, {
withCredentials: true
});
// Update local state
setOrganizations(orgs => orgs.map(o =>
o.organization_id === orgId
? { ...o, settings: updatedSettings }
: o
));
setSuccessMessage('Settings updated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
} catch (err) {
console.error('[PrivacySettings] Error updating settings:', err);
setError('Failed to update privacy settings');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
</div>
</div>
</div>
);
}
if (organizations.length === 0) {
return (
<div className="min-h-screen bg-gray-50 px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Privacy Settings</h1>
<div className="bg-white shadow rounded-lg p-8 text-center">
<p className="text-gray-600">
You are not enrolled in any organizations. Privacy settings are only available for students enrolled through organizations.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Privacy Settings</h1>
<p className="text-gray-600">
Control what information you share with your educational organizations. Your privacy is important to us - all settings default to private.
</p>
</div>
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{successMessage && (
<div className="mb-6 rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">{successMessage}</div>
</div>
)}
{organizations.map((org) => (
<div key={org.organization_id} className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
{org.organization_name}
</h2>
<p className="text-sm text-gray-600 mb-6">
Choose what data you want to share with {org.organization_name}. You can change these settings at any time.
</p>
<div className="space-y-4">
<PrivacyToggle
label="Career Exploration Data"
description="Share information about careers you've researched and viewed"
checked={org.settings.share_career_exploration || false}
onChange={(value) => updateSetting(org.organization_id, 'share_career_exploration', value)}
disabled={saving}
/>
<PrivacyToggle
label="Interest Inventory Results"
description="Share your RIASEC interest inventory results and career recommendations"
checked={org.settings.share_interest_inventory || false}
onChange={(value) => updateSetting(org.organization_id, 'share_interest_inventory', value)}
disabled={saving}
/>
<PrivacyToggle
label="Career Profiles"
description="Share your saved career profiles and career planning details"
checked={org.settings.share_career_profiles || false}
onChange={(value) => updateSetting(org.organization_id, 'share_career_profiles', value)}
disabled={saving}
/>
<PrivacyToggle
label="College Profiles"
description="Share your saved college profiles and college planning details"
checked={org.settings.share_college_profiles || false}
onChange={(value) => updateSetting(org.organization_id, 'share_college_profiles', value)}
disabled={saving}
/>
<PrivacyInfoBox
label="Financial Profile"
description="Your financial information is never shared with organizations to protect your privacy. Note: Expected salary projections from College Profiles may be visible to counselors as they are considered career planning data, not personal financial information."
/>
<PrivacyToggle
label="Career Roadmap"
description="Share your career roadmap and milestones"
checked={org.settings.share_roadmap || false}
onChange={(value) => updateSetting(org.organization_id, 'share_roadmap', value)}
disabled={saving}
/>
</div>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
<h3 className="text-sm font-semibold text-blue-900 mb-2">How Your Data is Shared</h3>
<p className="text-sm text-blue-800">
When you enable these settings, your organization's counselors and administrators can view your activity
data tied to your name and email. This allows them to provide personalized guidance and support for your
career exploration. Your data is always encrypted and secure, and you can change these settings at any time.
</p>
</div>
</div>
</div>
);
}
function PrivacyInfoBox({ label, description }) {
return (
<div className="flex items-start justify-between p-4 border border-blue-200 rounded-lg bg-blue-50">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{label}
</div>
<p className="text-sm text-gray-700 mt-1">{description}</p>
</div>
<div className="ml-4 flex-shrink-0">
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
);
}
function PrivacyToggle({ label, description, checked, onChange, disabled }) {
return (
<div className="flex items-start justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex-1">
<label className="text-sm font-medium text-gray-900 cursor-pointer">
{label}
</label>
<p className="text-sm text-gray-500 mt-1">{description}</p>
</div>
<div className="ml-4 flex-shrink-0">
<button
type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-aptiva focus:ring-offset-2 ${
checked ? 'bg-aptiva' : 'bg-gray-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function PrivacySettingsModal({ isOpen, onClose }) {
const [organizations, setOrganizations] = useState([]);
const [currentOrgIndex, setCurrentOrgIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState({
share_career_exploration: false,
share_interest_inventory: false,
share_career_profiles: false,
share_college_profiles: false,
share_financial_profile: false,
share_roadmap: false
});
useEffect(() => {
if (isOpen) {
fetchOrganizations();
}
}, [isOpen]);
const fetchOrganizations = async () => {
try {
setLoading(true);
const { data } = await axios.get('/api/privacy-settings', {
withCredentials: true
});
if (data.organizations && data.organizations.length > 0) {
setOrganizations(data.organizations);
// Load settings for first org
setSettings(data.organizations[0].settings || {
share_career_exploration: false,
share_interest_inventory: false,
share_career_profiles: false,
share_college_profiles: false,
share_financial_profile: false,
share_roadmap: false
});
}
} catch (err) {
console.error('[PrivacySettingsModal] Error loading organizations:', err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (organizations.length === 0) return;
try {
setSaving(true);
const currentOrg = organizations[currentOrgIndex];
await axios.post('/api/privacy-settings', {
organization_id: currentOrg.organization_id,
...settings
}, {
withCredentials: true
});
// If there are more organizations, move to next
if (currentOrgIndex < organizations.length - 1) {
const nextIndex = currentOrgIndex + 1;
setCurrentOrgIndex(nextIndex);
setSettings(organizations[nextIndex].settings || {
share_career_exploration: false,
share_interest_inventory: false,
share_career_profiles: false,
share_college_profiles: false,
share_financial_profile: false,
share_roadmap: false
});
} else {
// All done, close modal
onClose();
}
} catch (err) {
console.error('[PrivacySettingsModal] Error saving settings:', err);
alert('Failed to save privacy settings. Please try again.');
} finally {
setSaving(false);
}
};
const toggleSetting = (key) => {
setSettings(prev => ({
...prev,
[key]: !prev[key]
}));
};
if (!isOpen) return null;
if (loading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 max-w-2xl w-full mx-4">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-aptiva mx-auto"></div>
</div>
</div>
</div>
);
}
if (organizations.length === 0) {
return null; // No organizations, don't show modal
}
const currentOrg = organizations[currentOrgIndex];
const isLastOrg = currentOrgIndex === organizations.length - 1;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Privacy Settings
</h2>
<p className="text-sm text-gray-600 mb-6">
You've enrolled in <strong>{currentOrg.organization_name}</strong>. Please choose what information you'd like to share with them.
{organizations.length > 1 && ` (${currentOrgIndex + 1} of ${organizations.length})`}
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-900">
<strong>Your privacy is important to us.</strong> All settings default to private. When you enable sharing, your organization's counselors can view your activity data (tied to your name and email) to provide personalized guidance. You can change these settings anytime from Profile Privacy Settings.
</p>
</div>
<div className="space-y-4">
<PrivacyToggleItem
label="Career Exploration Data"
description="Share information about careers you've researched and viewed"
checked={settings.share_career_exploration}
onChange={() => toggleSetting('share_career_exploration')}
/>
<PrivacyToggleItem
label="Interest Inventory Results"
description="Share your RIASEC interest inventory results and career recommendations"
checked={settings.share_interest_inventory}
onChange={() => toggleSetting('share_interest_inventory')}
/>
<PrivacyToggleItem
label="Career Profiles"
description="Share your saved career profiles and career planning details"
checked={settings.share_career_profiles}
onChange={() => toggleSetting('share_career_profiles')}
/>
<PrivacyToggleItem
label="College Profiles"
description="Share your saved college profiles and college planning details"
checked={settings.share_college_profiles}
onChange={() => toggleSetting('share_college_profiles')}
/>
<PrivacyInfoItem
label="Financial Profile"
description="Your financial information is never shared with organizations to protect your privacy. Note: Expected salary projections from College Profiles may be visible to counselors as they are considered career planning data, not personal financial information."
/>
<PrivacyToggleItem
label="Career Roadmap"
description="Share your career roadmap and milestones"
checked={settings.share_roadmap}
onChange={() => toggleSetting('share_roadmap')}
/>
</div>
<div className="mt-8 flex justify-end gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-aptiva text-white rounded-md hover:bg-aptiva-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : (isLastOrg ? 'Save & Continue' : 'Next Organization')}
</button>
</div>
</div>
</div>
</div>
);
}
function PrivacyToggleItem({ label, description, checked, onChange }) {
return (
<div className="flex items-start justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex-1">
<label className="text-sm font-medium text-gray-900 cursor-pointer" onClick={onChange}>
{label}
</label>
<p className="text-sm text-gray-500 mt-1">{description}</p>
</div>
<div className="ml-4 flex-shrink-0">
<button
type="button"
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-aptiva focus:ring-offset-2 ${
checked ? 'bg-aptiva' : 'bg-gray-200'
}`}
role="switch"
aria-checked={checked}
onClick={onChange}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
);
}
function PrivacyInfoItem({ label, description }) {
return (
<div className="flex items-start justify-between p-4 border border-blue-200 rounded-lg bg-blue-50">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{label}
</div>
<p className="text-sm text-gray-700 mt-1">{description}</p>
</div>
<div className="ml-4 flex-shrink-0">
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
);
}

View File

@ -1,6 +1,14 @@
import React from 'react';
import { Bar } from 'react-chartjs-2'; import { Bar } from 'react-chartjs-2';
const RIASEC_DESCRIPTIONS = {
'Realistic': 'Prefers hands-on work with tools, machines, plants, or animals. Values practical tasks and tangible results.',
'Investigative': 'Enjoys analyzing information, solving problems, and conducting research. Values intellectual challenges.',
'Artistic': 'Prefers creative and expressive activities. Values self-expression, originality, and aesthetics.',
'Social': 'Enjoys helping, teaching, and working with people. Values relationships and making a positive impact.',
'Enterprising': 'Prefers leading, persuading, and managing others. Values achievement, influence, and business success.',
'Conventional': 'Prefers organized, structured tasks with clear procedures. Values accuracy, efficiency, and order.'
};
function RiaSecChart({ riaSecScores }) { function RiaSecChart({ riaSecScores }) {
const chartData = { const chartData = {
labels: riaSecScores.map(score => score.area), labels: riaSecScores.map(score => score.area),
@ -17,6 +25,7 @@ function RiaSecChart({ riaSecScores }) {
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
title: { title: {
display: true, display: true,
@ -34,9 +43,27 @@ function RiaSecChart({ riaSecScores }) {
}; };
return ( return (
<div className="riasec-scores"> <div className="flex gap-6">
<h2>RIASEC Scores</h2> <div className="flex-1" style={{ minWidth: '400px' }}>
<Bar data={chartData} options={options} /> <h2>RIASEC Scores</h2>
<Bar data={chartData} options={options} />
</div>
<div className="flex-shrink-0" style={{ width: '350px' }}>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Interest Area Descriptions</h3>
<div className="space-y-3">
{riaSecScores.map((score) => (
<div key={score.area} className="pb-3 border-b border-gray-200 last:border-b-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-900">{score.area}</span>
<span className="text-sm font-semibold text-teal-600">{score.score}</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">
{RIASEC_DESCRIPTIONS[score.area]}
</p>
</div>
))}
</div>
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect, useContext } from 'react'; import React, { useRef, useState, useEffect, useContext } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { ProfileCtx } from '../App.js'; import { ProfileCtx } from '../App.js';
import * as safeLocal from '../utils/safeLocal.js'; import * as safeLocal from '../utils/safeLocal.js';
@ -12,6 +12,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
const [showSessionExpiredMsg, setShowSessionExpiredMsg] = useState(false); const [showSessionExpiredMsg, setShowSessionExpiredMsg] = useState(false);
const [showConsent, setShowConsent] = useState(false); const [showConsent, setShowConsent] = useState(false);
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
useEffect(() => { useEffect(() => {
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
@ -105,7 +106,13 @@ function SignIn({ setIsAuthenticated, setUser }) {
setIsAuthenticated(true); setIsAuthenticated(true);
setUser(minimalUser); setUser(minimalUser);
navigate('/signin-landing'); // Check for redirect parameter (used by invitation flow)
const redirectTo = searchParams.get('redirect');
if (redirectTo) {
navigate(decodeURIComponent(redirectTo));
} else {
navigate('/signin-landing');
}
} catch (err) { } catch (err) {
setError(err.message || 'Sign-in failed'); setError(err.message || 'Sign-in failed');
} }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import SituationCard from './ui/SituationCard.js'; import SituationCard from './ui/SituationCard.js';
import PromptModal from './ui/PromptModal.js'; import PromptModal from './ui/PromptModal.js';
@ -40,8 +40,9 @@ const careerSituations = [
]; ];
function SignUp() { function SignUp({ inviteData: inviteDataProp = null }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
// existing states // existing states
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@ -64,11 +65,71 @@ function SignUp() {
const debounceRef = useRef(null); // debounce timer const debounceRef = useRef(null); // debounce timer
const inflightRef = useRef(null); // AbortController for in-flight const inflightRef = useRef(null); // AbortController for in-flight
// Date of birth for age verification
const [dobMonth, setDobMonth] = useState('');
const [dobDay, setDobDay] = useState('');
const [dobYear, setDobYear] = useState('');
const [showCareerSituations, setShowCareerSituations] = useState(false); const [showCareerSituations, setShowCareerSituations] = useState(false);
const [selectedSituation, setSelectedSituation] = useState(null); const [selectedSituation, setSelectedSituation] = useState(null);
const [showPrompt, setShowPrompt] = useState(false); const [showPrompt, setShowPrompt] = useState(false);
// Invitation state
const [inviteToken, setInviteToken] = useState(null);
const [inviteValidating, setInviteValidating] = useState(false);
const [inviteValid, setInviteValid] = useState(false);
// Check for invitation token in URL query params
useEffect(() => {
const token = searchParams.get('invite');
if (!token) return;
console.log('[SignUp] Found invite token in URL:', token);
setInviteValidating(true);
setInviteToken(token);
// Validate the invitation token
fetch('/api/validate-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
})
.then(res => res.json())
.then(data => {
console.log('[SignUp] Invite validation response:', data);
if (data.valid) {
setInviteValid(true);
setEmail(data.email);
setConfirmEmail(data.email);
if (data.firstname) setFirstname(data.firstname);
if (data.lastname) setLastname(data.lastname);
setError('');
} else {
setError(data.error || 'Invalid or expired invitation link');
setInviteValid(false);
}
})
.catch(err => {
console.error('[SignUp] Error validating invitation:', err);
setError('Failed to validate invitation. Please try again or contact your administrator.');
setInviteValid(false);
})
.finally(() => {
setInviteValidating(false);
});
}, [searchParams]);
// Legacy: Pre-fill email if invitation data is provided via prop (InviteSignup component - will be deprecated)
useEffect(() => {
console.log('[SignUp] inviteDataProp:', inviteDataProp);
if (inviteDataProp && inviteDataProp.email) {
setEmail(inviteDataProp.email);
setConfirmEmail(inviteDataProp.email);
setInviteToken(inviteDataProp.token);
setInviteValid(true);
}
}, [inviteDataProp]);
const states = [ const states = [
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
@ -94,19 +155,61 @@ function SignUp() {
const validateFields = async () => { const validateFields = async () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const zipRegex = /^\d{5}$/; const zipRegex = /^\d{5}$/;
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
const usPhoneRegex = /^\+1\d{10}$/; const usPhoneRegex = /^\+1\d{10}$/;
if ( if (
!username || !password || !confirmPassword || !username || !password || !confirmPassword ||
!firstname || !lastname || !firstname || !lastname ||
!email || !confirmEmail || !email || !confirmEmail ||
!dobMonth || !dobDay || !dobYear ||
!zipcode || !state || !area !zipcode || !state || !area
) { ) {
setError('All fields are required.'); setError('All fields are required.');
return false; return false;
} }
// Validate date of birth and calculate age
const month = parseInt(dobMonth, 10);
const day = parseInt(dobDay, 10);
const year = parseInt(dobYear, 10);
if (month < 1 || month > 12) {
setError('Invalid birth month.');
return false;
}
if (day < 1 || day > 31) {
setError('Invalid birth day.');
return false;
}
const currentYear = new Date().getFullYear();
if (year < 1900 || year > currentYear) {
setError('Invalid birth year.');
return false;
}
// Check if date is valid (e.g., no Feb 31)
const birthDate = new Date(year, month - 1, day);
if (birthDate.getMonth() !== month - 1 || birthDate.getDate() !== day) {
setError('Invalid date. Please check your birth date.');
return false;
}
// Calculate age
const today = new Date();
let age = today.getFullYear() - year;
const monthDiff = today.getMonth() - (month - 1);
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < day)) {
age--;
}
// COPPA compliance: Must be at least 13
if (age < 13) {
setError('You must be at least 13 years old to use AptivaAI. If you believe this is an error, please contact support.');
return false;
}
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
setError('Enter a valid email address.'); setError('Enter a valid email address.');
return false; return false;
@ -171,6 +274,9 @@ const handleSituationConfirm = async () => {
setShowPrompt(false); setShowPrompt(false);
try { try {
// Format DOB as YYYY-MM-DD for backend
const dob = `${dobYear}-${dobMonth.padStart(2, '0')}-${dobDay.padStart(2, '0')}`;
const response = await fetch('/api/register', { const response = await fetch('/api/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -180,12 +286,14 @@ const handleSituationConfirm = async () => {
firstname, firstname,
lastname, lastname,
email, email,
date_of_birth: dob,
zipcode, zipcode,
state, state,
area, area,
phone_e164 : phone, phone_e164 : phone,
sms_opt_in : optIn, sms_opt_in : optIn,
career_situation: selectedSituation.id career_situation: selectedSituation.id,
inviteToken: inviteToken || inviteDataProp?.token || null // Include invite token if present
}), }),
}); });
@ -253,7 +361,19 @@ return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-4"> <div className="flex min-h-screen items-center justify-center bg-gray-100 p-4">
{!showCareerSituations ? ( {!showCareerSituations ? (
<div className="w-full max-w-md bg-white p-6 rounded-lg shadow-lg"> <div className="w-full max-w-md bg-white p-6 rounded-lg shadow-lg">
<h2 className="mb-4 text-2xl font-semibold text-center">Sign Up</h2> <h2 className="mb-4 text-2xl font-semibold text-center">
{(inviteToken || inviteDataProp) ? 'Complete Your Invitation' : 'Sign Up'}
</h2>
{inviteValidating && (
<div className="mb-4 p-2 text-sm text-blue-600 bg-blue-100 rounded">
Validating invitation...
</div>
)}
{inviteValid && !error && (
<div className="mb-4 p-2 text-sm text-green-600 bg-green-100 rounded">
You've been invited! Please complete your account setup below.
</div>
)}
{error && ( {error && (
<div className="mb-4 p-2 text-sm text-red-600 bg-red-100 rounded"> <div className="mb-4 p-2 text-sm text-red-600 bg-red-100 rounded">
{error} {error}
@ -298,6 +418,9 @@ return (
placeholder="Email" placeholder="Email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
readOnly={!!(inviteToken || inviteDataProp)}
disabled={!!(inviteToken || inviteDataProp)}
style={(inviteToken || inviteDataProp) ? { backgroundColor: '#f3f4f6', cursor: 'not-allowed' } : {}}
/> />
<input <input
className="w-full px-3 py-2 border rounded-md" className="w-full px-3 py-2 border rounded-md"
@ -305,8 +428,50 @@ return (
type="email" type="email"
value={confirmEmail} value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)} onChange={(e) => setConfirmEmail(e.target.value)}
readOnly={!!(inviteToken || inviteDataProp)}
disabled={!!(inviteToken || inviteDataProp)}
style={(inviteToken || inviteDataProp) ? { backgroundColor: '#f3f4f6', cursor: 'not-allowed' } : {}}
/> />
{/* ─────────────── Date of Birth (Age Verification) ─────────────── */}
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-700">
Date of Birth (for age verification)
</label>
<div className="grid grid-cols-3 gap-2">
<input
type="number"
className="w-full px-3 py-2 border rounded-md"
placeholder="MM"
min="1"
max="12"
value={dobMonth}
onChange={(e) => setDobMonth(e.target.value)}
/>
<input
type="number"
className="w-full px-3 py-2 border rounded-md"
placeholder="DD"
min="1"
max="31"
value={dobDay}
onChange={(e) => setDobDay(e.target.value)}
/>
<input
type="number"
className="w-full px-3 py-2 border rounded-md"
placeholder="YYYY"
min="1900"
max={new Date().getFullYear()}
value={dobYear}
onChange={(e) => setDobYear(e.target.value)}
/>
</div>
<p className="text-xs text-gray-500">
You must be at least 13 years old to use AptivaAI. We encrypt and securely store your date of birth for legal compliance only.
</p>
</div>
{/* ─────────────── New: Mobile number ─────────────── */} {/* ─────────────── New: Mobile number ─────────────── */}
<input <input
type="tel" type="tel"

View File

@ -44,8 +44,9 @@ function TermsOfService() {
<h2 className="text-xl font-semibold mb-2">2. Eligibility</h2> <h2 className="text-xl font-semibold mb-2">2. Eligibility</h2>
<p className="mb-4 text-gray-700"> <p className="mb-4 text-gray-700">
You must be at least 16 years old, or the minimum age required by law in your jurisdiction, You must be at least 13 years old to use AptivaAI. If you are under 18, you confirm that you have
to use AptivaAI. By using the Service, you represent that you meet this requirement. your parent or guardian's permission to use the Service. By using the Service, you represent that
you meet this requirement.
</p> </p>
<h2 className="text-xl font-semibold mb-2">3. Accounts</h2> <h2 className="text-xl font-semibold mb-2">3. Accounts</h2>

View File

@ -338,6 +338,26 @@ function UserProfile() {
</select> </select>
</div> </div>
{/* Date of Birth Notice */}
<div className="mt-6 rounded border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start gap-2">
<svg className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="text-sm font-semibold text-gray-900">Date of Birth</h4>
<p className="text-sm text-gray-700 mt-1">
Your date of birth cannot be changed as it is used for age verification and legal compliance (COPPA).
If you entered your date of birth incorrectly during signup, please contact{' '}
<a href="mailto:support@aptivaai.com" className="text-blue-600 hover:underline">
support@aptivaai.com
</a>{' '}
with proof of age for assistance.
</p>
</div>
</div>
</div>
{/* Password */} {/* Password */}
<div className="mt-8"> <div className="mt-8">
<button <button

View File

@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const AdminContext = createContext();
export function useAdmin() {
const context = useContext(AdminContext);
if (!context) {
throw new Error('useAdmin must be used within AdminProvider');
}
return context;
}
export function AdminProvider({ children }) {
const [admin, setAdmin] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Check if we're on admin subdomain
const isAdminSubdomain = () => {
const hostname = window.location.hostname;
return hostname.includes('.admin.') || hostname === 'admin.aptivaai.com';
};
// Check admin session
const checkSession = async () => {
if (!isAdminSubdomain()) {
setLoading(false);
return;
}
try {
const { data } = await axios.get('/api/admin/auth/me', {
withCredentials: true
});
setAdmin(data);
setError(null);
} catch (err) {
setAdmin(null);
if (err.response?.status !== 401) {
setError(err.response?.data?.error || 'Failed to check session');
}
} finally {
setLoading(false);
}
};
useEffect(() => {
checkSession();
}, []);
const login = async (username, password) => {
try {
const { data } = await axios.post('/api/admin/auth/login',
{ username, password },
{ withCredentials: true }
);
await checkSession(); // Reload admin data
return { success: true, data };
} catch (err) {
const errorMsg = err.response?.data?.error || 'Login failed';
setError(errorMsg);
return { success: false, error: errorMsg };
}
};
const logout = async () => {
try {
await axios.post('/api/admin/auth/logout', {}, {
withCredentials: true
});
} catch (err) {
console.error('Logout error:', err);
} finally {
setAdmin(null);
setError(null);
}
};
const value = {
admin,
loading,
error,
isAuthenticated: !!admin,
isAdminPortal: isAdminSubdomain(),
isSuperAdmin: admin?.isSuperAdmin || false,
login,
logout,
checkSession
};
return (
<AdminContext.Provider value={value}>
{children}
</AdminContext.Provider>
);
}