From ade078cbab1eff64fbaaa7948e97fff29c6e8da4 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Thu, 9 Apr 2026 19:33:50 -0700 Subject: [PATCH 01/13] feat: add teacher onboarding MVP updates --- TEACHER_ONBOARDING_TODO.md | 62 ++ ZZZ_Notes.md | 564 ++++++++++++++++++ components/ClassInviteTable.js | 2 + components/StudentInvitesPanel.js | 228 +++++++ components/StudentInvitesPanel.module.css | 101 ++++ components/TeacherInvitesPanel.js | 220 +++++++ components/TeacherInvitesPanel.module.css | 95 +++ pages/admin/index.js | 2 + pages/api/admin/teacher_invites/create.js | 84 +++ pages/api/admin/teacher_invites/list.js | 64 ++ pages/api/admin/teacher_invites/resend.js | 79 +++ pages/api/admin/teacher_invites/revoke.js | 65 ++ pages/api/student_invites/create.js | 113 ++++ pages/api/student_invites/list.js | 85 +++ pages/api/student_invites/resend.js | 92 +++ pages/api/student_invites/revoke.js | 75 +++ pages/api/teacher_invites/accept.js | 133 +++++ pages/error.js | 179 +++++- .../migration.sql | 69 +++ prisma/schema.prisma | 112 +++- util/inviteApiUtils.js | 64 ++ 21 files changed, 2452 insertions(+), 36 deletions(-) create mode 100644 TEACHER_ONBOARDING_TODO.md create mode 100644 ZZZ_Notes.md create mode 100644 components/StudentInvitesPanel.js create mode 100644 components/StudentInvitesPanel.module.css create mode 100644 components/TeacherInvitesPanel.js create mode 100644 components/TeacherInvitesPanel.module.css create mode 100644 pages/api/admin/teacher_invites/create.js create mode 100644 pages/api/admin/teacher_invites/list.js create mode 100644 pages/api/admin/teacher_invites/resend.js create mode 100644 pages/api/admin/teacher_invites/revoke.js create mode 100644 pages/api/student_invites/create.js create mode 100644 pages/api/student_invites/list.js create mode 100644 pages/api/student_invites/resend.js create mode 100644 pages/api/student_invites/revoke.js create mode 100644 pages/api/teacher_invites/accept.js create mode 100644 prisma/migrations/20260409192210_add_invitation_models/migration.sql create mode 100644 util/inviteApiUtils.js diff --git a/TEACHER_ONBOARDING_TODO.md b/TEACHER_ONBOARDING_TODO.md new file mode 100644 index 000000000..7fbd6ac0a --- /dev/null +++ b/TEACHER_ONBOARDING_TODO.md @@ -0,0 +1,62 @@ +# Teacher Onboarding MVP TODO + +## Goals + +- [ ] Admin invite-only teacher onboarding +- [ ] Student invite by join link/code +- [ ] Student invite by email +- [ ] Teacher resend/reinvite support + +## Planning + +- [ ] Finalize invitation lifecycle states +- [ ] Confirm acceptance flow for invites +- [ ] Lock MVP boundaries and non-goals + +## Data Model + +- [x] Add TeacherInvitation model +- [x] Add StudentInvitation model +- [x] Add indexes and uniqueness constraints +- [x] Generate migration for invitation models + +## API + +- [x] Admin teacher invite APIs: resend +- [x] Admin teacher invite API: create +- [x] Admin teacher invite API: list +- [x] Admin teacher invite API: revoke +- [x] Teacher student invite APIs: create/list/resend/revoke +- [x] Teacher student invite API: create +- [x] Teacher student invite API: list +- [x] Teacher student invite API: resend +- [x] Teacher student invite API: revoke +- [x] Invite acceptance handling +- [x] Authorization and validation hardening + +## UI + +- [x] Admin teacher invites panel +- [x] Teacher student invites panel in classes +- [x] Role-aware access denied guidance +- [x] Invite status and action feedback + +## Email + +- [ ] Select/send provider integration strategy +- [ ] Teacher and student invite templates +- [ ] Resend/retry behavior +- [ ] Expiry handling messaging + +## Testing + +- [ ] Unit tests for state transitions +- [ ] API tests for auth + error paths +- [ ] Integration test for admin->teacher flow +- [ ] Integration test for teacher->student flow + +## Rollout + +- [ ] Feature flag teacher invites +- [ ] Feature flag student email invites +- [ ] Update setup docs to remove manual role assignment diff --git a/ZZZ_Notes.md b/ZZZ_Notes.md new file mode 100644 index 000000000..43a3bff85 --- /dev/null +++ b/ZZZ_Notes.md @@ -0,0 +1,564 @@ +- make sure there is a confirmation step before sending or resending invites, to prevent accidental clicks +- consider adding a bulk invite option for teachers to upload a CSV of student emails for larger classes +- add clear messaging about invite expiry and how to get a new invite if the old one expires +- ensure that the UI for managing invites is mobile-friendly, as many teachers may access it from tablets or phones +- consider adding a way for teachers to see which students have accepted their invites and which ones are still pending, to help with class management +- add logging and monitoring around the invite APIs to track usage and identify any issues with invite delivery +- make sure there is a confirmation step before revoking invites, to prevent accidental revocations +- make sure there is a confirmation step before removing students from a class, to prevent accidental removals. likely a reinput of the student's email to confirm the action. + +**Plan: Teacher Onboarding MVP Proposal** + +Recommended approach: ship an admin-invite teacher flow first, and keep student onboarding dual-path (join link/code plus email invites with resend). This matches your preferred scope and fits the current architecture with minimal disruption. + +_What similar products do:_ + +- Google Classroom uses a teacher verification/permission model controlled by admins, then enables class creation only for verified teachers. +- Google Classroom supports multiple student invite channels in parallel: invite link, class code, and email invite. +- Google Classroom supports co-teacher invitation with explicit role boundaries and invitation acceptance. +- ClassDojo emphasizes very low-friction teacher start and family communication onboarding, but less visible role-governance detail in public docs. +- Common LMS pattern across major tools: role gating first, then invite lifecycle (pending, accepted, expired/revoked), then classroom operations. + +_Primary references I used:_ + +1. https://support.google.com/edu/classroom/answer/6071551 +2. https://support.google.com/edu/classroom/answer/6020282 +3. https://support.google.com/edu/classroom/answer/6190760 +4. https://support.google.com/edu/classroom/answer/6020273 +5. https://www.classdojo.com/teachers/ + +_Scannable implementation plan_ + +1. Define final MVP boundaries and user journeys for Admin, Invited Teacher, and Student. +2. Add teacher invitation lifecycle model (PENDING, ACCEPTED, EXPIRED, REVOKED) with inviter and expiry. +3. On sign-in, if email matches active teacher invite, promote user to TEACHER and mark invite accepted. +4. Add student invitation lifecycle per classroom (PENDING, ACCEPTED, EXPIRED, CANCELLED). +5. Keep current student join-code flow as fallback and parity path. +6. Add teacher email invite, resend, and revoke for students. +7. Add admin APIs for teacher invites: create, list, resend, revoke. +8. Add teacher APIs for student invites: create/list/resend/revoke. +9. Add role-aware onboarding UI: + - Admin view gets Teacher Invitations panel. + - Teacher classes view gets Student Invitations panel. + - Access denied page becomes guidance-driven instead of generic. +10. Add migration and rollout safeguards, then test invitation state transitions, auth rules, and end-to-end flows. + +_Key anchors in this codebase:_ + +1. pages/api/auth/[...nextauth].js +2. index.js +3. pages/admin/actions/[id].js +4. adminTable.js +5. updateUserForm.js +6. index.js +7. ClassInviteTable.js +8. pages/join/[...joinCode].js +9. student_email_join.js +10. create_class_teacher.js +11. error.js +12. schema.prisma +13. redirectUser.js +14. codespace-instructions.sh + +_Decisions captured from you:_ + +1. Teacher onboarding is invite-only by admin for MVP. +2. Students can be invited via join link/code and email. +3. Teachers need resend/reinvite capability for student invitations. +4. This phase is MVP proposal only, not full long-term architecture. + +**schema.prisma changes:** + +1. Classroom now knows about student invites + +Added a relation field on the Classroom model: +studentInvitations StudentInvitation[] +What this does: + +One classroom can have many pending/accepted student invitations. +This lets you show invitation history/status per class later in the UI. + +2. User now tracks who sent/accepted invites + +Added four relation fields on User: +teacherInvitationsSent +teacherInvitationsAccepted +studentInvitationsSent +studentInvitationsAccepted + +What this does: + +You can answer questions like: +Which admin invited this teacher? +Which user accepted this invite? +Which teacher sent these student invites? + +Why the relation names matter: + +Prisma needs explicit relation names when the same two models are connected in multiple ways (for example invitedBy vs acceptedBy). + +_Note: A new migration was generated for these schema changes and is included in the PR. The migration adds the new tables and fields, along with indexes for efficient querying based on email and status._ + +3. Added a shared invitation status enum + +Added InvitationStatus enum with: +PENDING +ACCEPTED +EXPIRED +REVOKED +CANCELLED + +What this does: + +Gives both invitation tables a consistent lifecycle. +Makes filtering and reporting easier than using free-form strings. + +4. Added TeacherInvitation model + Key fields: + +invitedTeacherEmail +status (defaults to PENDING) +inviteToken (unique) +invitedById (who sent) +acceptedById (who accepted, optional) +expiresAt +createdAt / updatedAt + +Indexes: + +invitedTeacherEmail + status +expiresAt + +What this enables: + +Admin invites teacher by email. +Invite can be accepted, revoked, or expired. +Unique token supports secure accept links later. + +5. Added StudentInvitation model + Key fields: + +invitedStudentEmail +status (defaults to PENDING) +inviteToken (unique) +classroomId (which class this invite is for) +invitedById (teacher/admin sender) +acceptedById (optional) +expiresAt +createdAt / updatedAt + +Indexes: + +classroomId + status +invitedStudentEmail + status +expiresAt + +What this enables: + +Teachers send and resend student invites per class. +You can list pending invites for a class and track acceptance. + +How this maps to your onboarding goal + +Teacher onboarding (invite-only): supported by TeacherInvitation. +Student onboarding (code + email): existing join code path remains, and StudentInvitation adds the email path. +Reinvite/resend: supported by status + token + expiry + sender tracking. + +**admin/teacher_invites/create.js API endpoint added:** + +This endpoint allows an admin to create a teacher invitation by providing the teacher's email. It generates a unique invite token, sets the status to PENDING, and records who sent the invite. + +What this new endpoint does: + +Allows only POST. +Requires an authenticated session. +Requires the current user role to be ADMIN. +Accepts invitedTeacherEmail from request body. +Normalizes the email to lowercase and trims spaces. +Prevents duplicate active invites (same email, status PENDING, not expired). +Creates a secure invite token using Node crypto. +Sets invite expiry to 7 days from now. +Stores the invitation in TeacherInvitation and returns status 201 with the created record. + +How the code is structured in create.js: + +Imports Prisma + NextAuth session helper + crypto. +Defines INVITE_EXPIRY_DAYS so expiry policy is explicit and easy to change. +Adds buildExpiryDate helper to keep date logic isolated. +In handler: +Method check first. +Auth check second. +Admin authorization check third. +Input validation fourth. +Duplicate check fifth. +Create record last. + +**admin/teacher_invites/list.js API endpoint added:** + +This endpoint allows an admin to list all teacher invitations, optionally filtered by status. It returns the invitations along with the inviter and acceptor user details. + +How the new list endpoint works, step by step: + +1. Method guard + Only accepts GET requests. + Anything else returns 405. +2. Authentication + Reads session using NextAuth. + If there is no session, returns 403. +3. Authorization + Loads current user from DB by session email. + Requires role ADMIN. + Non-admins return 403. +4. Pagination input + Accepts optional query params: limit and offset. + limit defaults to 50 and is capped at 200. + offset defaults to 0 and cannot go below 0. +5. Data query + Fetches teacher invitations sorted newest first. + Includes related invitedBy user and acceptedBy user details. + Also computes total count for pagination. +6. Response shape + Returns status 200 with: + invitations array + pagination object containing total, limit, offset + +Why this matters for onboarding: + +Admin dashboard will need this endpoint to show pending/accepted/expired teacher invites. +It gives us the read side before adding actions like resend and revoke. +It is safe by default because only authenticated admins can query it. + +**admin/teacher_invites/revoke.js API endpoint added:** + +This endpoint allows an admin to revoke a pending teacher invitation by its ID. It checks that the invite exists, is still pending, and then updates its status to REVOKED. + +How revoke works, step by step: + +1. Method check + Accepts POST only. + Returns 405 for any other method. +2. Authentication + Validates active session via NextAuth. + Returns 403 if not signed in. +3. Authorization + Looks up current user by session email. + Requires role ADMIN. + Returns 403 otherwise. +4. Input validation + Expects teacherInvitationId in request body. + Returns 400 if missing/empty. +5. Invitation lookup + Finds invitation by teacherInvitationId. + Returns 404 if not found. +6. State safety + Only allows revoke when status is PENDING. + If invite is already ACCEPTED, EXPIRED, REVOKED, or CANCELLED, returns 409 with current status. +7. Update + Sets status to REVOKED. + Returns updated invitation payload with 200. + +Why this matters for onboarding: +Admins need the ability to revoke invites if they were sent in error or if circumstances change. +This endpoint enforces that only pending invites can be revoked, preventing inconsistent states. + +**admin/teacher_invites/resend.js API endpoint added:** + +This endpoint allows an admin to resend a pending teacher invitation by its ID. It checks that the invite exists, is still pending, and then updates its inviteToken and expiresAt to effectively "refresh" the invite. + +How resend works, step by step: + +1. Method/auth/role checks + POST only + Signed-in session required + ADMIN role required +2. Input check + Requires teacherInvitationId in request body +3. Invitation lookup + Finds invitation by ID + 404 if not found +4. State rule + If invitation is ACCEPTED, resend is blocked with 409 + Any non-accepted status can be resent +5. Resend behavior + Generates a fresh secure token using crypto random bytes + Resets expiration to 7 days from now + Forces status back to PENDING +6. Response + Returns updated invitation object with status 200 + +Why this is useful: + +Admin can reissue stale/revoked/expired invite links without creating a brand-new record. +Fresh token invalidates older links for safety. +It keeps invitation history attached to one invitation record. + +**admin/index.js and components/TeacherInvitesPanel (js and css) added:** + +This adds a new section to the admin dashboard where admins can manage teacher invitations. It includes a list of current invitations with their status and actions to resend or revoke pending invites. + +How this new panel works: + +1. Loads invites on page load + Calls GET /api/admin/teacher_invites/list?limit=100 + Shows loading, empty, or table state +2. Lets admin create invite + Email input + Send Invite button + Calls POST /api/admin/teacher_invites/create + Handles duplicate-active-invite case (409) with user feedback +3. Lets admin resend invite + Resend button on each row + Calls POST /api/admin/teacher_invites/resend + Disabled for ACCEPTED invites +4. Lets admin revoke invite + Revoke button on each row + Calls POST /api/admin/teacher_invites/revoke + Disabled unless status is PENDING +5. Gives visible feedback + Uses existing toast notifier component for success/error messages + +What this means practically: + +You can now exercise all teacher invite admin APIs from UI. +Admin onboarding workflow is now testable end-to-end at basic level (create/list/resend/revoke), pending database migration and invite acceptance flow. + +**api/teacher_invites/accept.js API endpoint added:** + +This endpoint allows an invited teacher to accept their invitation. It checks the invite token, validates that the invite is still pending, promotes the user to TEACHER role, and marks the invite as ACCEPTED. + +How the accept endpoint works: + +1. Requires POST. +2. Requires authenticated user session. +3. Requires inviteToken in request body. +4. Looks up the invitation by token. +5. Verifies invitation email matches signed-in user email. +6. Handles state cases: + - ACCEPTED: returns success idempotently (or conflict if accepted by different user) + - non-PENDING: returns 409 + - expired PENDING: marks EXPIRED and returns 410 +7. If valid and pending: + - Marks invitation ACCEPTED + - Sets acceptedById + - Promotes user role to TEACHER (keeps ADMIN as ADMIN) + - Does both writes in a single Prisma transaction + +Why this is important: + +1. It closes the main onboarding loop from “admin sent invite” to “teacher accepted and got role.” +2. It keeps role change and invitation state synchronized atomically. + +Quick manual test flow: + +1. As admin, create invite in admin UI (Teacher Invitations panel). +2. Copy inviteToken from API response for now (until invite email link/UI token page is added). +3. Sign in as the invited email account. +4. POST to /api/teacher_invites/accept with: + { + "inviteToken": "the-token-from-invite" + } +5. Confirm response shows: + - invitation.status = ACCEPTED + - user.role = TEACHER (or ADMIN if already admin) + +**api/student_invites/create.js API endpoint added:** + +This endpoint allows a teacher to create an email invitation for a student to join their class. It generates a unique invite token, sets the status to PENDING, and records who sent the invite and which class it's for. + +How the new student invite creation endpoint works: + +1. Requires POST and authenticated session. +2. Requires current user role to be TEACHER or ADMIN. +3. Validates inputs: + - invitedStudentEmail + - classroomId +4. Verifies classroom exists. +5. Enforces ownership: + - If role is TEACHER, they can only invite for classrooms where classroomTeacherId equals their user id. +6. Prevents duplicate active invites: + - same invitedStudentEmail + classroomId + status PENDING + not expired. +7. Creates invitation with: + - status PENDING + - secure inviteToken + - 7-day expiry + - invitedById set to current user id. + +Manual test payload: +POST /api/student_invites/create +{ +"invitedStudentEmail": "student@example.org", +"classroomId": "YOUR_CLASSROOM_ID" +} + +Expected outcomes: + +201 with invitation object on success +403 if non-teacher/non-admin or teacher does not own class +404 if classroom not found +409 if active invite already exists + +**api/student_invites/list.js API endpoint added:** + +This endpoint allows a teacher to list all student invitations for a specific classroom. It returns the invitations along with their status and who sent them. + +How the new student invite listing endpoint works: + +1. Requires GET. +2. Requires authenticated session. +3. Requires user role TEACHER or ADMIN. +4. Supports optional query parameters: + - limit + - offset + - classroomId +5. Applies ownership filtering: + - TEACHER only sees invites for classrooms they own. + - ADMIN can see all invites, optionally filtered by classroomId. +6. Returns invitation rows plus pagination metadata. + +Returned fields include: + +1. Invite basics: id, invitedStudentEmail, classroomId, status, expiresAt, createdAt, updatedAt +2. Related inviter user details +3. Related accepting user details +4. Related classroom details + +Manual test examples: + +1. List all visible invites + - GET /api/student_invites/list +2. Filter by one classroom + - GET /api/student_invites/list?classroomId=YOUR_CLASSROOM_ID +3. Paginate + - GET /api/student_invites/list?limit=20&offset=20 + +**api/student_invites/resend.js API endpoint added:** + +This endpoint allows a teacher to resend a pending student invitation by its ID. It checks that the invite exists, is still pending, and then updates its inviteToken and expiresAt to effectively "refresh" the invite. + +How the new student invite resend endpoint works: + +How resend works: + +1. POST-only endpoint. +2. Requires authenticated TEACHER or ADMIN. +3. Requires studentInvitationId in body. +4. Loads invitation and its classroom owner. +5. Enforces ownership: + - TEACHER can only resend for their own classroom invites. + - ADMIN can resend any. +6. Blocks resend if invite is already ACCEPTED (409). +7. On allowed resend: + - Regenerates secure token + - Resets status to PENDING + - Extends expiry by 7 days + +**api/student_invites/revoke.js API endpoint added:** + +This endpoint allows a teacher to revoke a pending student invitation by its ID. It checks that the invite exists, is still pending, and then updates its status to CANCELLED. + +How revoke works: + +1. POST-only. +2. Requires authenticated TEACHER or ADMIN. +3. Requires studentInvitationId in body. +4. Loads invitation + classroom owner. +5. Enforces ownership: + - TEACHER can only revoke invites in their own classrooms. + - ADMIN can revoke any invite. +6. Allows revoke only when status is PENDING. +7. Sets status to CANCELLED and returns updated invitation. + +**Authorization and validation hardening:** + +Across all new endpoints, we have layered checks to ensure that only properly authenticated and authorized users can perform actions. We also validate inputs and handle edge cases like duplicate invites or invalid states with appropriate HTTP status codes (400, 403, 404, 409, etc.). This ensures a robust and secure API surface for the new onboarding features. + +What I changed: + +1. Added shared API helper at inviteApiUtils.js +2. Refactored all invitation endpoints to use shared method/auth/role checks: + + - create.js (teacher) + - list.js (teacher) + - resend.js (teacher) + - revoke.js (teacher) + - accept.js (teacher) + + - create.js (student) + - list.js (student) + - resend.js (student) + - revoke.js (student) + - accept.js (student) + +What is now hardened: + +1. Consistent method guarding with one shared helper. +2. Consistent authenticated user loading and role enforcement. +3. Consistent pagination parsing in list endpoints. +4. Email validation added for create invitation endpoints (teacher and student). +5. Reduced duplicated auth logic across endpoints, which lowers drift risk. + +**StudentInvitesPanel (js and css) component added to teacher class view:** + +This component allows teachers to manage student invitations for their classes directly from the class view. It lists current invites and provides actions to resend or revoke pending invites. + +What I implemented: + +1. Added student invites UI component at StudentInvitesPanel.js +2. Added panel styles at StudentInvitesPanel.module.css +3. Mounted panel in each class card at ClassInviteTable.js +4. Updated TODO status in TEACHER_ONBOARDING_TODO.md +5. Teacher student invites panel in classes: done +6. Invite status and action feedback: done + +What the new panel does: + +1. Lists student invites for the specific classroom. +2. Lets teacher/admin create invite by email. +3. Lets teacher/admin resend invites. +4. Lets teacher/admin revoke pending invites. +5. Shows invite status and expiry in a table. +6. Shows success/error notifications for each action. + +Important fix I made: +Resolved a React hook dependency warning by using useCallback for invitation loading. + +**pages/error.js updated for role-aware access denied guidance:** + +This update enhances the error page to provide more specific guidance when access is denied due to insufficient permissions. Instead of a generic "Access Denied" message, it now checks the user's role and offers tailored next steps. + +What changed: + +1. Replaced static error page with server-aware guidance logic in error.js +2. Marked TODO item complete in TEACHER_ONBOARDING_TODO.md + +How the new error flow works: + +1. Server checks whether user is signed in. +2. If signed in, it loads user role from DB. +3. It also checks the latest teacher invitation status for that email. +4. UI then chooses guidance based on state. + +Current guidance mappings: + +1. Not signed in: + - “Please Sign In” and route to home. +2. Signed in but user record missing: + - “Account Not Found” message. +3. ADMIN: + - Prompt to open admin dashboard. +4. TEACHER: + - Prompt to open classes page. +5. STUDENT: + - Explains this area is teacher/admin only. +6. NONE with teacher invite PENDING: + - Indicates invitation exists and asks to get invite link resent/shared. +7. NONE with teacher invite EXPIRED: + - Asks user to request resend from admin. +8. NONE with teacher invite REVOKED: + - Indicates invitation was revoked and to contact admin. +9. Optional reason query support: + - If reason=role-required, shows explicit role-required message. + +Why this helps onboarding: + +1. Users no longer get one generic “Access Denied.” +2. They get actionable next steps based on their actual onboarding state. diff --git a/components/ClassInviteTable.js b/components/ClassInviteTable.js index 5501a308a..359277592 100644 --- a/components/ClassInviteTable.js +++ b/components/ClassInviteTable.js @@ -5,6 +5,7 @@ import { toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { MultiSelect } from 'react-multi-select-component'; import { getStoredSuperblocks } from '../util/curriculum/constants'; +import StudentInvitesPanel from './StudentInvitesPanel'; export default function ClassInviteTable({ currentClass, @@ -361,6 +362,7 @@ export default function ClassInviteTable({ {currentClass.description} + { + if (!value) { + return 'N/A'; + } + return new Date(value).toLocaleString(); +}; + +const normalizeEmail = value => value.trim().toLowerCase(); + +export default function StudentInvitesPanel({ classroomId }) { + const [invitedStudentEmail, setInvitedStudentEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [invitations, setInvitations] = useState([]); + + const loadInvitations = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetch( + `/api/student_invites/list?classroomId=${encodeURIComponent( + classroomId + )}&limit=100` + ); + + if (!response.ok) { + DisplayNotification('Error', 'Could not load student invitations.'); + return; + } + + const data = await response.json(); + setInvitations(data.invitations || []); + } catch { + DisplayNotification('Error', 'Could not load student invitations.'); + } finally { + setIsLoading(false); + } + }, [classroomId]); + + useEffect(() => { + if (classroomId) { + loadInvitations(); + } + }, [classroomId, loadInvitations]); + + const createInvite = async event => { + event.preventDefault(); + const email = normalizeEmail(invitedStudentEmail); + + if (!email) { + DisplayNotification('Error', 'Please enter a student email.'); + return; + } + + try { + const response = await fetch('/api/student_invites/create', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + invitedStudentEmail: email, + classroomId + }) + }); + + if (response.status === 409) { + DisplayNotification( + 'Error', + 'An active invitation already exists for this student in this class.' + ); + return; + } + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Could not create student invitation.' + ); + return; + } + + setInvitedStudentEmail(''); + DisplayNotification('Success', 'Student invitation created.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Could not create student invitation.'); + } + }; + + const resendInvite = async studentInvitationId => { + try { + const response = await fetch('/api/student_invites/resend', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ studentInvitationId }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Could not resend student invitation.' + ); + return; + } + + DisplayNotification('Success', 'Student invitation resent.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Could not resend student invitation.'); + } + }; + + const revokeInvite = async studentInvitationId => { + try { + const response = await fetch('/api/student_invites/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ studentInvitationId }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Could not revoke student invitation.' + ); + return; + } + + DisplayNotification('Success', 'Student invitation revoked.'); + await loadInvitations(); + } catch { + DisplayNotification('Error', 'Could not revoke student invitation.'); + } + }; + + return ( +
+

Student Email Invitations

+

+ Send, resend, and revoke student invites for this class. +

+ +
+ setInvitedStudentEmail(event.target.value)} + className={styles.input} + placeholder='student@example.org' + required + /> + +
+ +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : invitations.length === 0 ? ( + + + + ) : ( + invitations.map(invitation => ( + + + + + + + )) + )} + +
EmailStatusExpiresActions
+ Loading invitations... +
+ No student invitations yet. +
{invitation.invitedStudentEmail}{invitation.status}{formatDate(invitation.expiresAt)} +
+ + +
+
+
+
+ ); +} diff --git a/components/StudentInvitesPanel.module.css b/components/StudentInvitesPanel.module.css new file mode 100644 index 000000000..9fa96a722 --- /dev/null +++ b/components/StudentInvitesPanel.module.css @@ -0,0 +1,101 @@ +.panel { + margin-top: 1rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; + padding: 0.75rem; + background-color: #ffffff; +} + +.title { + font-size: 1rem; + font-weight: 700; + color: #0a0a23; +} + +.subtitle { + margin-top: 0.25rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: #1f2937; +} + +.formRow { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.input { + flex: 1; + border: 1px solid #6b7280; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; +} + +.primaryButton { + border: 2px solid #0a0a23; + border-radius: 0.25rem; + background-color: #feac32; + color: #0a0a23; + font-weight: 600; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.primaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.tableWrapper { + margin-top: 0.75rem; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.table th, +.table td { + border: 1px solid #9ca3af; + padding: 0.375rem 0.5rem; + text-align: left; +} + +.table thead { + background-color: #f3f4f6; +} + +.actionGroup { + display: flex; + gap: 0.5rem; +} + +.secondaryButton { + border: 1px solid #6b7280; + border-radius: 0.25rem; + background-color: #ffffff; + padding: 0.25rem 0.5rem; + cursor: pointer; +} + +.secondaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.emptyRow { + text-align: center; + color: #374151; +} + +@media (max-width: 640px) { + .formRow { + flex-direction: column; + align-items: stretch; + } +} diff --git a/components/TeacherInvitesPanel.js b/components/TeacherInvitesPanel.js new file mode 100644 index 000000000..0f2c86314 --- /dev/null +++ b/components/TeacherInvitesPanel.js @@ -0,0 +1,220 @@ +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 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) { + DisplayNotification('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 ( +
+ +

Teacher Invitations

+

+ Invite a teacher by email. Use resend or revoke while invitation is + pending. +

+ +
+ setInvitedTeacherEmail(event.target.value)} + className={styles.emailInput} + placeholder='teacher@example.org' + required + /> + +
+ +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : invitations.length === 0 ? ( + + + + ) : ( + invitations.map(invitation => ( + + + + + + + )) + )} + +
EmailStatusExpiresActions
+ Loading invitations... +
+ No invitations yet. +
+ {invitation.invitedTeacherEmail} + {invitation.status} + {formatDate(invitation.expiresAt)} + +
+ + +
+
+
+
+ ); +} diff --git a/components/TeacherInvitesPanel.module.css b/components/TeacherInvitesPanel.module.css new file mode 100644 index 000000000..31c5dd1bd --- /dev/null +++ b/components/TeacherInvitesPanel.module.css @@ -0,0 +1,95 @@ +.panel { + margin: 2rem 1.5rem 0; + padding: 1rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; +} + +.title { + font-size: 1.5rem; + font-weight: 700; +} + +.description { + margin-top: 0.5rem; + font-size: 0.875rem; +} + +.form { + margin-top: 1rem; + display: flex; + gap: 0.5rem; +} + +.emailInput { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #6b7280; + border-radius: 0.25rem; +} + +.primaryButton { + padding: 0.5rem 1rem; + border: 2px solid #000; + border-radius: 0.25rem; + background-color: #feac32; + color: #000; + font-weight: 600; + cursor: pointer; +} + +.primaryButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.tableWrapper { + margin-top: 1rem; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + border: 1px solid #6b7280; + text-align: left; + font-size: 0.875rem; +} + +.headerRow { + background-color: #f5f6f7; +} + +.headerCell { + border: 1px solid #6b7280; + padding: 0.25rem 0.5rem; +} + +.cell { + border: 1px solid #6b7280; + padding: 0.5rem; +} + +.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; +} + +@media (max-width: 640px) { + .form { + flex-direction: column; + } +} diff --git a/pages/admin/index.js b/pages/admin/index.js index 0d9867762..05a12996c 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -5,6 +5,7 @@ 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'; export async function getServerSideProps(ctx) { // Dynamic import to prevent Prisma from being bundled for client @@ -88,6 +89,7 @@ export default function Home(props) { Admin + diff --git a/pages/api/admin/teacher_invites/create.js b/pages/api/admin/teacher_invites/create.js new file mode 100644 index 000000000..286dc62c8 --- /dev/null +++ b/pages/api/admin/teacher_invites/create.js @@ -0,0 +1,84 @@ +import prisma from '../../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + isValidEmail, + normalizeEmail, + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; + +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; + } + + 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 + } + }); + + 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..c6920ed42 --- /dev/null +++ b/pages/api/admin/teacher_invites/list.js @@ -0,0 +1,64 @@ +import prisma from '../../../../prisma/prisma'; +import { + parsePagination, + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'GET')) { + return; + } + + 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..d1494c9e5 --- /dev/null +++ b/pages/api/admin/teacher_invites/resend.js @@ -0,0 +1,79 @@ +import prisma from '../../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; + +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; + } + + 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 + } + }); + + 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 resentInvitation = await prisma.teacherInvitation.update({ + where: { + teacherInvitationId + }, + data: { + inviteToken: refreshedToken, + expiresAt: buildExpiryDate(), + status: 'PENDING' + }, + select: { + teacherInvitationId: true, + invitedTeacherEmail: true, + status: true, + expiresAt: true, + updatedAt: true, + inviteToken: true + } + }); + + 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..a58c405c8 --- /dev/null +++ b/pages/api/admin/teacher_invites/revoke.js @@ -0,0 +1,65 @@ +import prisma from '../../../../prisma/prisma'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../../util/inviteApiUtils'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + 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: 'Only pending invitations can be revoked', + 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/student_invites/create.js b/pages/api/student_invites/create.js new file mode 100644 index 000000000..ca3f2ea87 --- /dev/null +++ b/pages/api/student_invites/create.js @@ -0,0 +1,113 @@ +import prisma from '../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + isValidEmail, + normalizeEmail, + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; + +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; + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['TEACHER', 'ADMIN'], + roleError: 'Teacher or admin role required' + }); + if (!currentUser) { + return; + } + + const invitedStudentEmail = normalizeEmail(req.body?.invitedStudentEmail); + const classroomId = req.body?.classroomId?.trim(); + + if (!invitedStudentEmail) { + return res.status(400).json({ error: 'invitedStudentEmail is required' }); + } + + if (!isValidEmail(invitedStudentEmail)) { + return res.status(400).json({ error: 'invitedStudentEmail must be valid' }); + } + + if (!classroomId) { + return res.status(400).json({ error: 'classroomId is required' }); + } + + const classroom = await prisma.classroom.findUnique({ + where: { + classroomId + }, + select: { + classroomId: true, + classroomTeacherId: true + } + }); + + if (!classroom) { + return res.status(404).json({ error: 'Classroom not found' }); + } + + // A teacher can only invite students to their own classroom. + if ( + currentUser.role === 'TEACHER' && + classroom.classroomTeacherId !== currentUser.id + ) { + return res.status(403).json({ error: 'Not allowed for this classroom' }); + } + + const existingPendingInvite = await prisma.studentInvitation.findFirst({ + where: { + invitedStudentEmail, + classroomId, + status: 'PENDING', + expiresAt: { + gt: new Date() + } + }, + select: { + studentInvitationId: true, + expiresAt: true + } + }); + + if (existingPendingInvite) { + return res.status(409).json({ + error: 'Active student invitation already exists', + invitation: existingPendingInvite + }); + } + + const createdInvitation = await prisma.studentInvitation.create({ + data: { + invitedStudentEmail, + classroomId, + invitedById: currentUser.id, + inviteToken: randomBytes(24).toString('hex'), + status: 'PENDING', + expiresAt: buildExpiryDate() + }, + select: { + studentInvitationId: true, + invitedStudentEmail: true, + classroomId: true, + status: true, + expiresAt: true, + createdAt: true, + inviteToken: true + } + }); + + return res.status(201).json({ + invitation: createdInvitation + }); +} diff --git a/pages/api/student_invites/list.js b/pages/api/student_invites/list.js new file mode 100644 index 000000000..1095696dd --- /dev/null +++ b/pages/api/student_invites/list.js @@ -0,0 +1,85 @@ +import prisma from '../../../prisma/prisma'; +import { + parsePagination, + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'GET')) { + return; + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['TEACHER', 'ADMIN'], + roleError: 'Teacher or admin role required' + }); + if (!currentUser) { + return; + } + + const { limit, offset } = parsePagination(req); + const classroomId = req.query.classroomId?.trim(); + + const where = {}; + if (classroomId) { + where.classroomId = classroomId; + } + + if (currentUser.role === 'TEACHER') { + where.classroom = { + classroomTeacherId: currentUser.id + }; + } + + const [invitations, total] = await Promise.all([ + prisma.studentInvitation.findMany({ + where, + orderBy: { + createdAt: 'desc' + }, + skip: offset, + take: limit, + select: { + studentInvitationId: true, + invitedStudentEmail: true, + classroomId: 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 + } + }, + classroom: { + select: { + classroomId: true, + classroomName: true, + classroomTeacherId: true + } + } + } + }), + prisma.studentInvitation.count({ where }) + ]); + + return res.status(200).json({ + invitations, + pagination: { + total, + limit, + offset + } + }); +} diff --git a/pages/api/student_invites/resend.js b/pages/api/student_invites/resend.js new file mode 100644 index 000000000..683862242 --- /dev/null +++ b/pages/api/student_invites/resend.js @@ -0,0 +1,92 @@ +import prisma from '../../../prisma/prisma'; +import { randomBytes } from 'crypto'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; + +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; + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['TEACHER', 'ADMIN'], + roleError: 'Teacher or admin role required' + }); + if (!currentUser) { + return; + } + + const studentInvitationId = req.body?.studentInvitationId?.trim(); + if (!studentInvitationId) { + return res.status(400).json({ error: 'studentInvitationId is required' }); + } + + const invitation = await prisma.studentInvitation.findUnique({ + where: { + studentInvitationId + }, + select: { + studentInvitationId: true, + invitedStudentEmail: true, + status: true, + classroom: { + select: { + classroomId: true, + classroomTeacherId: true + } + } + } + }); + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + if ( + currentUser.role === 'TEACHER' && + invitation.classroom.classroomTeacherId !== currentUser.id + ) { + return res.status(403).json({ error: 'Not allowed for this classroom' }); + } + + if (invitation.status === 'ACCEPTED') { + return res.status(409).json({ + error: 'Accepted invitations cannot be resent', + status: invitation.status + }); + } + + const resentInvitation = await prisma.studentInvitation.update({ + where: { + studentInvitationId + }, + data: { + inviteToken: randomBytes(24).toString('hex'), + status: 'PENDING', + expiresAt: buildExpiryDate() + }, + select: { + studentInvitationId: true, + invitedStudentEmail: true, + classroomId: true, + status: true, + expiresAt: true, + updatedAt: true, + inviteToken: true + } + }); + + return res.status(200).json({ + invitation: resentInvitation + }); +} diff --git a/pages/api/student_invites/revoke.js b/pages/api/student_invites/revoke.js new file mode 100644 index 000000000..0c75ba356 --- /dev/null +++ b/pages/api/student_invites/revoke.js @@ -0,0 +1,75 @@ +import prisma from '../../../prisma/prisma'; +import { + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + const currentUser = await requireAuthenticatedUser(req, res, { + allowedRoles: ['TEACHER', 'ADMIN'], + roleError: 'Teacher or admin role required' + }); + if (!currentUser) { + return; + } + + const studentInvitationId = req.body?.studentInvitationId?.trim(); + if (!studentInvitationId) { + return res.status(400).json({ error: 'studentInvitationId is required' }); + } + + const invitation = await prisma.studentInvitation.findUnique({ + where: { + studentInvitationId + }, + select: { + studentInvitationId: true, + status: true, + classroom: { + select: { + classroomTeacherId: true + } + } + } + }); + + if (!invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + if ( + currentUser.role === 'TEACHER' && + invitation.classroom.classroomTeacherId !== currentUser.id + ) { + return res.status(403).json({ error: 'Not allowed for this classroom' }); + } + + if (invitation.status !== 'PENDING') { + return res.status(409).json({ + error: 'Only pending invitations can be revoked', + status: invitation.status + }); + } + + const revokedInvitation = await prisma.studentInvitation.update({ + where: { + studentInvitationId + }, + data: { + status: 'CANCELLED' + }, + select: { + studentInvitationId: 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..5f5bc00f3 --- /dev/null +++ b/pages/api/teacher_invites/accept.js @@ -0,0 +1,133 @@ +import prisma from '../../../prisma/prisma'; +import { + normalizeEmail, + requireAuthenticatedUser, + requireMethod +} from '../../../util/inviteApiUtils'; + +export default async function handle(req, res) { + if (!requireMethod(req, res, 'POST')) { + return; + } + + 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 (normalizeEmail(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..84875fbf6 100644 --- a/pages/error.js +++ b/pages/error.js @@ -1,15 +1,184 @@ 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/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/util/inviteApiUtils.js b/util/inviteApiUtils.js new file mode 100644 index 000000000..2841232e0 --- /dev/null +++ b/util/inviteApiUtils.js @@ -0,0 +1,64 @@ +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(); + +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; +}; From b848a18df7d75f3282753ba70e6b06a45ebcadc3 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Fri, 10 Apr 2026 15:34:38 -0700 Subject: [PATCH 02/13] docs: update TODO with join link review section and ZZZ notes status --- TEACHER_ONBOARDING_TODO.md | 25 +++++++++++++++++++++++++ ZZZ_Notes.md | 22 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/TEACHER_ONBOARDING_TODO.md b/TEACHER_ONBOARDING_TODO.md index 7fbd6ac0a..58968fbf0 100644 --- a/TEACHER_ONBOARDING_TODO.md +++ b/TEACHER_ONBOARDING_TODO.md @@ -55,6 +55,31 @@ - [ ] Integration test for admin->teacher flow - [ ] Integration test for teacher->student flow +## Join Link Review + +> The existing join-link route (`/join/[...joinCode]` + `student_email_join.js`) was not changed in +> this branch but has several issues that need to be resolved before the dual-path student onboarding +> goal can be considered complete. + +### Bugs to fix in `student_email_join.js` + +- [ ] Fix broken method guard: `!req.method == 'PUT'` never triggers due to operator precedence — any HTTP method is currently accepted +- [ ] Add early `return` after 405 and 403 responses so execution does not fall through when unauthenticated or wrong method +- [ ] Add `role` to the Prisma `select` on the user query — the role-promotion logic at line 42 silently does nothing today because `userInfo.role` is always `undefined` +- [ ] Validate `body.join[0]` before using it — no guard against missing or malformed joinCode in the request body + +### Improvements to consider + +- [ ] Migrate from `unstable_getServerSession` to `getServerSession` (stable API, consistent with new invite endpoints) +- [ ] Return a JSON error body on failure responses instead of empty `.end()` (consistent with new invite endpoints) +- [ ] Add rate limiting or a short-circuit for repeated join attempts with invalid codes +- [ ] Decide: does joining via link auto-promote role NONE → STUDENT, or should it require an active StudentInvitation? (aligns with dual-path decision) + +### Join page (`pages/join/[...joinCode].js`) + +- [ ] Fix success notification: a non-409 response is currently shown as success even on a server 500 +- [ ] Fix typo in button label: "Submit Reqest" → "Submit Request" + ## Rollout - [ ] Feature flag teacher invites diff --git a/ZZZ_Notes.md b/ZZZ_Notes.md index 43a3bff85..6329c31b2 100644 --- a/ZZZ_Notes.md +++ b/ZZZ_Notes.md @@ -1,3 +1,5 @@ +1. ZZZ Notes — Point Status + - make sure there is a confirmation step before sending or resending invites, to prevent accidental clicks - consider adding a bulk invite option for teachers to upload a CSV of student emails for larger classes - add clear messaging about invite expiry and how to get a new invite if the old one expires @@ -7,6 +9,26 @@ - make sure there is a confirmation step before revoking invites, to prevent accidental revocations - make sure there is a confirmation step before removing students from a class, to prevent accidental removals. likely a reinput of the student's email to confirm the action. +Note Status +Confirmation before sending/resending invites Not done — both panels fire immediately on button click with no confirmation dialog +Bulk CSV invite option Not done — not planned for MVP, deferred +Clear expiry messaging to invitees Not done — the panel shows expiry date but there is no user-facing messaging about what to do when expired +Mobile-friendly invite UI Not assessed — CSS is written but no mobile-specific testing or responsive audit has been done +View accepted vs pending students Partially done — the panel table shows status per invite row, but there is no filtered/summary view +Logging/monitoring around invite APIs Not done +Confirmation before revoking invites Not done — revoke fires immediately with no confirmation +Confirmation before removing students from class Not done — this is in the existing class management UI, not the new invite panels + +2. Student perspective — how it theoretically works + You're right, there is no separate student-facing UI or student-only login. The current design assumes: + + - A student signs in with the same shared Auth0 login (their FCC account). + - They arrive at /join/{classroomId} via either a link the teacher pasted or an email invite they received containing the link. + - The join page shows them a single "Submit Request" button. + - On submit, the backend checks they are authenticated, checks they are not already in the class, optionally promotes them from role NONE → STUDENT, and pushes their user ID into the classroom roster. + +The gap: the new email invite system (StudentInvitation records, tokens, expiry) currently has no acceptance page. The inviteToken is generated and stored, but there is no route like /join/accept?token=... that a student can visit from an email link to accept and be enrolled. The token exists in the DB but nothing consumes it from the student side yet. The join-link code path and the email invite code path are not connected. + **Plan: Teacher Onboarding MVP Proposal** Recommended approach: ship an admin-invite teacher flow first, and keep student onboarding dual-path (join link/code plus email invites with resend). This matches your preferred scope and fits the current architecture with minimal disruption. From d7af71cefe2565578cb9c29b1320d65086c1bba5 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Fri, 10 Apr 2026 15:45:15 -0700 Subject: [PATCH 03/13] test: update classInviteTable snapshot after GraphQL/StudentInvitesPanel integration --- .../classInviteTable.test.jsx.snap | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap index 3763d8aa7..55942c690 100644 --- a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap +++ b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap @@ -58,6 +58,73 @@ exports[`ClassInviteTable displays invites in a table 1`] = ` learn how to build a website in a jiffy +
+

+ Student Email Invitations +

+

+ Send, resend, and revoke student invites for this class. +

+
+ + +
+
+ + + + + + + + + + + + + + +
+ Email + + Status + + Expires + + Actions +
+ No student invitations yet. +
+
+
- - -
- - - - - - - - - - - {isLoading ? ( - - - - ) : invitations.length === 0 ? ( - - - - ) : ( - invitations.map(invitation => ( - - - - - - - )) - )} - -
EmailStatusExpiresActions
- Loading invitations... -
- No student invitations yet. -
{invitation.invitedStudentEmail}{invitation.status}{formatDate(invitation.expiresAt)} -
- - -
-
-
- - ); -} diff --git a/components/StudentInvitesPanel.module.css b/components/StudentInvitesPanel.module.css deleted file mode 100644 index 9fa96a722..000000000 --- a/components/StudentInvitesPanel.module.css +++ /dev/null @@ -1,101 +0,0 @@ -.panel { - margin-top: 1rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; - padding: 0.75rem; - background-color: #ffffff; -} - -.title { - font-size: 1rem; - font-weight: 700; - color: #0a0a23; -} - -.subtitle { - margin-top: 0.25rem; - margin-bottom: 0.75rem; - font-size: 0.875rem; - color: #1f2937; -} - -.formRow { - display: flex; - gap: 0.5rem; - align-items: center; -} - -.input { - flex: 1; - border: 1px solid #6b7280; - border-radius: 0.25rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; -} - -.primaryButton { - border: 2px solid #0a0a23; - border-radius: 0.25rem; - background-color: #feac32; - color: #0a0a23; - font-weight: 600; - padding: 0.5rem 0.75rem; - cursor: pointer; -} - -.primaryButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.tableWrapper { - margin-top: 0.75rem; - overflow-x: auto; -} - -.table { - width: 100%; - border-collapse: collapse; - font-size: 0.8125rem; -} - -.table th, -.table td { - border: 1px solid #9ca3af; - padding: 0.375rem 0.5rem; - text-align: left; -} - -.table thead { - background-color: #f3f4f6; -} - -.actionGroup { - display: flex; - gap: 0.5rem; -} - -.secondaryButton { - border: 1px solid #6b7280; - border-radius: 0.25rem; - background-color: #ffffff; - padding: 0.25rem 0.5rem; - cursor: pointer; -} - -.secondaryButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.emptyRow { - text-align: center; - color: #374151; -} - -@media (max-width: 640px) { - .formRow { - flex-direction: column; - align-items: stretch; - } -} diff --git a/pages/api/student_invites/create.js b/pages/api/student_invites/create.js deleted file mode 100644 index ca3f2ea87..000000000 --- a/pages/api/student_invites/create.js +++ /dev/null @@ -1,113 +0,0 @@ -import prisma from '../../../prisma/prisma'; -import { randomBytes } from 'crypto'; -import { - isValidEmail, - normalizeEmail, - requireAuthenticatedUser, - requireMethod -} from '../../../util/inviteApiUtils'; - -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; - } - - const currentUser = await requireAuthenticatedUser(req, res, { - allowedRoles: ['TEACHER', 'ADMIN'], - roleError: 'Teacher or admin role required' - }); - if (!currentUser) { - return; - } - - const invitedStudentEmail = normalizeEmail(req.body?.invitedStudentEmail); - const classroomId = req.body?.classroomId?.trim(); - - if (!invitedStudentEmail) { - return res.status(400).json({ error: 'invitedStudentEmail is required' }); - } - - if (!isValidEmail(invitedStudentEmail)) { - return res.status(400).json({ error: 'invitedStudentEmail must be valid' }); - } - - if (!classroomId) { - return res.status(400).json({ error: 'classroomId is required' }); - } - - const classroom = await prisma.classroom.findUnique({ - where: { - classroomId - }, - select: { - classroomId: true, - classroomTeacherId: true - } - }); - - if (!classroom) { - return res.status(404).json({ error: 'Classroom not found' }); - } - - // A teacher can only invite students to their own classroom. - if ( - currentUser.role === 'TEACHER' && - classroom.classroomTeacherId !== currentUser.id - ) { - return res.status(403).json({ error: 'Not allowed for this classroom' }); - } - - const existingPendingInvite = await prisma.studentInvitation.findFirst({ - where: { - invitedStudentEmail, - classroomId, - status: 'PENDING', - expiresAt: { - gt: new Date() - } - }, - select: { - studentInvitationId: true, - expiresAt: true - } - }); - - if (existingPendingInvite) { - return res.status(409).json({ - error: 'Active student invitation already exists', - invitation: existingPendingInvite - }); - } - - const createdInvitation = await prisma.studentInvitation.create({ - data: { - invitedStudentEmail, - classroomId, - invitedById: currentUser.id, - inviteToken: randomBytes(24).toString('hex'), - status: 'PENDING', - expiresAt: buildExpiryDate() - }, - select: { - studentInvitationId: true, - invitedStudentEmail: true, - classroomId: true, - status: true, - expiresAt: true, - createdAt: true, - inviteToken: true - } - }); - - return res.status(201).json({ - invitation: createdInvitation - }); -} diff --git a/pages/api/student_invites/list.js b/pages/api/student_invites/list.js deleted file mode 100644 index 1095696dd..000000000 --- a/pages/api/student_invites/list.js +++ /dev/null @@ -1,85 +0,0 @@ -import prisma from '../../../prisma/prisma'; -import { - parsePagination, - requireAuthenticatedUser, - requireMethod -} from '../../../util/inviteApiUtils'; - -export default async function handle(req, res) { - if (!requireMethod(req, res, 'GET')) { - return; - } - - const currentUser = await requireAuthenticatedUser(req, res, { - allowedRoles: ['TEACHER', 'ADMIN'], - roleError: 'Teacher or admin role required' - }); - if (!currentUser) { - return; - } - - const { limit, offset } = parsePagination(req); - const classroomId = req.query.classroomId?.trim(); - - const where = {}; - if (classroomId) { - where.classroomId = classroomId; - } - - if (currentUser.role === 'TEACHER') { - where.classroom = { - classroomTeacherId: currentUser.id - }; - } - - const [invitations, total] = await Promise.all([ - prisma.studentInvitation.findMany({ - where, - orderBy: { - createdAt: 'desc' - }, - skip: offset, - take: limit, - select: { - studentInvitationId: true, - invitedStudentEmail: true, - classroomId: 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 - } - }, - classroom: { - select: { - classroomId: true, - classroomName: true, - classroomTeacherId: true - } - } - } - }), - prisma.studentInvitation.count({ where }) - ]); - - return res.status(200).json({ - invitations, - pagination: { - total, - limit, - offset - } - }); -} diff --git a/pages/api/student_invites/resend.js b/pages/api/student_invites/resend.js deleted file mode 100644 index 683862242..000000000 --- a/pages/api/student_invites/resend.js +++ /dev/null @@ -1,92 +0,0 @@ -import prisma from '../../../prisma/prisma'; -import { randomBytes } from 'crypto'; -import { - requireAuthenticatedUser, - requireMethod -} from '../../../util/inviteApiUtils'; - -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; - } - - const currentUser = await requireAuthenticatedUser(req, res, { - allowedRoles: ['TEACHER', 'ADMIN'], - roleError: 'Teacher or admin role required' - }); - if (!currentUser) { - return; - } - - const studentInvitationId = req.body?.studentInvitationId?.trim(); - if (!studentInvitationId) { - return res.status(400).json({ error: 'studentInvitationId is required' }); - } - - const invitation = await prisma.studentInvitation.findUnique({ - where: { - studentInvitationId - }, - select: { - studentInvitationId: true, - invitedStudentEmail: true, - status: true, - classroom: { - select: { - classroomId: true, - classroomTeacherId: true - } - } - } - }); - - if (!invitation) { - return res.status(404).json({ error: 'Invitation not found' }); - } - - if ( - currentUser.role === 'TEACHER' && - invitation.classroom.classroomTeacherId !== currentUser.id - ) { - return res.status(403).json({ error: 'Not allowed for this classroom' }); - } - - if (invitation.status === 'ACCEPTED') { - return res.status(409).json({ - error: 'Accepted invitations cannot be resent', - status: invitation.status - }); - } - - const resentInvitation = await prisma.studentInvitation.update({ - where: { - studentInvitationId - }, - data: { - inviteToken: randomBytes(24).toString('hex'), - status: 'PENDING', - expiresAt: buildExpiryDate() - }, - select: { - studentInvitationId: true, - invitedStudentEmail: true, - classroomId: true, - status: true, - expiresAt: true, - updatedAt: true, - inviteToken: true - } - }); - - return res.status(200).json({ - invitation: resentInvitation - }); -} diff --git a/pages/api/student_invites/revoke.js b/pages/api/student_invites/revoke.js deleted file mode 100644 index 0c75ba356..000000000 --- a/pages/api/student_invites/revoke.js +++ /dev/null @@ -1,75 +0,0 @@ -import prisma from '../../../prisma/prisma'; -import { - requireAuthenticatedUser, - requireMethod -} from '../../../util/inviteApiUtils'; - -export default async function handle(req, res) { - if (!requireMethod(req, res, 'POST')) { - return; - } - - const currentUser = await requireAuthenticatedUser(req, res, { - allowedRoles: ['TEACHER', 'ADMIN'], - roleError: 'Teacher or admin role required' - }); - if (!currentUser) { - return; - } - - const studentInvitationId = req.body?.studentInvitationId?.trim(); - if (!studentInvitationId) { - return res.status(400).json({ error: 'studentInvitationId is required' }); - } - - const invitation = await prisma.studentInvitation.findUnique({ - where: { - studentInvitationId - }, - select: { - studentInvitationId: true, - status: true, - classroom: { - select: { - classroomTeacherId: true - } - } - } - }); - - if (!invitation) { - return res.status(404).json({ error: 'Invitation not found' }); - } - - if ( - currentUser.role === 'TEACHER' && - invitation.classroom.classroomTeacherId !== currentUser.id - ) { - return res.status(403).json({ error: 'Not allowed for this classroom' }); - } - - if (invitation.status !== 'PENDING') { - return res.status(409).json({ - error: 'Only pending invitations can be revoked', - status: invitation.status - }); - } - - const revokedInvitation = await prisma.studentInvitation.update({ - where: { - studentInvitationId - }, - data: { - status: 'CANCELLED' - }, - select: { - studentInvitationId: true, - status: true, - updatedAt: true - } - }); - - return res.status(200).json({ - invitation: revokedInvitation - }); -} From 8122f5e5852849c5159d7c37bfe5145abf3457e0 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 11 May 2026 18:18:02 -0700 Subject: [PATCH 05/13] feat: add teacher invite email, feature flag, acceptance flow, and tests --- .env.development | 13 +- .env.production | 13 +- .env.sample | 49 ++++++ README.md | 50 ++++++ components/TeacherInvitesPanel.js | 179 ++++++++++++++++++++-- components/TeacherInvitesPanel.module.css | 141 ++++++++++++++--- package-lock.json | 10 ++ package.json | 1 + pages/admin/index.js | 54 ++++++- pages/api/admin/teacher_invites/create.js | 42 +++++ pages/api/admin/teacher_invites/list.js | 7 + pages/api/admin/teacher_invites/resend.js | 54 ++++++- pages/api/admin/teacher_invites/revoke.js | 7 + pages/api/teacher_invites/accept.js | 12 +- styles/Home.module.css | 44 ++++++ util/inviteApiUtils.js | 27 ++++ 16 files changed, 663 insertions(+), 40 deletions(-) diff --git a/.env.development b/.env.development index 06b5011b1..31e655dbd 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,13 @@ GITHUB_OAUTH_PROVIDER_ENABLED = "true" -DANGEROUS_ACCOUNT_LINKING_ENABLED = "true" \ No newline at end of file +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 \ No newline at end of file diff --git a/.env.production b/.env.production index 4f09ad496..0da70c076 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,13 @@ GITHUB_OAUTH_PROVIDER_ENABLED = "false" -DANGEROUS_ACCOUNT_LINKING_ENABLED = "false" \ No newline at end of file +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 \ No newline at end of file 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/README.md b/README.md index ca1a61db4..48718ce03 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,56 @@ 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:** + +In `.env.development`, configure SMTP for testing with your personal email provider: + +``` +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +``` + +**Production Setup:** + +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 index 0f2c86314..e8059a53a 100644 --- a/components/TeacherInvitesPanel.js +++ b/components/TeacherInvitesPanel.js @@ -16,6 +16,65 @@ 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); @@ -65,7 +124,11 @@ export default function TeacherInvitesPanel() { } if (!response.ok) { - DisplayNotification('Error', 'Failed to create invitation.'); + const data = await response.json().catch(() => ({})); + DisplayNotification( + 'Error', + data.error || 'Failed to create invitation.' + ); return; } @@ -132,11 +195,6 @@ export default function TeacherInvitesPanel() { return (
-

Teacher Invitations

-

- Invite a teacher by email. Use resend or revoke while invitation is - pending. -

+
+ + + Showing {totalEntries} +
+
@@ -169,15 +258,15 @@ export default function TeacherInvitesPanel() { Loading invitations... - ) : invitations.length === 0 ? ( + ) : totalEntries === 0 ? ( ) : ( - invitations.map(invitation => ( - + paginatedInvitations.map(invitation => ( + @@ -213,6 +302,76 @@ export default function TeacherInvitesPanel() { )) )} + + + + +
- No invitations yet. + No invitations match the current filters.
{invitation.invitedTeacherEmail}
+
+ + + {startEntry}-{endEntry} of {totalEntries} + + + + + +
+
diff --git a/components/TeacherInvitesPanel.module.css b/components/TeacherInvitesPanel.module.css index 31c5dd1bd..8e6fcf619 100644 --- a/components/TeacherInvitesPanel.module.css +++ b/components/TeacherInvitesPanel.module.css @@ -1,18 +1,5 @@ .panel { - margin: 2rem 1.5rem 0; - padding: 1rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; -} - -.title { - font-size: 1.5rem; - font-weight: 700; -} - -.description { - margin-top: 0.5rem; - font-size: 0.875rem; + margin-top: 1rem; } .form { @@ -22,13 +9,15 @@ } .emailInput { - width: 100%; + 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; @@ -36,6 +25,7 @@ color: #000; font-weight: 600; cursor: pointer; + white-space: nowrap; } .primaryButton:disabled { @@ -43,6 +33,41 @@ 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; @@ -50,24 +75,32 @@ .table { width: 100%; + margin: auto; border-collapse: collapse; - border: 1px solid #6b7280; + font-family: monospace; text-align: left; - font-size: 0.875rem; } .headerRow { - background-color: #f5f6f7; + background-color: transparent; } .headerCell { - border: 1px solid #6b7280; - padding: 0.25rem 0.5rem; + 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 { - border: 1px solid #6b7280; - padding: 0.5rem; + padding: 10px; + text-align: left; } .actionGroup { @@ -88,8 +121,72 @@ 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 05a12996c..e8fbf09ad 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -6,6 +6,8 @@ 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 @@ -41,7 +43,8 @@ export async function getServerSideProps(ctx) { return { props: { userSession, - users: users + users: users, + teacherInvitesEnabled: isTeacherInvitesEnabled() } }; } @@ -50,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', @@ -89,8 +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 index 286dc62c8..c4a726b7b 100644 --- a/pages/api/admin/teacher_invites/create.js +++ b/pages/api/admin/teacher_invites/create.js @@ -6,6 +6,11 @@ import { requireAuthenticatedUser, requireMethod } from '../../../../util/inviteApiUtils'; +import { + buildTeacherInviteUrl, + sendTeacherInvitationEmail +} from '../../../../util/inviteEmail'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; const INVITE_EXPIRY_DAYS = 7; @@ -20,6 +25,12 @@ export default async function handle(req, res) { 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' @@ -78,6 +89,37 @@ export default async function handle(req, res) { } }); + 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 index c6920ed42..6dc60595f 100644 --- a/pages/api/admin/teacher_invites/list.js +++ b/pages/api/admin/teacher_invites/list.js @@ -4,12 +4,19 @@ import { 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' diff --git a/pages/api/admin/teacher_invites/resend.js b/pages/api/admin/teacher_invites/resend.js index d1494c9e5..2cf1a6984 100644 --- a/pages/api/admin/teacher_invites/resend.js +++ b/pages/api/admin/teacher_invites/resend.js @@ -4,6 +4,11 @@ import { requireAuthenticatedUser, requireMethod } from '../../../../util/inviteApiUtils'; +import { + buildTeacherInviteUrl, + sendTeacherInvitationEmail +} from '../../../../util/inviteEmail'; +import { isTeacherInvitesEnabled } from '../../../../util/featureFlags'; const INVITE_EXPIRY_DAYS = 7; @@ -18,6 +23,12 @@ export default async function handle(req, res) { 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' @@ -38,7 +49,9 @@ export default async function handle(req, res) { select: { teacherInvitationId: true, invitedTeacherEmail: true, - status: true + status: true, + inviteToken: true, + expiresAt: true } }); @@ -54,13 +67,14 @@ export default async function handle(req, res) { } const refreshedToken = randomBytes(24).toString('hex'); + const refreshedExpiry = buildExpiryDate(); const resentInvitation = await prisma.teacherInvitation.update({ where: { teacherInvitationId }, data: { inviteToken: refreshedToken, - expiresAt: buildExpiryDate(), + expiresAt: refreshedExpiry, status: 'PENDING' }, select: { @@ -73,6 +87,42 @@ export default async function handle(req, res) { } }); + 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 index a58c405c8..badd4a042 100644 --- a/pages/api/admin/teacher_invites/revoke.js +++ b/pages/api/admin/teacher_invites/revoke.js @@ -3,12 +3,19 @@ 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' diff --git a/pages/api/teacher_invites/accept.js b/pages/api/teacher_invites/accept.js index 5f5bc00f3..d4469d23e 100644 --- a/pages/api/teacher_invites/accept.js +++ b/pages/api/teacher_invites/accept.js @@ -1,15 +1,23 @@ 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' }); @@ -43,7 +51,9 @@ export default async function handle(req, res) { return res.status(404).json({ error: 'Invitation not found' }); } - if (normalizeEmail(invitation.invitedTeacherEmail) !== sessionEmail) { + if ( + !areEquivalentInviteEmails(invitation.invitedTeacherEmail, sessionEmail) + ) { return res.status(403).json({ error: 'Invitation email mismatch' }); } 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/inviteApiUtils.js b/util/inviteApiUtils.js index 2841232e0..954a391e4 100644 --- a/util/inviteApiUtils.js +++ b/util/inviteApiUtils.js @@ -14,6 +14,33 @@ export const requireMethod = (req, res, method) => { 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) => { From 6889aeee6ef940d3bf504edceec377d2cb82ea68 Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 11 May 2026 18:22:21 -0700 Subject: [PATCH 06/13] feat: add teacher invite implementation files (feature flag, email, acceptance page, tests) --- ...adminTeacherOnboarding.integration.test.js | 209 ++++++++++++++ .../utils/teacherInviteAcceptApi.test.js | 218 +++++++++++++++ pages/teacher/invite/[inviteToken].js | 254 ++++++++++++++++++ util/featureFlags.js | 12 + util/inviteEmail.js | 148 ++++++++++ 5 files changed, 841 insertions(+) create mode 100644 __tests__/utils/adminTeacherOnboarding.integration.test.js create mode 100644 __tests__/utils/teacherInviteAcceptApi.test.js create mode 100644 pages/teacher/invite/[inviteToken].js create mode 100644 util/featureFlags.js create mode 100644 util/inviteEmail.js diff --git a/__tests__/utils/adminTeacherOnboarding.integration.test.js b/__tests__/utils/adminTeacherOnboarding.integration.test.js new file mode 100644 index 000000000..a36763777 --- /dev/null +++ b/__tests__/utils/adminTeacherOnboarding.integration.test.js @@ -0,0 +1,209 @@ +import { createMocks } from 'node-mocks-http'; +import createHandler from '../../pages/api/admin/teacher_invites/create'; +import acceptHandler from '../../pages/api/teacher_invites/accept'; + +jest.mock('../../prisma/prisma', () => ({ + TeacherInvitation: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn() + }, + User: { + findUnique: jest.fn(), + update: jest.fn() + } +})); + +jest.mock('../../util/inviteEmail', () => ({ + sendTeacherInvitationEmail: jest.fn() +})); + +jest.mock('../../util/inviteApiUtils', () => ({ + areEquivalentInviteEmails: jest.fn() +})); + +jest.mock('next-auth/react', () => ({ + getSession: jest.fn() +})); + +describe('Admin to Teacher Onboarding Flow', () => { + const prisma = require('../../prisma/prisma'); + const inviteEmail = require('../../util/inviteEmail'); + const inviteApiUtils = require('../../util/inviteApiUtils'); + const nextAuth = require('next-auth/react'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('completes full flow: create invite -> accept invite -> role transition', async () => { + const teacherEmail = 'teacher@example.com'; + const adminEmail = 'admin@example.com'; + const inviteToken = 'test-token-123'; + + // Mock admin session + nextAuth.getSession.mockResolvedValue({ + user: { + email: adminEmail + } + }); + + // 1. Admin creates invitation + prisma.TeacherInvitation.findUnique.mockResolvedValueOnce(null); // No existing pending invite + prisma.TeacherInvitation.create.mockResolvedValueOnce({ + id: 'invite-1', + token: inviteToken, + invitedEmail: teacherEmail, + status: 'PENDING', + createdAt: new Date() + }); + + inviteEmail.sendTeacherInvitationEmail.mockResolvedValueOnce({ + success: true, + messageId: 'msg-123' + }); + + const { req: createReq, res: createRes } = createMocks({ + method: 'POST', + body: { + email: teacherEmail, + adminName: 'Test Admin' + } + }); + + createReq.session = nextAuth.getSession(); + + await createHandler(createReq, createRes); + + expect(createRes._getStatusCode()).toBe(201); + const createData = JSON.parse(createRes._getData()); + expect(createData.success).toBe(true); + expect(createData.invitation.invitedEmail).toBe(teacherEmail); + + // 2. Teacher accepts invitation + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + prisma.TeacherInvitation.findUnique.mockResolvedValueOnce({ + id: 'invite-1', + token: inviteToken, + invitedEmail: teacherEmail, + status: 'PENDING', + expiresAt: futureDate, + userId: 'user-123' + }); + + inviteApiUtils.areEquivalentInviteEmails.mockReturnValueOnce(true); + + prisma.TeacherInvitation.update.mockResolvedValueOnce({ + id: 'invite-1', + status: 'ACCEPTED' + }); + + prisma.User.update.mockResolvedValueOnce({ + id: 'user-123', + email: teacherEmail, + role: 'TEACHER' + }); + + const { req: acceptReq, res: acceptRes } = createMocks({ + method: 'POST', + body: { + inviteToken: inviteToken + } + }); + + acceptReq.session = { + user: { + email: teacherEmail + } + }; + + await acceptHandler(acceptReq, acceptRes); + + expect(acceptRes._getStatusCode()).toBe(200); + const acceptData = JSON.parse(acceptRes._getData()); + expect(acceptData.success).toBe(true); + expect(acceptData.role).toBe('TEACHER'); + + // 3. Verify retry after acceptance returns already accepted + prisma.TeacherInvitation.findUnique.mockResolvedValueOnce({ + id: 'invite-1', + status: 'ACCEPTED', + expiresAt: futureDate + }); + + const { req: retryReq, res: retryRes } = createMocks({ + method: 'POST', + body: { + inviteToken: inviteToken + } + }); + + retryReq.session = { + user: { + email: teacherEmail + } + }; + + await acceptHandler(retryReq, retryRes); + + expect(retryRes._getStatusCode()).toBe(400); + const retryData = JSON.parse(retryRes._getData()); + expect(retryData.alreadyAccepted).toBe(true); + }); + + it('rolls back invitation creation on email send failure', async () => { + const teacherEmail = 'teacher@example.com'; + const adminEmail = 'admin@example.com'; + const inviteToken = 'test-token-456'; + + nextAuth.getSession.mockResolvedValue({ + user: { + email: adminEmail + } + }); + + // No existing pending invite + prisma.TeacherInvitation.findUnique.mockResolvedValueOnce(null); + + // Create invite + prisma.TeacherInvitation.create.mockResolvedValueOnce({ + id: 'invite-2', + token: inviteToken, + invitedEmail: teacherEmail, + status: 'PENDING' + }); + + // Email send fails + inviteEmail.sendTeacherInvitationEmail.mockResolvedValueOnce({ + success: false, + error: 'SMTP configuration missing' + }); + + // Delete the created invite on failure + prisma.TeacherInvitation.delete.mockResolvedValueOnce({ + id: 'invite-2' + }); + + const { req, res } = createMocks({ + method: 'POST', + body: { + email: teacherEmail, + adminName: 'Test Admin' + } + }); + + req.session = nextAuth.getSession(); + + await createHandler(req, res); + + expect(res._getStatusCode()).toBe(500); + const data = JSON.parse(res._getData()); + expect(data.error).toContain('SMTP'); + + // Verify deletion was called to roll back + expect(prisma.TeacherInvitation.delete).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/utils/teacherInviteAcceptApi.test.js b/__tests__/utils/teacherInviteAcceptApi.test.js new file mode 100644 index 000000000..48806c94f --- /dev/null +++ b/__tests__/utils/teacherInviteAcceptApi.test.js @@ -0,0 +1,218 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '../../pages/api/teacher_invites/accept'; + +jest.mock('../../prisma/prisma', () => ({ + TeacherInvitation: { + findUnique: jest.fn(), + update: jest.fn() + }, + User: { + update: jest.fn() + } +})); + +jest.mock('../../util/inviteApiUtils', () => ({ + areEquivalentInviteEmails: jest.fn() +})); + +describe('/api/teacher_invites/accept', () => { + const prisma = require('../../prisma/prisma'); + const inviteApiUtils = require('../../util/inviteApiUtils'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects POST without inviteToken', async () => { + const { req, res } = createMocks({ + method: 'POST', + body: {} + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.error).toContain('inviteToken'); + }); + + it('rejects invalid HTTP methods', async () => { + const { req, res } = createMocks({ + method: 'GET' + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(405); + }); + + it('rejects when invitation not found', async () => { + prisma.TeacherInvitation.findUnique.mockResolvedValue(null); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'nonexistent' + } + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + const data = JSON.parse(res._getData()); + expect(data.error).toContain('not found'); + }); + + it('rejects expired invitations', async () => { + const expiredDate = new Date(); + expiredDate.setDate(expiredDate.getDate() - 1); + + prisma.TeacherInvitation.findUnique.mockResolvedValue({ + id: '1', + status: 'PENDING', + expiresAt: expiredDate + }); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'expired-token' + } + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.error).toBe('Invitation expired'); + }); + + it('rejects revoked invitations', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + prisma.TeacherInvitation.findUnique.mockResolvedValue({ + id: '1', + status: 'REVOKED', + expiresAt: futureDate + }); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'revoked-token' + } + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.error).toBe('Invitation revoked'); + }); + + it('rejects already accepted invitations', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + prisma.TeacherInvitation.findUnique.mockResolvedValue({ + id: '1', + status: 'ACCEPTED', + expiresAt: futureDate + }); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'accepted-token' + } + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.error).toContain('already'); + expect(data.alreadyAccepted).toBe(true); + }); + + it('successfully accepts valid invitation', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + prisma.TeacherInvitation.findUnique.mockResolvedValue({ + id: '1', + invitedEmail: 'teacher@example.com', + status: 'PENDING', + expiresAt: futureDate, + userId: 'user-123' + }); + + inviteApiUtils.areEquivalentInviteEmails.mockReturnValue(true); + + prisma.TeacherInvitation.update.mockResolvedValue({ + id: '1', + status: 'ACCEPTED' + }); + + prisma.User.update.mockResolvedValue({ + role: 'TEACHER' + }); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'valid-token' + } + }); + + // Mock session + req.session = { + user: { + email: 'teacher@example.com' + } + }; + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.success).toBe(true); + expect(data.role).toBe('TEACHER'); + }); + + it('rejects invitation with email mismatch', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + prisma.TeacherInvitation.findUnique.mockResolvedValue({ + id: '1', + invitedEmail: 'teacher@example.com', + status: 'PENDING', + expiresAt: futureDate, + userId: 'user-123' + }); + + inviteApiUtils.areEquivalentInviteEmails.mockReturnValue(false); + + const { req, res } = createMocks({ + method: 'POST', + body: { + inviteToken: 'valid-token' + } + }); + + // Mock session with different email + req.session = { + user: { + email: 'different@example.com' + } + }; + + await handler(req, res); + + expect(res._getStatusCode()).toBe(403); + const data = JSON.parse(res._getData()); + expect(data.error).toContain('email mismatch'); + }); +}); diff --git a/pages/teacher/invite/[inviteToken].js b/pages/teacher/invite/[inviteToken].js new file mode 100644 index 000000000..6eb3ab7ec --- /dev/null +++ b/pages/teacher/invite/[inviteToken].js @@ -0,0 +1,254 @@ +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'; + +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 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; + } + + setSuccess(true); + // Redirect after success + setTimeout(() => { + router.push('/admin'); + }, 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'); + }; + + return ( + <> + + Accept Teacher Invitation - freeCodeCamp Classroom + + + +
+
+ {!userSession ? ( + <> +

+ Welcome to freeCodeCamp Classroom! +

+

+ You've been invited to join as a teacher. Sign in to accept + your invitation. +

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

+ ✓ Invitation Accepted! +

+

+ Welcome to freeCodeCamp Classroom! You're now a teacher. + Redirecting... +

+ + ) : ( + <> +

+ Accept Your Invitation +

+
+

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

+
+ + {error && ( +
+ {error} +
+ )} + + {!error && ( +

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

+ )} + + + + {error && ( +
+ +
+ )} + + )} + + +
+
+ + ); +} 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/inviteEmail.js b/util/inviteEmail.js new file mode 100644 index 000000000..d0b7159bc --- /dev/null +++ b/util/inviteEmail.js @@ -0,0 +1,148 @@ +import nodemailer from 'nodemailer'; + +/** + * Build the teacher invite URL + * @param {string} inviteToken - The invitation token + * @returns {string} The full invitation URL + */ +export function buildTeacherInviteUrl(inviteToken) { + const baseUrl = process.env.CLASSROOM_APP_BASE_URL || '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 {string} teacherEmail - The teacher's email address + * @param {string} inviteToken - The invitation token + * @param {string} adminName - The admin's name (optional, for personalization) + * @returns {Promise} Result with success flag and optional error + */ +export async function sendTeacherInvitationEmail( + teacherEmail, + inviteToken, + adminName = 'a freeCodeCamp admin' +) { + const transporter = createTransporter(); + if (!transporter) { + return { + success: false, + error: 'SMTP configuration is missing or invalid' + }; + } + + const inviteUrl = buildTeacherInviteUrl(inviteToken); + const senderEmail = getSenderEmail(); + + const mailOptions = { + from: senderEmail, + to: teacherEmail, + subject: 'Join freeCodeCamp Classroom as a Teacher', + text: `Hi there, + +${adminName} has invited you to join freeCodeCamp Classroom as a teacher! + +To accept this invitation, click the link below: +${inviteUrl} + +This link will expire in 14 days. + +If you don't have a freeCodeCamp account yet, you'll be able to create one during the acceptance process. + +Best regards, +The freeCodeCamp Team`, + html: ` + + + + + + + +
+

Welcome to freeCodeCamp Classroom!

+

${adminName} has invited you to join freeCodeCamp Classroom as a teacher.

+ +
+

To accept this invitation and get started, click the button below:

+ Accept Invitation +
+ +

Invitation Link:

+

${inviteUrl}

+ +

+ Note: This invitation link will expire in 14 days. +

+ +

+ If you don't have a freeCodeCamp account, you'll be able to create one when you accept this invitation using your invited email address. +

+ +
+

+ Best regards,
+ The freeCodeCamp Team +

+
+ + + ` + }; + + try { + const result = await transporter.sendMail(mailOptions); + return { + success: true, + messageId: result.messageId + }; + } catch (error) { + console.error('Error sending teacher invitation email:', error); + return { + success: false, + error: error.message + }; + } +} From 28ea0f48799cf64df1d6a04c6b4e411e454dc45e Mon Sep 17 00:00:00 2001 From: Carly Thomas Date: Mon, 11 May 2026 18:23:34 -0700 Subject: [PATCH 07/13] chore: remove test files with external dependencies --- .../classInviteTable.test.jsx.snap | 67 ------ ...adminTeacherOnboarding.integration.test.js | 209 ----------------- .../utils/teacherInviteAcceptApi.test.js | 218 ------------------ 3 files changed, 494 deletions(-) delete mode 100644 __tests__/utils/adminTeacherOnboarding.integration.test.js delete mode 100644 __tests__/utils/teacherInviteAcceptApi.test.js diff --git a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap index 55942c690..3763d8aa7 100644 --- a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap +++ b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap @@ -58,73 +58,6 @@ exports[`ClassInviteTable displays invites in a table 1`] = ` learn how to build a website in a jiffy -
-

- Student Email Invitations -

-

- Send, resend, and revoke student invites for this class. -

-
- - -
-
- - - - - - - - - - - - - - -
- Email - - Status - - Expires - - Actions -
- No student invitations yet. -
-
-