diff --git a/.env.development b/.env.development deleted file mode 100644 index 06b5011b1..000000000 --- a/.env.development +++ /dev/null @@ -1,2 +0,0 @@ -GITHUB_OAUTH_PROVIDER_ENABLED = "true" -DANGEROUS_ACCOUNT_LINKING_ENABLED = "true" \ No newline at end of file diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 000000000..77fbc984a --- /dev/null +++ b/.env.development.example @@ -0,0 +1,13 @@ +GITHUB_OAUTH_PROVIDER_ENABLED = "true" +DANGEROUS_ACCOUNT_LINKING_ENABLED = "true" +TEACHER_INVITES_ENABLED = "true" + +# Teacher invitation email delivery for local testing +# Replace these with your actual SMTP provider credentials +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_FROM="freeCodeCamp Classroom " +CLASSROOM_APP_BASE_URL=http://localhost:3001 diff --git a/.env.production b/.env.production deleted file mode 100644 index 4f09ad496..000000000 --- a/.env.production +++ /dev/null @@ -1,2 +0,0 @@ -GITHUB_OAUTH_PROVIDER_ENABLED = "false" -DANGEROUS_ACCOUNT_LINKING_ENABLED = "false" \ No newline at end of file diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 000000000..2c87862fe --- /dev/null +++ b/.env.production.example @@ -0,0 +1,13 @@ +GITHUB_OAUTH_PROVIDER_ENABLED = "false" +DANGEROUS_ACCOUNT_LINKING_ENABLED = "false" +TEACHER_INVITES_ENABLED = "true" + +# Teacher invitation email delivery for production +# Replace these with your production SMTP provider credentials +SMTP_HOST=smtp.sendgrid.net +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=apikey +SMTP_PASS=your-sendgrid-api-key +SMTP_FROM="freeCodeCamp Classroom " +CLASSROOM_APP_BASE_URL=https://classroom.freecodecamp.org diff --git a/.env.sample b/.env.sample index 63e0d5ab0..568fe007c 100644 --- a/.env.sample +++ b/.env.sample @@ -20,3 +20,52 @@ GITHUB_SECRET= AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= AUTH0_ISSUER=https://example-tenant.auth0.com + +# Teacher invitation email delivery (REQUIRED for teacher invites to work) +# +# For local development, you can use a service like Gmail (with an app password) or Mailtrap for testing. +# If you are using Gmail, to set up Gmail Credentials: +# 1. Enable 2-Step Verification on your Google account. +# 2. Go to myaccount.google.com/apppasswords +# 3. Create a new app password by writing in a writing in a new app name (use "FCC Classroom") and select Create. +# 4. Google generates a 16-character password. Copy that password (not your Gmail password) +# and use it as the SMTP_PASS in your .env.development file. For example: +# 5. Copy that password (not your Gmail password) +# 6. Update .env.development: +# SMTP_USER=your-email@gmail.com +# SMTP_PASS=xxxx xxxx xxxx xxxx # (the 16-char app password Google generated) + +# For production, you should use a reliable SMTP provider like SendGrid, AWS SES, or similar. + +# Teacher invite rollout flag +# true = enable admin teacher invite UI/API and acceptance flow +# false = disable teacher invite endpoints and hide admin teacher invite panel +TEACHER_INVITES_ENABLED=true + +# LOCAL DEVELOPMENT (.env.development): +# - Use your personal/test email provider (Gmail, Mailtrap, etc.) +# - Set CLASSROOM_APP_BASE_URL to your local URL (http://localhost:3001) +# - Example for Gmail: +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=your-email@gmail.com +# SMTP_PASS=your-app-password (not your Gmail password) +# +# PRODUCTION (.env.production): +# - Use your production SMTP provider (SendGrid, AWS SES, etc.) +# - Set CLASSROOM_APP_BASE_URL to your live URL (https://classroom.freecodecamp.org) +# - Example for SendGrid: +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=apikey +# SMTP_PASS=your-sendgrid-api-key +# +CLASSROOM_APP_BASE_URL=http://localhost:3001 +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM="freeCodeCamp Classroom " diff --git a/.gitignore b/.gitignore index 45e9b9ea8..97dcbaab4 100644 --- a/.gitignore +++ b/.gitignore @@ -128,8 +128,10 @@ yarn-error.log* # local env files .env.local +.env.development .env.development.local .env.test.local +.env.production .env.production.local .env diff --git a/README.md b/README.md index ca1a61db4..6b1e7071d 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,79 @@ In production environments with separate domains (e.g., `classroom.freecodecamp. - No port conflicts occur because domains are different - The port changes in this repository are primarily for local development +### Teacher Invitation Email Setup + +Teacher invitations now send a secure acceptance link by email. Configure these variables in your `.env` file: + +| Variable | Description | Example | +| ------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------- | +| `CLASSROOM_APP_BASE_URL` | Public URL used to build invitation links. Falls back to `NEXTAUTH_URL` when omitted. | `http://localhost:3001` | +| `SMTP_HOST` | SMTP server hostname | `smtp.sendgrid.net` | +| `SMTP_PORT` | SMTP server port | `587` | +| `SMTP_SECURE` | SMTP TLS mode (`true` for implicit TLS, commonly port 465) | `false` | +| `SMTP_USER` | SMTP username (if required by provider) | `apikey` | +| `SMTP_PASS` | SMTP password/API key (if required by provider) | `your-secret` | +| `SMTP_FROM` | Sender identity for invite emails | `freeCodeCamp Classroom ` | +| `TEACHER_INVITES_ENABLED` | Rollout flag for teacher invitation flow (admin panel + APIs + accept endpoint) | `true` | + +**Local Development Setup:** + +Use `.env.development.example` as a template and create your own local `.env.development`. + +In `.env.development`, configure SMTP for testing with your personal email provider: + +If you are setting up your local development with Gmail, you will need to create an App Password for SMTP authentication. + +1. Go to +2. Type in the name of the Application (FCC Classroom) and select create. +3. Google generates a 16-character password +4. Copy that password (not your Gmail password) +5. Update `.env.development` with the generated App Password: + +``` +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +``` + +Do not change the spacing in the generated App Password. It should be entered as a single string with spaces in the `.env` file. +For example, if your generated App Password is `abcd efgh ijkl mnop`, enter it in `.env` as `abcd efgh ijkl mnop` (including the spaces). + +## Testing Notes for Teacher Invitation Flow + +- Use two email accounts when testing the flow: + - one admin account + - one teacher account +- The admin account is the one that must be configured with the SMTP app password. Please see the README.md for Local Development Setup of Teacher Invitation Email Setup. +- Open the teacher account in an incognito browser so there is no sign-in conflict when accepting the invite email. + +**Production Setup:** + +Use `.env.production.example` as a template and create your own local `.env.production`. + +In `.env.production`, configure SMTP for your production email service: + +``` +SMTP_HOST=smtp.sendgrid.net +SMTP_PORT=587 +SMTP_USER=apikey +SMTP_PASS=your-sendgrid-api-key +``` + +**Acceptance Flow:** + +1. Admin creates or resends a teacher invite from the admin dashboard. +2. Classroom sends a secure invitation email with a link in the form `/teacher/invite/`. +3. Invited teacher receives the email and clicks the link. +4. Teacher signs in with the invited email and accepts the invite. +5. The pending invitation is marked as accepted and the teacher role is activated. + +### Teacher Role Assignment + +Manual role assignment is no longer required for teacher onboarding when invites are enabled. +Teachers are promoted to the `TEACHER` role when they accept a valid invitation using the invited email account. + --- ### Join us in our [Discord Chat](https://discord.gg/qcynkd4Edx) here. diff --git a/components/TeacherInvitesPanel.js b/components/TeacherInvitesPanel.js new file mode 100644 index 000000000..e8059a53a --- /dev/null +++ b/components/TeacherInvitesPanel.js @@ -0,0 +1,379 @@ +import { useEffect, useState } from 'react'; +import DisplayNotification from './displayNotification'; +import { ToastContainer } from 'react-toastify'; +import styles from './TeacherInvitesPanel.module.css'; + +const normalizeInvitedEmail = value => value.trim().toLowerCase(); + +const formatDate = value => { + if (!value) { + return 'N/A'; + } + return new Date(value).toLocaleString(); +}; + +export default function TeacherInvitesPanel() { + const [invitedTeacherEmail, setInvitedTeacherEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [invitations, setInvitations] = useState([]); + const [emailQuery, setEmailQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('ACTIVE'); + const [entriesPerPage, setEntriesPerPage] = useState(10); + const [pageIndex, setPageIndex] = useState(0); + + const normalizedQuery = emailQuery.trim().toLowerCase(); + + const visibleInvitations = invitations.filter(invitation => { + const matchesEmail = normalizedQuery + ? invitation.invitedTeacherEmail.toLowerCase().includes(normalizedQuery) + : true; + + const matchesStatus = + statusFilter === 'ALL' + ? true + : statusFilter === 'ACTIVE' + ? invitation.status !== 'ACCEPTED' + : invitation.status === statusFilter; + + return matchesEmail && matchesStatus; + }); + + useEffect(() => { + setPageIndex(0); + }, [emailQuery, statusFilter, entriesPerPage]); + + const totalEntries = visibleInvitations.length; + const paginatedInvitations = visibleInvitations.slice( + pageIndex * entriesPerPage, + (pageIndex + 1) * entriesPerPage + ); + const startEntry = totalEntries === 0 ? 0 : pageIndex * entriesPerPage + 1; + const endEntry = Math.min((pageIndex + 1) * entriesPerPage, totalEntries); + + const baseOptions = [10, 20, 50, 100]; + let entriesPerPageOptions = baseOptions.filter( + option => option < totalEntries + ); + if (totalEntries > 0 && totalEntries < 100) { + entriesPerPageOptions.push(totalEntries); + } + if (entriesPerPageOptions.length === 0) { + entriesPerPageOptions = [10]; + } + + const normalizedEntriesPerPageOptions = [ + ...new Set(entriesPerPageOptions) + ].sort((first, second) => first - second); + + const canPreviousPage = pageIndex > 0; + const canNextPage = endEntry < totalEntries; + + const goToLastPage = () => { + const lastPageIndex = Math.max( + 0, + Math.ceil(totalEntries / entriesPerPage) - 1 + ); + setPageIndex(lastPageIndex); + }; + + const loadInvitations = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/admin/teacher_invites/list?limit=100'); + if (!response.ok) { + DisplayNotification('Error', 'Could not load teacher invitations.'); + return; + } + const data = await response.json(); + setInvitations(data.invitations || []); + } catch { + DisplayNotification('Error', 'Could not load teacher invitations.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadInvitations(); + }, []); + + const createInvitation = async event => { + event.preventDefault(); + const normalizedEmail = normalizeInvitedEmail(invitedTeacherEmail); + + if (!normalizedEmail) { + DisplayNotification('Error', 'Please enter a teacher email.'); + return; + } + + try { + const response = await fetch('/api/admin/teacher_invites/create', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ invitedTeacherEmail: normalizedEmail }) + }); + + if (response.status === 409) { + DisplayNotification( + 'Error', + 'An active invite already exists for this teacher email.' + ); + return; + } + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Failed to create invitation.' + ); + return; + } + + setInvitedTeacherEmail(''); + DisplayNotification('Success', 'Teacher invitation created.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Failed to create invitation.'); + } + }; + + const resendInvitation = async teacherInvitationId => { + try { + const response = await fetch('/api/admin/teacher_invites/resend', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ teacherInvitationId }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Failed to resend invitation.' + ); + return; + } + + DisplayNotification('Success', 'Invitation resent.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Failed to resend invitation.'); + } + }; + + const revokeInvitation = async teacherInvitationId => { + try { + const response = await fetch('/api/admin/teacher_invites/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ teacherInvitationId }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Failed to revoke invitation.' + ); + return; + } + + DisplayNotification('Success', 'Invitation revoked.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Failed to revoke invitation.'); + } + }; + + return ( +
+ + +
+ setInvitedTeacherEmail(event.target.value)} + className={styles.emailInput} + placeholder='teacher@example.org' + required + /> + +
+ +
+ + + Showing {totalEntries} +
+ +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : totalEntries === 0 ? ( + + + + ) : ( + paginatedInvitations.map(invitation => ( + + + + + + + )) + )} + + + + + + +
EmailStatusExpiresActions
+ Loading invitations... +
+ No invitations match the current filters. +
+ {invitation.invitedTeacherEmail} + {invitation.status} + {formatDate(invitation.expiresAt)} + +
+ + +
+
+
+ + + {startEntry}-{endEntry} of {totalEntries} + + + + + +
+
+
+
+ ); +} diff --git a/components/TeacherInvitesPanel.module.css b/components/TeacherInvitesPanel.module.css new file mode 100644 index 000000000..8e6fcf619 --- /dev/null +++ b/components/TeacherInvitesPanel.module.css @@ -0,0 +1,192 @@ +.panel { + margin-top: 1rem; +} + +.form { + margin-top: 1rem; + display: flex; + gap: 0.5rem; +} + +.emailInput { + flex: 1; + min-width: 0; + padding: 0.5rem 0.75rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; +} + +.primaryButton { + flex: 0 0 12rem; + padding: 0.5rem 1rem; + border: 2px solid #000; + border-radius: 0.25rem; + background-color: #feac32; + color: #000; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.controlsRow { + margin-top: 10px; + display: flex; + align-items: center; + justify-content: flex-end; + color: #757575; + gap: 0.5rem; + flex-wrap: wrap; +} + +.controlLabel { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: inherit; + white-space: nowrap; +} + +.filterInput { + min-width: 15rem; + padding: 0.35rem 0.5rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; + font-size: inherit; +} + +.filterSelect { + min-width: 12rem; + padding: 0.35rem 0.5rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; + background-color: #fff; + font-size: inherit; +} + +.tableWrapper { + margin-top: 1rem; + overflow-x: auto; +} + +.table { + width: 100%; + margin: auto; + border-collapse: collapse; + font-family: monospace; + text-align: left; +} + +.headerRow { + background-color: transparent; +} + +.headerCell { + border-bottom: solid 1px #e0e0e0; + color: black; + font-weight: bold; + text-align: left; + padding: 10px; +} + +.row { + border-top: 1px solid #e0e0e0; + border-bottom: 1px solid #e0e0e0; +} + +.cell { + padding: 10px; + text-align: left; +} + +.actionGroup { + display: flex; + gap: 0.5rem; +} + +.secondaryButton { + padding: 0.25rem 0.5rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; + background-color: #fff; + cursor: pointer; +} + +.secondaryButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.footer { + text-align: right; +} + +.paginationContainer { + margin-top: 10px; + display: flex; + align-items: center; + justify-content: flex-end; + color: #757575; +} + +.paginationInfo { + margin-left: 20px; + margin-right: 10px; + color: #757575; +} + +.paginationButton { + margin-left: 10px; + margin-right: 10px; + font-size: 1.5em; + background: none; + border: none; +} + +.paginationButtonDisabled { + color: #d1d1d1; +} + +.paginationButtonEnabled { + color: #757575; + cursor: pointer; +} + +@media (max-width: 640px) { + .form { + flex-direction: column; + } + + .primaryButton { + width: 100%; + } + + .controlsRow { + align-items: stretch; + justify-content: flex-start; + gap: 0.5rem; + } + + .controlLabel { + width: 100%; + justify-content: space-between; + } + + .filterInput, + .filterSelect { + min-width: 0; + width: 100%; + } + + .paginationContainer { + justify-content: flex-start; + } + + .paginationInfo { + margin-left: 0; + } +} diff --git a/package-lock.json b/package-lock.json index c2deb6d09..0620cbaa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", + "nodemailer": "^6.10.1", "react": "^18.2.0", "react-data-table-component": "7.5.3", "react-dom": "^18.2.0", @@ -9193,6 +9194,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", diff --git a/package.json b/package.json index b475a58b8..4ab8cf471 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", + "nodemailer": "^6.10.1", "react": "^18.2.0", "react-data-table-component": "7.5.3", "react-dom": "^18.2.0", diff --git a/pages/admin/index.js b/pages/admin/index.js index 0d9867762..e8fbf09ad 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -5,6 +5,9 @@ import Link from 'next/link'; import { getSession } from 'next-auth/react'; import dynamic from 'next/dynamic'; import redirectUser from '../../util/redirectUser.js'; +import TeacherInvitesPanel from '../../components/TeacherInvitesPanel'; +import { useState } from 'react'; +import { isTeacherInvitesEnabled } from '../../util/featureFlags'; export async function getServerSideProps(ctx) { // Dynamic import to prevent Prisma from being bundled for client @@ -40,7 +43,8 @@ export async function getServerSideProps(ctx) { return { props: { userSession, - users: users + users: users, + teacherInvitesEnabled: isTeacherInvitesEnabled() } }; } @@ -49,6 +53,8 @@ export default function Home(props) { const AdminTable = dynamic(() => import('../../components/adminTable'), { ssr: false }); + const [showTeacherInvites, setShowTeacherInvites] = useState(true); + const [showUserDatabase, setShowUserDatabase] = useState(true); const columns = [ { name: 'Name', @@ -88,7 +94,51 @@ export default function Home(props) { Admin - + {props.teacherInvitesEnabled && ( + <> +
+
+

Teacher Invitations

+ +
+

+ Invite management and invitation history. +

+ {showTeacherInvites && } +
+ +
+ + )} + +
+
+

User Database

+ +
+

+ Current users and role management actions. +

+
+ {showUserDatabase && ( +
+ +
+ )} ); diff --git a/pages/api/admin/teacher_invites/create.js b/pages/api/admin/teacher_invites/create.js new file mode 100644 index 000000000..c4a726b7b --- /dev/null +++ b/pages/api/admin/teacher_invites/create.js @@ -0,0 +1,126 @@ +import prisma from '../../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + isValidEmail, + normalizeEmail, + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; +import { + buildTeacherInviteUrl, + sendTeacherInvitationEmail +} from '../../../../util/inviteEmail'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; + +const INVITE_EXPIRY_DAYS = 7; + +const buildExpiryDate = () => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITE_EXPIRY_DAYS); + return expiresAt; +}; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + if (!isTeacherInvitesEnabled()) { + return res + .status(404) + .json({ error: 'Teacher invites feature is disabled' }); + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['ADMIN'], + roleError: 'Admin role required' + }); + if (!currentUser) { + return; + } + + const invitedTeacherEmail = normalizeEmail(req.body?.invitedTeacherEmail); + + if (!invitedTeacherEmail) { + return res.status(400).json({ error: 'invitedTeacherEmail is required' }); + } + + if (!isValidEmail(invitedTeacherEmail)) { + return res.status(400).json({ error: 'invitedTeacherEmail must be valid' }); + } + + const pendingInvite = await prisma.teacherInvitation.findFirst({ + where: { + invitedTeacherEmail, + status: 'PENDING', + expiresAt: { + gt: new Date() + } + }, + select: { + teacherInvitationId: true, + expiresAt: true + } + }); + + if (pendingInvite) { + return res.status(409).json({ + error: 'Active invitation already exists', + invitation: pendingInvite + }); + } + + const inviteToken = randomBytes(24).toString('hex'); + const createdInvitation = await prisma.teacherInvitation.create({ + data: { + invitedTeacherEmail, + inviteToken, + invitedById: currentUser.id, + status: 'PENDING', + expiresAt: buildExpiryDate() + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + expiresAt: true, + createdAt: true, + inviteToken: true + } + }); + + const inviteUrl = buildTeacherInviteUrl(req, createdInvitation.inviteToken); + + try { + await sendTeacherInvitationEmail({ + req, + invitedTeacherEmail, + inviteUrl, + expiresAt: createdInvitation.expiresAt, + invitedByEmail: currentUser.email + }); + } catch (error) { + console.error('Failed to send teacher invite email', error); + + await prisma.teacherInvitation + .delete({ + where: { + teacherInvitationId: createdInvitation.teacherInvitationId + } + }) + .catch(deleteError => { + console.error( + 'Failed to rollback teacher invitation after email failure', + deleteError + ); + }); + + return res.status(502).json({ + error: `Teacher invitation email could not be sent. ${error?.message || 'Please verify SMTP configuration and try again.'}` + }); + } + + return res.status(201).json({ + invitation: createdInvitation + }); +} diff --git a/pages/api/admin/teacher_invites/list.js b/pages/api/admin/teacher_invites/list.js new file mode 100644 index 000000000..6dc60595f --- /dev/null +++ b/pages/api/admin/teacher_invites/list.js @@ -0,0 +1,71 @@ +import prisma from '../../../../prisma/prisma'; +import { + parsePagination, + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'GET')) { + return; + } + + if (!isTeacherInvitesEnabled()) { + return res + .status(404) + .json({ error: 'Teacher invites feature is disabled' }); + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['ADMIN'], + roleError: 'Admin role required' + }); + if (!currentUser) { + return; + } + + const { limit, offset } = parsePagination(req); + + const [invitations, total] = await Promise.all([ + prisma.teacherInvitation.findMany({ + orderBy: { + createdAt: 'desc' + }, + skip: offset, + take: limit, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + expiresAt: true, + createdAt: true, + updatedAt: true, + invitedBy: { + select: { + id: true, + email: true, + name: true + } + }, + acceptedBy: { + select: { + id: true, + email: true, + name: true + } + } + } + }), + prisma.teacherInvitation.count() + ]); + + return res.status(200).json({ + invitations, + pagination: { + total, + limit, + offset + } + }); +} diff --git a/pages/api/admin/teacher_invites/resend.js b/pages/api/admin/teacher_invites/resend.js new file mode 100644 index 000000000..2cf1a6984 --- /dev/null +++ b/pages/api/admin/teacher_invites/resend.js @@ -0,0 +1,129 @@ +import prisma from '../../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; +import { + buildTeacherInviteUrl, + sendTeacherInvitationEmail +} from '../../../../util/inviteEmail'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; + +const INVITE_EXPIRY_DAYS = 7; + +const buildExpiryDate = () => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITE_EXPIRY_DAYS); + return expiresAt; +}; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + if (!isTeacherInvitesEnabled()) { + return res + .status(404) + .json({ error: 'Teacher invites feature is disabled' }); + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['ADMIN'], + roleError: 'Admin role required' + }); + if (!currentUser) { + return; + } + + const teacherInvitationId = req.body?.teacherInvitationId?.trim(); + if (!teacherInvitationId) { + return res.status(400).json({ error: 'teacherInvitationId is required' }); + } + + const existingInvitation = await prisma.teacherInvitation.findUnique({ + where: { + teacherInvitationId + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + inviteToken: true, + expiresAt: true + } + }); + + if (!existingInvitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + if (existingInvitation.status === 'ACCEPTED') { + return res.status(409).json({ + error: 'Accepted invitations cannot be resent', + status: existingInvitation.status + }); + } + + const refreshedToken = randomBytes(24).toString('hex'); + const refreshedExpiry = buildExpiryDate(); + const resentInvitation = await prisma.teacherInvitation.update({ + where: { + teacherInvitationId + }, + data: { + inviteToken: refreshedToken, + expiresAt: refreshedExpiry, + status: 'PENDING' + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + expiresAt: true, + updatedAt: true, + inviteToken: true + } + }); + + const inviteUrl = buildTeacherInviteUrl(req, resentInvitation.inviteToken); + + try { + await sendTeacherInvitationEmail({ + req, + invitedTeacherEmail: resentInvitation.invitedTeacherEmail, + inviteUrl, + expiresAt: resentInvitation.expiresAt, + invitedByEmail: currentUser.email + }); + } catch (error) { + console.error('Failed to resend teacher invitation email', error); + + await prisma.teacherInvitation + .update({ + where: { + teacherInvitationId + }, + data: { + inviteToken: existingInvitation.inviteToken, + expiresAt: existingInvitation.expiresAt, + status: existingInvitation.status + } + }) + .catch(updateError => { + console.error( + 'Failed to rollback teacher invitation after resend email failure', + updateError + ); + }); + + return res.status(502).json({ + error: `Teacher invitation email could not be resent. ${error?.message || 'Please verify SMTP configuration and try again.'}` + }); + } + + return res.status(200).json({ + invitation: resentInvitation + }); +} diff --git a/pages/api/admin/teacher_invites/revoke.js b/pages/api/admin/teacher_invites/revoke.js new file mode 100644 index 000000000..526633293 --- /dev/null +++ b/pages/api/admin/teacher_invites/revoke.js @@ -0,0 +1,72 @@ +import prisma from '../../../../prisma/prisma'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + if (!isTeacherInvitesEnabled()) { + return res + .status(404) + .json({ error: 'Teacher invites feature is disabled' }); + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['ADMIN'], + roleError: 'Admin role required' + }); + if (!currentUser) { + return; + } + + const teacherInvitationId = req.body?.teacherInvitationId?.trim(); + if (!teacherInvitationId) { + return res.status(400).json({ error: 'teacherInvitationId is required' }); + } + + const existingInvitation = await prisma.teacherInvitation.findUnique({ + where: { + teacherInvitationId + }, + select: { + teacherInvitationId: true, + status: true, + invitedTeacherEmail: true + } + }); + + if (!existingInvitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + if (existingInvitation.status !== 'PENDING') { + return res.status(409).json({ + error: 'This invitation link is no longer active', + status: existingInvitation.status + }); + } + + const revokedInvitation = await prisma.teacherInvitation.update({ + where: { + teacherInvitationId + }, + data: { + status: 'REVOKED' + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + updatedAt: true + } + }); + + return res.status(200).json({ + invitation: revokedInvitation + }); +} diff --git a/pages/api/teacher_invites/accept.js b/pages/api/teacher_invites/accept.js new file mode 100644 index 000000000..d4469d23e --- /dev/null +++ b/pages/api/teacher_invites/accept.js @@ -0,0 +1,143 @@ +import prisma from '../../../prisma/prisma'; +import { + areEquivalentInviteEmails, + normalizeEmail, + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; +import { isTeacherInvitesEnabled } from '../../../util/featureFlags'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + if (!isTeacherInvitesEnabled()) { + return res + .status(404) + .json({ error: 'Teacher invites feature is disabled' }); + } + + const inviteToken = req.body?.inviteToken?.trim(); + if (!inviteToken) { + return res.status(400).json({ error: 'inviteToken is required' }); + } + + const currentUser = await requireAuthenticatedUser(req, res); + if (!currentUser) { + return; + } + + const sessionEmail = normalizeEmail(currentUser.email); + + if (!sessionEmail) { + return res.status(403).json({ error: 'User email missing' }); + } + + const invitation = await prisma.teacherInvitation.findUnique({ + where: { + inviteToken + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + acceptedById: true, + expiresAt: true + } + }); + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + if ( + !areEquivalentInviteEmails(invitation.invitedTeacherEmail, sessionEmail) + ) { + return res.status(403).json({ error: 'Invitation email mismatch' }); + } + + if (invitation.status === 'ACCEPTED') { + if (invitation.acceptedById && invitation.acceptedById !== currentUser.id) { + return res + .status(409) + .json({ error: 'Invitation accepted by another user' }); + } + + return res.status(200).json({ + invitation: { + teacherInvitationId: invitation.teacherInvitationId, + status: invitation.status, + expiresAt: invitation.expiresAt + }, + user: { + id: currentUser.id, + role: currentUser.role + }, + alreadyAccepted: true + }); + } + + if (invitation.status !== 'PENDING') { + return res.status(409).json({ + error: 'Only pending invitations can be accepted', + status: invitation.status + }); + } + + if (new Date(invitation.expiresAt) <= new Date()) { + await prisma.teacherInvitation.update({ + where: { + teacherInvitationId: invitation.teacherInvitationId + }, + data: { + status: 'EXPIRED' + } + }); + + return res.status(410).json({ + error: 'Invitation expired', + status: 'EXPIRED' + }); + } + + const targetRole = currentUser.role === 'ADMIN' ? 'ADMIN' : 'TEACHER'; + + const [updatedInvitation, updatedUser] = await prisma.$transaction([ + prisma.teacherInvitation.update({ + where: { + teacherInvitationId: invitation.teacherInvitationId + }, + data: { + status: 'ACCEPTED', + acceptedById: currentUser.id + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + expiresAt: true, + updatedAt: true + } + }), + prisma.user.update({ + where: { + id: currentUser.id + }, + data: { + role: targetRole + }, + select: { + id: true, + role: true, + email: true + } + }) + ]); + + return res.status(200).json({ + invitation: updatedInvitation, + user: updatedUser, + alreadyAccepted: false + }); +} diff --git a/pages/error.js b/pages/error.js index 1b54456b4..8579d2f6d 100644 --- a/pages/error.js +++ b/pages/error.js @@ -1,15 +1,185 @@ import Navbar from '../components/navbar'; -export default function error() { +import Link from 'next/link'; +import { getSession } from 'next-auth/react'; + +const getGuidance = ({ hasSession, hasUser, role, inviteStatus, reason }) => { + if (!hasSession) { + return { + heading: 'Please Sign In', + body: 'You must sign in before accessing protected pages.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (!hasUser) { + return { + heading: 'Account Not Found', + body: 'Your account could not be located. Please contact your administrator.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (role === 'ADMIN') { + return { + heading: 'Admin Access Route', + body: 'Your account is an admin account. Use the admin dashboard to manage access and invitations.', + actionLabel: 'Open Admin Dashboard', + actionHref: '/admin' + }; + } + + if (role === 'TEACHER') { + return { + heading: 'Teacher Access Route', + body: 'Your account is a teacher account. Continue from your classes page.', + actionLabel: 'Open Classes', + actionHref: '/classes' + }; + } + + if (role === 'STUDENT') { + return { + heading: 'Student Access', + body: 'This area is reserved for teachers and admins. Ask your teacher for the student join link or class code.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (inviteStatus === 'PENDING') { + return { + heading: 'Teacher Invitation Found', + body: 'A teacher invitation exists for your email. Ask your administrator to share or resend your invite link so you can accept it.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (inviteStatus === 'EXPIRED') { + return { + heading: 'Teacher Invitation Expired', + body: 'Your teacher invitation has expired. Ask your administrator to resend a new invitation.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (inviteStatus === 'REVOKED') { + return { + heading: 'Teacher Invitation Revoked', + body: 'Your previous teacher invitation was revoked. Contact your administrator if this needs to be restored.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + if (reason === 'role-required') { + return { + heading: 'Role Required', + body: 'Your account does not currently have the required role for this page. Contact your administrator for access.', + actionLabel: 'Go to Home', + actionHref: '/' + }; + } + + return { + heading: 'Access Denied', + body: 'Please contact your administrator if you believe this is incorrect.', + actionLabel: 'Go to Home', + actionHref: '/' + }; +}; + +export async function getServerSideProps(ctx) { + // Dynamic import to prevent Prisma from being bundled for client + const { default: prisma } = await import('../prisma/prisma'); + + const userSession = await getSession(ctx); + const reason = ctx?.query?.reason || null; + + if (!userSession?.user?.email) { + return { + props: { + hasSession: false, + hasUser: false, + role: null, + inviteStatus: null, + reason + } + }; + } + + const user = await prisma.user.findUnique({ + where: { + email: userSession.user.email + }, + select: { + role: true, + email: true + } + }); + + if (!user?.email) { + return { + props: { + hasSession: true, + hasUser: false, + role: null, + inviteStatus: null, + reason + } + }; + } + + const latestInvite = await prisma.teacherInvitation.findFirst({ + where: { + invitedTeacherEmail: { + equals: user.email, + mode: 'insensitive' + } + }, + orderBy: { + createdAt: 'desc' + }, + select: { + status: true + } + }); + + return { + props: { + hasSession: true, + hasUser: true, + role: user.role, + inviteStatus: latestInvite?.status || null, + reason + } + }; +} + +export default function ErrorPage(props) { + const guidance = getGuidance(props); + return ( <>

- Access Denied + {guidance.heading}

-

- Please contact your administrator if you believe this is incorrect. -

+
+

{guidance.body}

+
+ + {guidance.actionLabel} + +
+
); } diff --git a/pages/teacher/invite/TeacherInviteAccept.module.css b/pages/teacher/invite/TeacherInviteAccept.module.css new file mode 100644 index 000000000..2b376308c --- /dev/null +++ b/pages/teacher/invite/TeacherInviteAccept.module.css @@ -0,0 +1,194 @@ +.page { + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(254, 204, 76, 0.18), transparent 28%), + linear-gradient(180deg, #f5f6f7 0%, #ffffff 100%); + color: #0a0a23; +} + +.shell { + width: 100%; + max-width: 720px; + margin: 0 auto; + padding: 64px 20px; +} + +.card { + background: #ffffff; + border: 1px solid #d0d0d5; + box-shadow: 0 18px 50px rgba(10, 10, 35, 0.08); +} + +.header { + padding: 24px 32px; + background: #0a0a23; + color: #ffffff; +} + +.eyebrow { + margin: 0 0 8px; + font-size: clamp(2rem, 4vw, 2.75rem); + line-height: 1.15; + font-weight: 700; + letter-spacing: 0; + text-transform: none; + color: #ffffff; +} + +.title { + margin: 0 0 10px; + font-size: clamp(1.25rem, 2.2vw, 1.5rem); + line-height: 1.25; + color: rgba(255, 255, 255, 0.92); + font-weight: 600; +} + +.subtitle { + margin: 0; + font-size: 1rem; + line-height: 1.7; + color: rgba(255, 255, 255, 0.9); +} + +.body { + padding: 32px; +} + +.statusCard { + margin-bottom: 24px; + padding: 16px 18px; + border-left: 4px solid #0a0a23; + background: #f5f6f7; + color: #1b1b32; +} + +.statusCard p { + margin: 0; +} + +.errorCard { + margin-bottom: 24px; + padding: 16px 18px; + border-left: 4px solid #d4351c; + background: #fff1f0; + color: #7f1d1d; +} + +.copy { + margin: 0 0 20px; + line-height: 1.7; + color: #1b1b32; +} + +.infoPanel { + margin: 0 0 28px; + padding: 20px; + background: #f5f6f7; + border: 1px solid #d0d0d5; +} + +.infoPanelTitle { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 700; +} + +.infoPanel p { + margin: 0 0 12px; + line-height: 1.7; + color: #3b3b4f; +} + +.linkRow { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.inlineLink { + color: #0a0a23; + text-decoration: underline; + text-underline-offset: 2px; + font-weight: 600; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.primaryButton, +.secondaryButton, +.continueLink { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 0 20px; + font-size: 1rem; + font-weight: 700; + text-decoration: none; + transition: + transform 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease, + color 120ms ease; +} + +.primaryButton { + border: 2px solid #0a0a23; + background: #fecc4c; + color: #0a0a23; + cursor: pointer; +} + +.primaryButton:hover:not(:disabled), +.secondaryButton:hover, +.continueLink:hover { + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(10, 10, 35, 0.12); +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.secondaryButton, +.continueLink { + border: 2px solid #0a0a23; + background: #ffffff; + color: #0a0a23; +} + +.successTitle { + margin: 0 0 16px; + color: #0a7f52; + font-size: clamp(2rem, 4vw, 2.5rem); + line-height: 1.15; +} + +.successNote { + margin: 20px 0 24px; + line-height: 1.7; + color: #3b3b4f; +} + +@media (max-width: 640px) { + .shell { + padding: 32px 16px; + } + + .header, + .body { + padding: 24px 20px; + } + + .primaryButton, + .secondaryButton, + .continueLink { + width: 100%; + } +} diff --git a/pages/teacher/invite/[inviteToken].js b/pages/teacher/invite/[inviteToken].js new file mode 100644 index 000000000..d0dfa3137 --- /dev/null +++ b/pages/teacher/invite/[inviteToken].js @@ -0,0 +1,244 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { getSession, signIn } from 'next-auth/react'; +import styles from './TeacherInviteAccept.module.css'; + +export async function getServerSideProps(ctx) { + const { inviteToken } = ctx.params; + const userSession = await getSession(ctx); + + return { + props: { + inviteToken, + userSession: userSession || null + } + }; +} + +export default function TeacherInviteAccept({ inviteToken, userSession }) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [redirectPath, setRedirectPath] = useState('/classes'); + + const handleAcceptInvite = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/teacher_invites/accept', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + inviteToken + }) + }); + + const data = await response.json(); + + if (!response.ok) { + // Map specific error cases + if (data.error === 'Invitation expired') { + setError( + 'This invitation has expired. Please ask your admin for a new one.' + ); + } else if (data.error === 'Invitation revoked') { + setError( + 'This invitation has been revoked. Please contact your admin.' + ); + } else if (data.error?.includes('email mismatch')) { + setError( + 'This invitation is for a different email address. Please sign in with the correct email.' + ); + } else { + setError(data.error || 'Failed to accept invitation'); + } + return; + } + + const nextPath = data?.user?.role === 'ADMIN' ? '/admin' : '/classes'; + setRedirectPath(nextPath); + setSuccess(true); + // Redirect after success + setTimeout(() => { + router.push(nextPath); + }, 2000); + } catch (err) { + setError('An error occurred. Please try again.'); + console.error('Error accepting invite:', err); + } finally { + setLoading(false); + } + }; + + const handleSignIn = async () => { + await signIn('auth0'); + }; + + const successDestinationLabel = + redirectPath === '/admin' + ? 'Continue to freeCodeCamp Classroom ->' + : 'Continue to freeCodeCamp Classroom ->'; + + return ( + <> + + Accept Teacher Invitation - freeCodeCamp Classroom + + + +
+
+
+
+

freeCodeCamp Classroom

+

Teacher Invitation

+

+ Accept your teacher invitation and continue into the Classroom + experience with your invited freeCodeCamp account. +

+
+ +
+ {!userSession ? ( + <> +

+ You've been invited to join freeCodeCamp Classroom as a + teacher. To accept this invitation, sign in through Auth0 + using the email address that received the invitation. +

+
+

+ What is freeCodeCamp Classroom? +

+

+ Classroom helps teachers organize student learning on top + of the freeCodeCamp curriculum, certifications, and + progress tracking tools. +

+ +
+ + + ) : success ? ( + <> +

+ ✓ Invitation Accepted! +

+

+ Welcome to freeCodeCamp Classroom. You'll be redirected + shortly to the site. If that does not happen, use the link + below. +

+ + {successDestinationLabel} + + + ) : ( + <> +
+

+ Signed in as: {userSession.user.email} +

+
+ + {error &&
{error}
} + + {!error && ( + <> +

+ Click the button below to accept your invitation and + become a teacher. +

+
+

+ About freeCodeCamp Classroom +

+

+ Classroom gives teachers a focused dashboard for + managing classes built around the freeCodeCamp + learning platform. +

+

+ You can explore the wider freeCodeCamp platform and + curriculum here before continuing. +

+ +
+ + )} + +
+ + + {error && ( + + )} +
+ + )} +
+
+
+
+ + ); +} diff --git a/prisma/migrations/20260409192210_add_invitation_models/migration.sql b/prisma/migrations/20260409192210_add_invitation_models/migration.sql new file mode 100644 index 000000000..2b3e7ec94 --- /dev/null +++ b/prisma/migrations/20260409192210_add_invitation_models/migration.sql @@ -0,0 +1,69 @@ +-- CreateEnum +CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "TeacherInvitation" ( + "teacherInvitationId" TEXT NOT NULL, + "invitedTeacherEmail" TEXT NOT NULL, + "status" "InvitationStatus" NOT NULL DEFAULT 'PENDING', + "inviteToken" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "acceptedById" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeacherInvitation_pkey" PRIMARY KEY ("teacherInvitationId") +); + +-- CreateTable +CREATE TABLE "StudentInvitation" ( + "studentInvitationId" TEXT NOT NULL, + "invitedStudentEmail" TEXT NOT NULL, + "status" "InvitationStatus" NOT NULL DEFAULT 'PENDING', + "inviteToken" TEXT NOT NULL, + "classroomId" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "acceptedById" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StudentInvitation_pkey" PRIMARY KEY ("studentInvitationId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TeacherInvitation_inviteToken_key" ON "TeacherInvitation"("inviteToken"); + +-- CreateIndex +CREATE INDEX "TeacherInvitation_invitedTeacherEmail_status_idx" ON "TeacherInvitation"("invitedTeacherEmail", "status"); + +-- CreateIndex +CREATE INDEX "TeacherInvitation_expiresAt_idx" ON "TeacherInvitation"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "StudentInvitation_inviteToken_key" ON "StudentInvitation"("inviteToken"); + +-- CreateIndex +CREATE INDEX "StudentInvitation_classroomId_status_idx" ON "StudentInvitation"("classroomId", "status"); + +-- CreateIndex +CREATE INDEX "StudentInvitation_invitedStudentEmail_status_idx" ON "StudentInvitation"("invitedStudentEmail", "status"); + +-- CreateIndex +CREATE INDEX "StudentInvitation_expiresAt_idx" ON "StudentInvitation"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "TeacherInvitation" ADD CONSTRAINT "TeacherInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeacherInvitation" ADD CONSTRAINT "TeacherInvitation_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudentInvitation" ADD CONSTRAINT "StudentInvitation_classroomId_fkey" FOREIGN KEY ("classroomId") REFERENCES "Classroom"("classroomId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudentInvitation" ADD CONSTRAINT "StudentInvitation_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudentInvitation" ADD CONSTRAINT "StudentInvitation_acceptedById_fkey" FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9847c45ae..e5b4a2261 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,29 +11,30 @@ datasource db { } model Classroom { - classroomId String @id @default(cuid()) - classroomName String - fccUserIds String[] - description String? - fccCertifications String[] + classroomId String @id @default(cuid()) + classroomName String + fccUserIds String[] + description String? + fccCertifications String[] classroomTeacherId String - User User @relation(fields: [classroomTeacherId], references: [id]) - createdAt DateTime @default(now()) -} + User User @relation(fields: [classroomTeacherId], references: [id]) + studentInvitations StudentInvitation[] + createdAt DateTime @default(now()) +} model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? refresh_token_expires_in Int? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -50,16 +51,65 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - role String @default("NONE") - fccProperUserId String? @unique - Classroom Classroom[] - accounts Account[] - sessions Session[] + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + role String @default("NONE") + fccProperUserId String? @unique + Classroom Classroom[] + teacherInvitationsSent TeacherInvitation[] @relation("TeacherInvitedBy") + teacherInvitationsAccepted TeacherInvitation[] @relation("TeacherAcceptedBy") + studentInvitationsSent StudentInvitation[] @relation("StudentInvitedBy") + studentInvitationsAccepted StudentInvitation[] @relation("StudentAcceptedBy") + accounts Account[] + sessions Session[] +} + +enum InvitationStatus { + PENDING + ACCEPTED + EXPIRED + REVOKED + CANCELLED +} + +model TeacherInvitation { + teacherInvitationId String @id @default(cuid()) + invitedTeacherEmail String + status InvitationStatus @default(PENDING) + inviteToken String @unique + invitedById String + invitedBy User @relation("TeacherInvitedBy", fields: [invitedById], references: [id]) + acceptedById String? + acceptedBy User? @relation("TeacherAcceptedBy", fields: [acceptedById], references: [id]) + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([invitedTeacherEmail, status]) + @@index([expiresAt]) +} + +model StudentInvitation { + studentInvitationId String @id @default(cuid()) + invitedStudentEmail String + status InvitationStatus @default(PENDING) + inviteToken String @unique + classroomId String + classroom Classroom @relation(fields: [classroomId], references: [classroomId], onDelete: Cascade) + invitedById String + invitedBy User @relation("StudentInvitedBy", fields: [invitedById], references: [id]) + acceptedById String? + acceptedBy User? @relation("StudentAcceptedBy", fields: [acceptedById], references: [id]) + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([classroomId, status]) + @@index([invitedStudentEmail, status]) + @@index([expiresAt]) } model VerificationToken { @@ -68,4 +118,4 @@ model VerificationToken { expires DateTime @@unique([identifier, token]) -} \ No newline at end of file +} diff --git a/styles/Home.module.css b/styles/Home.module.css index 69dd3d1db..866364133 100644 --- a/styles/Home.module.css +++ b/styles/Home.module.css @@ -132,3 +132,47 @@ align-items: center; justify-content: center; } + +.sectionContainer { + margin: 1.5rem 1.5rem 0; +} + +.sectionHeaderRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.sectionTitle { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #0a0a23; +} + +.sectionSubtitle { + margin-top: 0.4rem; + margin-bottom: 0; + font-size: 0.9rem; + color: #4b5563; +} + +.sectionToggleButton { + padding: 0.35rem 0.75rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; + background-color: #fff; + cursor: pointer; +} + +.sectionDivider { + margin: 1.5rem; + border: 0; + border-top: 1px solid #d1d5db; +} + +.databaseTableShell { + margin: 1rem 1.5rem 0; + overflow-x: auto; +} diff --git a/util/featureFlags.js b/util/featureFlags.js new file mode 100644 index 000000000..1f5739308 --- /dev/null +++ b/util/featureFlags.js @@ -0,0 +1,12 @@ +/** + * Feature flag helpers for gradual rollout of features + */ + +export function isTeacherInvitesEnabled() { + // Default to enabled if env var not set + const envValue = process.env.TEACHER_INVITES_ENABLED; + if (envValue === undefined) { + return true; + } + return envValue === 'true'; +} diff --git a/util/inviteApiUtils.js b/util/inviteApiUtils.js new file mode 100644 index 000000000..954a391e4 --- /dev/null +++ b/util/inviteApiUtils.js @@ -0,0 +1,91 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '../pages/api/auth/[...nextauth]'; +import prisma from '../prisma/prisma'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const requireMethod = (req, res, method) => { + if (req.method !== method) { + res.status(405).json({ error: 'Method not allowed' }); + return false; + } + return true; +}; + +export const normalizeEmail = email => email?.trim().toLowerCase(); + +const normalizeGmailLocalPart = localPart => { + const withoutPlus = localPart.split('+')[0]; + return withoutPlus.replace(/\./g, ''); +}; + +const normalizeInviteComparableEmail = email => { + const normalized = normalizeEmail(email); + if (!normalized || !normalized.includes('@')) { + return normalized; + } + + const [localPart, domain] = normalized.split('@'); + + if (domain === 'gmail.com' || domain === 'googlemail.com') { + return `${normalizeGmailLocalPart(localPart)}@gmail.com`; + } + + return normalized; +}; + +export const areEquivalentInviteEmails = (firstEmail, secondEmail) => { + return ( + normalizeInviteComparableEmail(firstEmail) === + normalizeInviteComparableEmail(secondEmail) + ); +}; + +export const isValidEmail = email => EMAIL_PATTERN.test(email || ''); + +export const parsePagination = (req, defaultLimit = 50, maxLimit = 200) => { + const parsedLimit = Number.parseInt(req.query.limit, 10); + const parsedOffset = Number.parseInt(req.query.offset, 10); + + const limit = Number.isNaN(parsedLimit) + ? defaultLimit + : Math.min(Math.max(parsedLimit, 1), maxLimit); + const offset = Number.isNaN(parsedOffset) ? 0 : Math.max(parsedOffset, 0); + + return { limit, offset }; +}; + +export const requireAuthenticatedUser = async ( + req, + res, + { allowedRoles = null, roleError = 'Insufficient role' } = {} +) => { + const session = await getServerSession(req, res, authOptions); + if (!session?.user?.email) { + res.status(403).json({ error: 'Not authenticated' }); + return null; + } + + const user = await prisma.user.findUnique({ + where: { + email: session.user.email + }, + select: { + id: true, + role: true, + email: true + } + }); + + if (!user) { + res.status(403).json({ error: 'User not found' }); + return null; + } + + if (allowedRoles && !allowedRoles.includes(user.role)) { + res.status(403).json({ error: roleError }); + return null; + } + + return user; +}; diff --git a/util/inviteEmail.js b/util/inviteEmail.js new file mode 100644 index 000000000..e71e8b25e --- /dev/null +++ b/util/inviteEmail.js @@ -0,0 +1,187 @@ +import nodemailer from 'nodemailer'; + +/** + * Build the teacher invite URL + * @param {object|string} reqOrToken - Next.js request object or invite token + * @param {string} maybeInviteToken - The invitation token when request is provided + * @returns {string} The full invitation URL + */ +export function buildTeacherInviteUrl(reqOrToken, maybeInviteToken) { + const inviteToken = + typeof reqOrToken === 'string' ? reqOrToken : maybeInviteToken; + + const hostFromHeader = + typeof reqOrToken === 'object' && reqOrToken?.headers?.host + ? `${reqOrToken.headers['x-forwarded-proto'] || 'http'}://${reqOrToken.headers.host}` + : null; + + const baseUrl = + process.env.CLASSROOM_APP_BASE_URL || + process.env.NEXTAUTH_URL || + hostFromHeader || + 'http://localhost:3001'; + + return `${baseUrl}/teacher/invite/${inviteToken}`; +} + +/** + * Create an SMTP transporter for sending emails + * @returns {object} Nodemailer transporter or null if config is missing + */ +function createTransporter() { + const host = process.env.SMTP_HOST; + const port = process.env.SMTP_PORT; + const secure = process.env.SMTP_SECURE === 'true'; + const user = process.env.SMTP_USER; + const pass = process.env.SMTP_PASS; + + // Check if essential config is present + if (!host || !port) { + return null; + } + + return nodemailer.createTransport({ + host, + port: parseInt(port), + secure, + auth: { + user, + pass + } + }); +} + +/** + * Get the sender email address, with fallback if SMTP_FROM is not set + * @returns {string} The sender email address + */ +function getSenderEmail() { + if (process.env.SMTP_FROM) { + return process.env.SMTP_FROM; + } + // Fallback: use SMTP_USER if available + if (process.env.SMTP_USER) { + return process.env.SMTP_USER; + } + // Last resort + return 'noreply@classroom.freecodecamp.org'; +} + +/** + * Send a teacher invitation email + * @param {object} params + * @param {string} params.invitedTeacherEmail - The teacher's email address + * @param {string} [params.inviteUrl] - Precomputed invite URL + * @param {Date} [params.expiresAt] - Expiry date for invite + * @param {string} [params.invitedByEmail] - Email of inviter + * @returns {Promise} Result including messageId + */ +export async function sendTeacherInvitationEmail({ + invitedTeacherEmail, + inviteUrl, + expiresAt, + invitedByEmail +}) { + if (!invitedTeacherEmail) { + throw new Error('No recipient email provided'); + } + + const transporter = createTransporter(); + if (!transporter) { + throw new Error('SMTP configuration is missing or invalid'); + } + + const resolvedInviteUrl = inviteUrl; + if (!resolvedInviteUrl) { + throw new Error('Invite URL is required to send invitation email'); + } + const senderEmail = getSenderEmail(); + const inviterLabel = invitedByEmail || 'a freeCodeCamp admin'; + const expiryText = expiresAt + ? new Date(expiresAt).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }) + : '7 days from now'; + + const mailOptions = { + from: senderEmail, + to: invitedTeacherEmail, + subject: 'Join freeCodeCamp Classroom as a Teacher', + text: `Hi there, + +freeCodeCamp Classroom + +Teacher Invitation + +You have been invited to join freeCodeCamp Classroom as a teacher. + +This invitation expires on ${expiryText}. + +Accept Invitation +${resolvedInviteUrl} + +Selecting Accept Invitation will redirect you to the freeCodeCamp Classroom application. + +You will need to sign in through Auth0 with your invited email address to access Classroom. + +If the button does not work, use this secure link: +${resolvedInviteUrl} + +This invitation was sent by ${inviterLabel}. + +If you were not expecting this invitation, you can safely ignore this email. + +The freeCodeCamp Team`, + html: ` + + + + + + + +
+
+

freeCodeCamp Classroom

+

Teacher Invitation

+
+ +
+

You have been invited to join freeCodeCamp Classroom as a teacher.

+

This invitation expires on ${expiryText}.

+ + + +

Selecting Accept Invitation will redirect you to the freeCodeCamp Classroom application.

+

You will need to sign in through Auth0 with your invited email address to access Classroom.

+ +

If the button does not work, use this secure link:

+

${resolvedInviteUrl}

+ +

This invitation was sent by ${inviterLabel}.

+

If you were not expecting this invitation, you can safely ignore this email.

+
+
+ + + ` + }; + + try { + const result = await transporter.sendMail(mailOptions); + return { + success: true, + messageId: result.messageId + }; + } catch (error) { + console.error('Error sending teacher invitation email:', error); + throw error; + } +}