Big one - admin portal and DOB COPPA compliance
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
This commit is contained in:
parent
6a58f62075
commit
c0a68eb81c
@ -1 +1 @@
|
||||
7525e7b74f06b3341cb73a157afaea13b4af1f5d-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
7ed237a540a248342b77de971556a895ac91cacc-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
||||
@ -47,3 +47,17 @@ GAETC_PRINT_MATERIALS_FINAL.md
|
||||
CONFERENCE_MATERIALS.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
13
.gitignore
vendored
@ -38,3 +38,16 @@ ACCURATE_COST_PROJECTIONS.md
|
||||
GAETC_PRINT_MATERIALS_FINAL.md
|
||||
CONFERENCE_MATERIALS.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/
|
||||
|
||||
@ -24,7 +24,7 @@ steps:
|
||||
|
||||
# Check which images already exist in PROD (so we don't try to push them)
|
||||
MISSING=""
|
||||
for s in server1 server2 server3 nginx; do
|
||||
for s in server1 server2 server3 server4 nginx; do
|
||||
REF="docker://$DST/$s:$IMG_TAG"
|
||||
if ! skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null 2>&1; then
|
||||
MISSING="$MISSING $s"
|
||||
@ -67,7 +67,7 @@ steps:
|
||||
DST="us-central1-docker.pkg.dev/aptivaai-prod/aptiva-repo"
|
||||
apt-get update -qq && apt-get install -y -qq skopeo
|
||||
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"
|
||||
echo "🔎 verify $REF"
|
||||
skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null
|
||||
@ -93,7 +93,7 @@ steps:
|
||||
export PATH="$PATH:$(pwd)/bin"
|
||||
|
||||
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"
|
||||
echo "🛡 scan $REF"
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
@ -185,10 +187,10 @@ steps:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
echo "✅ Prod stack refreshed with tag $IMG_TAG"
|
||||
|
||||
24
Dockerfile.server4
Normal file
24
Dockerfile.server4
Normal 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"]
|
||||
@ -475,6 +475,16 @@ function emailLookup(email) {
|
||||
.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) -----
|
||||
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)
|
||||
------------------------------------------------------------------ */
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const {
|
||||
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;
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
// 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 || '')) {
|
||||
return res.status(400).json({ error: 'Phone must be +E.164 format.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
let userId;
|
||||
let isInvitedStudent = false;
|
||||
let organizationId = null;
|
||||
|
||||
const emailNorm = String(email).trim().toLowerCase();
|
||||
const encEmail = encrypt(emailNorm); // if encrypt() is async in your lib, use: await encrypt(...)
|
||||
const emailLookupVal = emailLookup(emailNorm);
|
||||
// Check if this is completing an invitation
|
||||
if (inviteToken) {
|
||||
try {
|
||||
const decoded = jwt.verify(inviteToken, JWT_SECRET);
|
||||
|
||||
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
|
||||
]);
|
||||
if (decoded.prp === 'student_invite') {
|
||||
userId = decoded.userId;
|
||||
organizationId = decoded.organizationId;
|
||||
isInvitedStudent = true;
|
||||
|
||||
// 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 (?, ?, ?)`;
|
||||
await pool.query(authQuery, [newProfileId, username, hashedPassword]);
|
||||
if (existingUser[0].username) {
|
||||
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());
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'User registered successfully',
|
||||
profileId: newProfileId,
|
||||
profileId: userId,
|
||||
token,
|
||||
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
|
||||
}
|
||||
});
|
||||
@ -954,16 +1295,19 @@ app.post('/api/signin', signinLimiter, async (req, res) => {
|
||||
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
|
||||
const query = `
|
||||
SELECT ua.user_id AS userProfileId, ua.hashed_password
|
||||
FROM user_auth ua
|
||||
WHERE ua.username = ?
|
||||
WHERE ua.username_lookup = ?
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const [results] = await pool.query(query, [username]);
|
||||
const [results] = await pool.query(query, [usernameLookupVal]);
|
||||
if (!results || results.length === 0) {
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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
|
||||
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
||||
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
|
||||
@ -1052,10 +1399,17 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
|
||||
const finalUserName = (userName !== undefined)
|
||||
? userName
|
||||
: existing?.username ?? null;
|
||||
const finalRiasec = riasec_scores
|
||||
const finalRiasec = (riasec_scores !== undefined)
|
||||
? JSON.stringify(riasec_scores)
|
||||
: 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)
|
||||
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
|
||||
@ -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) => {
|
||||
if (res.headersSent) return;
|
||||
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
|
||||
|
||||
@ -1067,25 +1067,37 @@ app.post('/api/onet/submit_answers', async (req, res) => {
|
||||
const filtered = filterHigherEducationCareers(careerSuggestions);
|
||||
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)
|
||||
const bearer = req.headers.authorization; // e.g. "Bearer eyJ..."
|
||||
if (bearer) {
|
||||
console.log('[submit_answers] Sending to /api/user-profile with RIASEC:', riasecScoresObject);
|
||||
try {
|
||||
await axios.post(
|
||||
const response = await axios.post(
|
||||
`${API_BASE}/api/user-profile`,
|
||||
{
|
||||
interest_inventory_answers: answers,
|
||||
riasec: riasecCode,
|
||||
riasec: riasecScoresObject,
|
||||
},
|
||||
{ headers: { Authorization: bearer } }
|
||||
);
|
||||
console.log('[submit_answers] Successfully saved RIASEC scores');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Error storing RIASEC in user_profile =>',
|
||||
'[submit_answers] Error storing RIASEC in user_profile =>',
|
||||
err.response?.data || err.message
|
||||
);
|
||||
// non-fatal for the O*NET response
|
||||
}
|
||||
} else {
|
||||
console.log('[submit_answers] No bearer token, skipping RIASEC save');
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
@ -1141,7 +1153,7 @@ function convertToRiasecCode(riaSecScores) {
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (!socCode) {
|
||||
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' },
|
||||
});
|
||||
|
||||
res.status(200).json(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching career details:', err);
|
||||
|
||||
@ -1149,6 +1149,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
const {
|
||||
scenario_title,
|
||||
career_name,
|
||||
career_soc_code,
|
||||
status,
|
||||
start_date,
|
||||
college_enrollment_status,
|
||||
@ -1174,13 +1175,14 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
try {
|
||||
const finalId = req.body.id || uuidv4();
|
||||
|
||||
// 1) Insert includes career_goals
|
||||
// 1) Insert includes career_goals and career_soc_code
|
||||
const sql = `
|
||||
INSERT INTO career_profiles (
|
||||
id,
|
||||
user_id,
|
||||
scenario_title,
|
||||
career_name,
|
||||
career_soc_code,
|
||||
status,
|
||||
start_date,
|
||||
college_enrollment_status,
|
||||
@ -1196,8 +1198,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
planned_surplus_retirement_pct,
|
||||
planned_additional_income
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
career_soc_code = VALUES(career_soc_code),
|
||||
status = VALUES(status),
|
||||
start_date = VALUES(start_date),
|
||||
college_enrollment_status = VALUES(college_enrollment_status),
|
||||
@ -1220,6 +1223,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
req.id,
|
||||
scenario_title || null,
|
||||
career_name,
|
||||
career_soc_code || null,
|
||||
status || 'planned',
|
||||
start_date || null,
|
||||
college_enrollment_status || null,
|
||||
@ -1479,11 +1483,7 @@ const lastAssistantIsOneShotQ =
|
||||
const _scenarioRow = scenarioRow || {};
|
||||
const _financialProfile = financialProfile || {};
|
||||
const _collegeProfile = collegeProfile || {};
|
||||
// 1) USER PROFILE
|
||||
const firstName = userProfile.firstname || "N/A";
|
||||
const lastName = userProfile.lastname || "N/A";
|
||||
const fullName = `${firstName} ${lastName}`;
|
||||
const username = _userProfile.username || "N/A";
|
||||
// 1) USER PROFILE (PII removed - no names/username sent to AI)
|
||||
const location = _userProfile.area || _userProfile.state || "Unknown Region";
|
||||
const careerSituation = _userProfile.career_situation || "Not provided";
|
||||
|
||||
@ -1604,11 +1604,9 @@ Occupation: ${economicProjections.national.occupationName}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// 8) BUILD THE FINAL TEXT
|
||||
// 8) BUILD THE FINAL TEXT (PII removed - no student names/usernames)
|
||||
return `
|
||||
[USER PROFILE]
|
||||
- Full Name: ${fullName}
|
||||
- Username: ${username}
|
||||
- Location: ${location}
|
||||
- Career Situation: ${careerSituation}
|
||||
- RIASEC:
|
||||
|
||||
3129
backend/server4.js
Normal file
3129
backend/server4.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,9 @@ const WRITE_RE = /^\s*(insert|update|replace)\s/i;
|
||||
|
||||
/* ── map of columns that must be protected ─────────────────── */
|
||||
const TABLE_MAP = {
|
||||
user_auth : [
|
||||
'username', 'date_of_birth'
|
||||
],
|
||||
user_profile : [
|
||||
'username', 'firstname', 'lastname', 'email', 'phone_e164',
|
||||
'zipcode', 'stripe_customer_id',
|
||||
|
||||
@ -18,7 +18,7 @@ echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)"
|
||||
SECRETS=(
|
||||
ENV_NAME PROJECT CORS_ALLOWED_ORIGINS
|
||||
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
|
||||
STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET
|
||||
STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR
|
||||
@ -27,7 +27,7 @@ SECRETS=(
|
||||
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA
|
||||
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE
|
||||
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
|
||||
)
|
||||
|
||||
@ -103,7 +103,7 @@ build_and_push () {
|
||||
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)
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
|
||||
@ -173,12 +173,56 @@ services:
|
||||
retries: 5
|
||||
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:
|
||||
<<: *with-env
|
||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/nginx:${IMG_TAG}
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
depends_on: [server1, server2, server3]
|
||||
depends_on: [server1, server2, server3, server4]
|
||||
networks: [default, aptiva-shared]
|
||||
environment:
|
||||
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
|
||||
|
||||
@ -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
|
||||
(512‑byte text columns ≈ 684 B once encrypted/Base64‑encoded)
|
||||
───────────────────*/
|
||||
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 *don’t* need to query by these columns any more,
|
||||
just comment‑out 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;
|
||||
77
nginx.conf
77
nginx.conf
@ -19,6 +19,7 @@ http {
|
||||
upstream backend5001 { server server2:5001;
|
||||
keepalive 1024;} # onet, distance, etc.
|
||||
upstream backend5002 { server server3:5002; } # premium
|
||||
upstream backend5003 { server server4:5003; } # admin portal
|
||||
upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
|
||||
upstream woodpecker_backend { server woodpecker-server:8000; }
|
||||
|
||||
@ -195,10 +196,23 @@ http {
|
||||
limit_req zone=reqperip burst=10 nodelay;
|
||||
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;
|
||||
limit_req zone=reqperip burst=10 nodelay;
|
||||
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
|
||||
location ^~ /api/ { proxy_pass http://backend5000; }
|
||||
|
||||
@ -212,6 +226,69 @@ http {
|
||||
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
|
||||
########################################################################
|
||||
|
||||
178
src/App.js
178
src/App.js
@ -15,6 +15,8 @@ import SessionExpiredHandler from './components/SessionExpiredHandler.js';
|
||||
import SignInLanding from './components/SignInLanding.js';
|
||||
import SignIn from './components/SignIn.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 CareerExplorer from './components/CareerExplorer.js';
|
||||
import PreparingLanding from './components/PreparingLanding.js';
|
||||
@ -49,11 +51,21 @@ import VerificationGate from './components/VerificationGate.js';
|
||||
import Verify from './components/Verify.js';
|
||||
import { initNetObserver } from './utils/net.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 HomePage from './components/HomePage.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();
|
||||
|
||||
@ -185,6 +197,9 @@ const canShowRetireBot =
|
||||
// Logout warning modal
|
||||
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
|
||||
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
||||
@ -222,19 +237,19 @@ const showPremiumCTA = !premiumPaths.some(p =>
|
||||
// ==============================
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (loggingOut) return;
|
||||
if (loggingOut) return;
|
||||
|
||||
// Skip auth probe on all public auth routes
|
||||
if (
|
||||
location.pathname.startsWith('/reset-password') ||
|
||||
location.pathname === '/signin' ||
|
||||
location.pathname === '/signup' ||
|
||||
location.pathname === '/forgot-password' ||
|
||||
location.pathname === '/privacy' ||
|
||||
location.pathname === '/terms' ||
|
||||
location.pathname === '/home' ||
|
||||
location.pathname === '/demo'
|
||||
) {
|
||||
if (
|
||||
location.pathname.startsWith('/reset-password') ||
|
||||
location.pathname === '/signin' ||
|
||||
location.pathname === '/signup' ||
|
||||
location.pathname === '/forgot-password' ||
|
||||
location.pathname === '/privacy' ||
|
||||
location.pathname === '/terms' ||
|
||||
location.pathname === '/home' ||
|
||||
location.pathname === '/demo'
|
||||
) {
|
||||
try { localStorage.removeItem('id'); } catch {}
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
@ -255,7 +270,28 @@ if (loggingOut) return;
|
||||
is_premium : !!data?.is_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);
|
||||
|
||||
// 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) {
|
||||
if (cancelled) return;
|
||||
clearToken();
|
||||
@ -272,7 +308,9 @@ if (loggingOut) return;
|
||||
p === '/privacy' ||
|
||||
p === '/terms' ||
|
||||
p === '/home' ||
|
||||
p === '/demo';
|
||||
p === '/demo' ||
|
||||
p === '/invite-response' ||
|
||||
p === '/link-secondary-email';
|
||||
if (!onPublic) navigate('/signin?session=expired', { replace: true });
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
@ -285,6 +323,40 @@ if (loggingOut) return;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// ==========================
|
||||
@ -561,6 +633,14 @@ const cancelLogout = () => {
|
||||
>
|
||||
Account
|
||||
</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
|
||||
to="/financial-profile"
|
||||
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">
|
||||
|
||||
<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>
|
||||
{canAccessPremium ? (
|
||||
<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} />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/invite-response"
|
||||
element={<InviteResponse />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/link-secondary-email"
|
||||
element={<LinkSecondaryEmail />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
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="/interest-inventory" element={<VerificationGate><InterestInventory /></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="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
|
||||
<Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
|
||||
@ -935,10 +1029,66 @@ const cancelLogout = () => {
|
||||
|
||||
{/* Session Handler (Optional) */}
|
||||
<SessionExpiredHandler />
|
||||
|
||||
{/* Privacy Settings Modal (for organization students) */}
|
||||
<PrivacySettingsModal
|
||||
isOpen={showPrivacyModal}
|
||||
onClose={() => setShowPrivacyModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</ChatCtx.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;
|
||||
|
||||
155
src/components/Admin/AddStudent.js
Normal file
155
src/components/Admin/AddStudent.js
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/Admin/AdminLayout.js
Normal file
130
src/components/Admin/AdminLayout.js
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/Admin/AdminLogin.js
Normal file
102
src/components/Admin/AdminLogin.js
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/components/Admin/BouncedInvitations.js
Normal file
244
src/components/Admin/BouncedInvitations.js
Normal 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>
|
||||
);
|
||||
}
|
||||
538
src/components/Admin/Dashboard.js
Normal file
538
src/components/Admin/Dashboard.js
Normal 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>
|
||||
);
|
||||
}
|
||||
188
src/components/Admin/PendingInvitations.js
Normal file
188
src/components/Admin/PendingInvitations.js
Normal 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>
|
||||
);
|
||||
}
|
||||
438
src/components/Admin/RosterUpload.js
Normal file
438
src/components/Admin/RosterUpload.js
Normal 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>
|
||||
);
|
||||
}
|
||||
945
src/components/Admin/Settings.js
Normal file
945
src/components/Admin/Settings.js
Normal 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>
|
||||
);
|
||||
}
|
||||
576
src/components/Admin/StudentDetail.js
Normal file
576
src/components/Admin/StudentDetail.js
Normal 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>
|
||||
);
|
||||
}
|
||||
402
src/components/Admin/StudentList.js
Normal file
402
src/components/Admin/StudentList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -339,6 +339,14 @@ function CareerExplorer() {
|
||||
setSelectedCareer(career);
|
||||
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 {
|
||||
let cipCode = null;
|
||||
try { const { data } = await api.get(`/api/cip/${socCode}`); cipCode = data?.cipCode ?? null; } catch {}
|
||||
|
||||
@ -54,7 +54,7 @@ export default function CareerProfileForm() {
|
||||
...prev,
|
||||
scenario_title : d.scenario_title ?? '',
|
||||
career_name : d.career_name ?? '',
|
||||
soc_code : d.soc_code ?? '',
|
||||
soc_code : d.career_soc_code ?? d.soc_code ?? '',
|
||||
status : d.status ?? 'current',
|
||||
start_date : (d.start_date || '').slice(0, 10), // ← trim
|
||||
retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
|
||||
@ -78,6 +78,7 @@ export default function CareerProfileForm() {
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({
|
||||
...form,
|
||||
career_soc_code : form.soc_code, // map to backend field name
|
||||
start_date : form.start_date?.slice(0, 10) || null,
|
||||
retirement_start_date : form.retirement_start_date?.slice(0, 10) || null,
|
||||
id: id === 'new' ? undefined : id // upsert
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { format } from 'date-fns'; // ⬅ install if not already
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
@ -335,6 +335,7 @@ function getYearsInCareer(startDateString) {
|
||||
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
||||
const { careerId } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'RANDOM'
|
||||
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
|
||||
@ -864,6 +865,9 @@ useEffect(() => {
|
||||
setCareerProfileId(latest.id);
|
||||
setSelectedCareer(latest);
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ const InterestInventory = () => {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
riasec_scores: scoresMap // store in DB as a JSON string
|
||||
riasec: scoresMap // server1 expects 'riasec', not 'riasec_scores'
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
235
src/components/InviteResponse.js
Normal file
235
src/components/InviteResponse.js
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/InviteSignup.js
Normal file
93
src/components/InviteSignup.js
Normal 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;
|
||||
227
src/components/LinkSecondaryEmail.js
Normal file
227
src/components/LinkSecondaryEmail.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -112,6 +112,7 @@ function handleSubmit() {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
career_name : selectedCareerTitle,
|
||||
soc_code : careerObj?.soc_code || prev.soc_code || '',
|
||||
college_enrollment_status : collegeStatus,
|
||||
currently_working : currentlyWorking,
|
||||
inCollege,
|
||||
|
||||
@ -164,7 +164,11 @@ export default function OnboardingContainer() {
|
||||
async function handleFinalSubmit() {
|
||||
try {
|
||||
// 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', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(scenarioPayload)
|
||||
@ -214,6 +218,14 @@ export default function OnboardingContainer() {
|
||||
await clearDraft();
|
||||
localStorage.removeItem(POINTER_KEY);
|
||||
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 } });
|
||||
} catch (err) {
|
||||
console.error('Error in final submit =>', err);
|
||||
|
||||
@ -49,15 +49,13 @@ function PrivacyPolicy() {
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Information We Collect</h2>
|
||||
<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>
|
||||
Payment information handled by third-party <strong>payment processors</strong>
|
||||
+ (we do not store full card details).
|
||||
<strong>Date of birth</strong> for age verification (COPPA compliance). We encrypt and
|
||||
securely store this information for legal compliance only and never share it with third parties.
|
||||
</li>
|
||||
<li>
|
||||
Messaging information (phone/email for notifications) handled by <strong>communications providers</strong>.
|
||||
</li>
|
||||
<li>Career and education data you enter into your profile.</li>
|
||||
<li>Payment information (handled by third-party payment processors; we do not store full card details).</li>
|
||||
<li>Career and education data you enter (career interests, goals, financial planning data).</li>
|
||||
<li>
|
||||
Technical information: necessary cookies and local storage (for
|
||||
authentication, security, and preferences).
|
||||
@ -90,9 +88,45 @@ function PrivacyPolicy() {
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Data Sharing</h2>
|
||||
<p className="mb-4 text-gray-700">
|
||||
We only share information with providers required to run AptivaAI.
|
||||
These providers are bound by their
|
||||
own security/privacy obligations. We do not sell or rent your data.
|
||||
We only share information with service providers required to run AptivaAI.
|
||||
These providers are bound by their own security and privacy obligations.
|
||||
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>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">Data Security</h2>
|
||||
@ -102,6 +136,19 @@ function PrivacyPolicy() {
|
||||
your data.
|
||||
</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>
|
||||
<p className="mb-4 text-gray-700">
|
||||
Depending on your location, you may have the right to access, correct,
|
||||
|
||||
238
src/components/PrivacySettings.js
Normal file
238
src/components/PrivacySettings.js
Normal 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>
|
||||
);
|
||||
}
|
||||
237
src/components/PrivacySettingsModal.js
Normal file
237
src/components/PrivacySettingsModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,14 @@
|
||||
import React from 'react';
|
||||
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 }) {
|
||||
const chartData = {
|
||||
labels: riaSecScores.map(score => score.area),
|
||||
@ -17,6 +25,7 @@ function RiaSecChart({ riaSecScores }) {
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
@ -34,9 +43,27 @@ function RiaSecChart({ riaSecScores }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="riasec-scores">
|
||||
<h2>RIASEC Scores</h2>
|
||||
<Bar data={chartData} options={options} />
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-1" style={{ minWidth: '400px' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 * as safeLocal from '../utils/safeLocal.js';
|
||||
|
||||
@ -12,6 +12,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const [showSessionExpiredMsg, setShowSessionExpiredMsg] = useState(false);
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
@ -105,7 +106,13 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
setIsAuthenticated(true);
|
||||
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) {
|
||||
setError(err.message || 'Sign-in failed');
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 SituationCard from './ui/SituationCard.js';
|
||||
import PromptModal from './ui/PromptModal.js';
|
||||
@ -40,8 +40,9 @@ const careerSituations = [
|
||||
];
|
||||
|
||||
|
||||
function SignUp() {
|
||||
function SignUp({ inviteData: inviteDataProp = null }) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// existing states
|
||||
const [username, setUsername] = useState('');
|
||||
@ -64,11 +65,71 @@ function SignUp() {
|
||||
const debounceRef = useRef(null); // debounce timer
|
||||
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 [selectedSituation, setSelectedSituation] = useState(null);
|
||||
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 = [
|
||||
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
|
||||
@ -94,19 +155,61 @@ function SignUp() {
|
||||
const validateFields = async () => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
const zipRegex = /^\d{5}$/;
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
|
||||
const usPhoneRegex = /^\+1\d{10}$/;
|
||||
|
||||
if (
|
||||
!username || !password || !confirmPassword ||
|
||||
!firstname || !lastname ||
|
||||
!email || !confirmEmail ||
|
||||
!dobMonth || !dobDay || !dobYear ||
|
||||
!zipcode || !state || !area
|
||||
) {
|
||||
setError('All fields are required.');
|
||||
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)) {
|
||||
setError('Enter a valid email address.');
|
||||
return false;
|
||||
@ -171,6 +274,9 @@ const handleSituationConfirm = async () => {
|
||||
setShowPrompt(false);
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -180,12 +286,14 @@ const handleSituationConfirm = async () => {
|
||||
firstname,
|
||||
lastname,
|
||||
email,
|
||||
date_of_birth: dob,
|
||||
zipcode,
|
||||
state,
|
||||
area,
|
||||
phone_e164 : phone,
|
||||
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">
|
||||
{!showCareerSituations ? (
|
||||
<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 && (
|
||||
<div className="mb-4 p-2 text-sm text-red-600 bg-red-100 rounded">
|
||||
{error}
|
||||
@ -298,6 +418,9 @@ return (
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
readOnly={!!(inviteToken || inviteDataProp)}
|
||||
disabled={!!(inviteToken || inviteDataProp)}
|
||||
style={(inviteToken || inviteDataProp) ? { backgroundColor: '#f3f4f6', cursor: 'not-allowed' } : {}}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
@ -305,8 +428,50 @@ return (
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
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 ─────────────── */}
|
||||
<input
|
||||
type="tel"
|
||||
|
||||
@ -44,8 +44,9 @@ function TermsOfService() {
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">2. Eligibility</h2>
|
||||
<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,
|
||||
to use AptivaAI. By using the Service, you represent that you meet this requirement.
|
||||
You must be at least 13 years old to use AptivaAI. If you are under 18, you confirm that you have
|
||||
your parent or guardian's permission to use the Service. By using the Service, you represent that
|
||||
you meet this requirement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2">3. Accounts</h2>
|
||||
|
||||
@ -338,6 +338,26 @@ function UserProfile() {
|
||||
</select>
|
||||
</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 */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
|
||||
97
src/contexts/AdminContext.js
Normal file
97
src/contexts/AdminContext.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user