From a4e920411c485dd8395a014428583f89654327c8 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Wed, 27 May 2026 12:22:15 -0700 Subject: [PATCH 1/8] Added UI Elements for Student Onboarding Workflow Fixed Join Page and added instructions for enabling permission on FreeCodeCamp Proper TODO: Implement 'Connect' button functionality. This requires Mrugesh's PR #66063 to be implemented. Currently, the Connect button just adds the user to the Classroom without verification. --- pages/join/[...joinCode].js | 89 ++++++++++++++++++++++++------------- pages/join/index.js | 27 +++++++---- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index cf2065ab2..94acd1d3a 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -7,16 +7,35 @@ import AuthButton from '../../components/authButton'; import DisplayNotification from '../../components/displayNotification'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import prisma from '../../prisma/prisma'; export async function getServerSideProps(ctx) { const userSession = await getSession(ctx); + + const params = ctx.params || {}; + const joinParam = params.joinCode || null; + const classroomId = Array.isArray(joinParam) ? joinParam[0] : joinParam; + + let classroom = null; + if (classroomId) { + try { + classroom = await prisma.classroom.findUnique({ + where: { classroomId }, + select: { classroomName: true, description: true, classroomId: true } + }); + } catch (err) { + classroom = null; + } + } + return { props: { - userSession: userSession + userSession: userSession, + classroom } }; } -export default function JoinWithCode({ userSession }) { +export default function JoinWithCode({ userSession, classroom }) { const [formData] = useState({}); const router = useRouter(); const { joinCode } = router.query; @@ -63,50 +82,60 @@ export default function JoinWithCode({ userSession }) { {userSession ? ( <> -
-
+
+
-

- Register for Classroom +

+ Join Classroom

+ {classroom && ( +

+ Joining:{' '} + + {classroom.classroomName || classroom.classroomId} + +

+ )}
-
+
+
+

+ If you have not enabled Classroom access on freeCodeCamp, + please open your settings and enable it before connecting. +

+ +

+ Note: If you change the email on your freeCodeCamp account + later, you may need to reconnect to this classroom. +

+
) : ( <> -
-
+
+
-

+

Sign In with FreeCodeCamp

diff --git a/pages/join/index.js b/pages/join/index.js index 82e401616..43ed2676a 100644 --- a/pages/join/index.js +++ b/pages/join/index.js @@ -1,21 +1,32 @@ import Layout from '../../components/layout'; import Head from 'next/head'; import Navbar from '../../components/navbar'; +import Link from 'next/link'; export default function Join() { return ( - Create Next App - + Join a Classroom + - -
  • -

    Welcome

    -
  • -
    - No code given + +
    +
    +

    No join code provided

    +

    + To join a Classroom, you must open the unique join link provided by + your instructor. The link should look like{' '} + /join/<classroomId>. +

    + + + Return to Home + + +
    +
    ); } From 9f6ec56fc68142dda66aec1d391371e2a831bc38 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Fri, 29 May 2026 23:05:14 -0700 Subject: [PATCH 2/8] Fix Undefined classroom link --- components/ClassInviteTable.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/ClassInviteTable.js b/components/ClassInviteTable.js index 5501a308a..f0ea0367c 100644 --- a/components/ClassInviteTable.js +++ b/components/ClassInviteTable.js @@ -30,12 +30,11 @@ export default function ClassInviteTable({ ); const ref = useRef(); - const userCurrentDomain = process.env.NEXTAUTH_URL; const copy = async () => { //Add the full URL to send to student await navigator.clipboard.writeText( - `${userCurrentDomain}/join/` + currentClass.classroomId + `${window.location.origin}/join/` + currentClass.classroomId ); toast('Class code successfully copied', { From c88b83b2def56cf952bc0953e57f42520c63fe74 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 30 May 2026 18:14:17 -0700 Subject: [PATCH 3/8] Added 'Classroom Not Found' error page Previously, when a user enters a join link for a classroom that does not exist, it would allow the user to enter the Join page and fail silently. --- pages/join/[...joinCode].js | 50 +++++++++++++++++++++++++------------ pages/join/index.js | 4 +-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index 94acd1d3a..0fc8390af 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -1,4 +1,5 @@ import Head from 'next/head'; +import Link from 'next/link'; import Navbar from '../../components/navbar'; import { useState } from 'react'; import { useRouter } from 'next/router'; @@ -80,7 +81,39 @@ export default function JoinWithCode({ userSession, classroom }) { - {userSession ? ( + {!userSession ? ( + <> +
    +
    +
    +

    + Sign In with FreeCodeCamp +

    +
    +
    + +
    +
    +
    + + ) : classroom === null ? ( +
    +
    +

    + Classroom Not Found +

    +

    + We could not find a classroom for this invite link. Please check + the link or ask your teacher to resend the invite. +

    +
    + + Back to Home + +
    +
    +
    + ) : ( <>
    @@ -130,21 +163,6 @@ export default function JoinWithCode({ userSession, classroom }) {
    - ) : ( - <> -
    -
    -
    -

    - Sign In with FreeCodeCamp -

    -
    -
    - -
    -
    -
    - )}
    diff --git a/pages/join/index.js b/pages/join/index.js index 43ed2676a..7e87c5df4 100644 --- a/pages/join/index.js +++ b/pages/join/index.js @@ -12,8 +12,8 @@ export default function Join() { -
    -
    +
    +

    No join code provided

    To join a Classroom, you must open the unique join link provided by From ac67a05492c14b47eb35e9d4d81b80bcf298409f Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 30 May 2026 18:32:53 -0700 Subject: [PATCH 4/8] Added 'Sign in with FreeCodeCamp' case to Join Index page --- pages/join/index.js | 51 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/pages/join/index.js b/pages/join/index.js index 7e87c5df4..428f4e537 100644 --- a/pages/join/index.js +++ b/pages/join/index.js @@ -2,8 +2,10 @@ import Layout from '../../components/layout'; import Head from 'next/head'; import Navbar from '../../components/navbar'; import Link from 'next/link'; +import AuthButton from '../../components/authButton'; +import { getSession } from 'next-auth/react'; -export default function Join() { +export default function Join({ userSession }) { return ( @@ -12,21 +14,46 @@ export default function Join() { +

    -

    No join code provided

    -

    - To join a Classroom, you must open the unique join link provided by - your instructor. The link should look like{' '} - /join/<classroomId>. -

    - - - Return to Home - - + {!userSession ? ( +
    +
    +

    + Sign In with FreeCodeCamp +

    +
    + +
    +
    +
    + ) : ( +
    +

    No join code provided

    +

    + To join a Classroom, you must open the unique join link provided + by your instructor. The link should look like{' '} + /join/<classroomId>. +

    + + + Return to Home + + +
    + )}
    ); } + +export async function getServerSideProps(ctx) { + const session = await getSession(ctx); + return { + props: { + userSession: session || null + } + }; +} From 70817ee5831986c899de9cb7739e54d3e88ded8a Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Mon, 1 Jun 2026 15:42:31 -0700 Subject: [PATCH 5/8] Implemented Connect button functionality Now calls get-user-id endpoint in FreeCodeCamp Proper Disables Connect button upon successfully connecting to classroom Adds user to Classroom and adds user's FCC Proper ID to their account. --- pages/api/student_email_join.js | 161 +++++++++++++++++++++++++------- pages/join/[...joinCode].js | 76 +++++++++++---- 2 files changed, 188 insertions(+), 49 deletions(-) diff --git a/pages/api/student_email_join.js b/pages/api/student_email_join.js index 738b4fe20..20330750e 100644 --- a/pages/api/student_email_join.js +++ b/pages/api/student_email_join.js @@ -6,58 +6,153 @@ export default async function handle(req, res) { // unstable_getServerSession is recommended here: https://next-auth.js.org/configuration/nextjs const session = await unstable_getServerSession(req, res, authOptions); - if (!req.method == 'PUT') { - res.status(405).end(); + if (req.method !== 'PUT') { + return res.status(405).json({ + success: false, + error: 'METHOD_NOT_ALLOWED', + message: 'Only PUT requests are allowed.' + }); } - if (!session) { - res.status(403).end(); + if (!session?.user?.email) { + return res.status(403).json({ + success: false, + error: 'NOT_AUTHENTICATED', + message: 'You must be signed in to connect to a classroom.' + }); + } + + const body = req.body || {}; + const classroomId = body.classroomId || body.join?.[0] || body.join; + + if (!classroomId || typeof classroomId !== 'string') { + return res.status(400).json({ + success: false, + error: 'INVALID_CLASSROOM_ID', + message: 'A valid classroomId is required.' + }); } - const body = req.body; - // Grab user info here const userInfo = await prisma.user.findUnique({ where: { email: session.user.email }, select: { - id: true + id: true, + role: true, + fccProperUserId: true } }); - // Grab class info here - const checkClass = await prisma.classroom.findUniqueOrThrow({ + + if (!userInfo) { + return res.status(404).json({ + success: false, + error: 'USER_NOT_FOUND', + message: 'Could not find your Classroom user record.' + }); + } + + const classroom = await prisma.classroom.findUnique({ where: { - classroomId: body.join[0] + classroomId }, select: { fccUserIds: true } }); - const existsInClassroom = checkClass.fccUserIds.includes(userInfo.id); - if (existsInClassroom) { - res.status(409).end(); - } - // TODO: Once we allow multiple teachers inside of a classroom, make sure that the teachers - // are placed inside of the teacher array rather than as a regular student - else if (userInfo.role === 'NONE') { - // This runs only when a new user attempts to join a classroom. - await prisma.user.update({ - where: { - email: session.user.email - }, - data: { - role: 'STUDENT' - } + + if (!classroom) { + return res.status(404).json({ + success: false, + error: 'CLASSROOM_NOT_FOUND', + message: 'We could not find that classroom.' }); } - // Update calssroom with user id - await prisma.classroom.update({ - where: { - classroomId: body.join[0] + + const fccUserIdEndpoint = + process.env.FCC_GET_USER_ID_URL || + 'http://localhost:3000/apps/classroom/get-user-id'; + const fccUserIdToken = + process.env.FCC_GET_USER_ID_TOKEN || process.env.TPA_API_BEARER_TOKEN; + + if (!fccUserIdToken) { + return res.status(500).json({ + success: false, + error: 'FCC_TOKEN_MISSING', + message: 'FCC bearer token is not configured.' + }); + } + + const fccResponse = await fetch(fccUserIdEndpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${fccUserIdToken}`, + 'Content-Type': 'application/json' }, - data: { - fccUserIds: { push: userInfo.id } - } + body: JSON.stringify({ + email: session.user.email + }) + }); + + let fccPayload = null; + try { + fccPayload = await fccResponse.json(); + } catch (error) { + fccPayload = null; + } + + const fccProperUserId = fccPayload?.userId?.trim?.() || ''; + + if (!fccResponse.ok || !fccProperUserId) { + return res.status(502).json({ + success: false, + error: 'FCC_ID_NOT_FOUND', + message: + 'Email not found on FCC Proper or Classroom permission is not enabled.' + }); + } + + const updates = []; + + if ( + userInfo.fccProperUserId !== fccProperUserId || + userInfo.role === 'NONE' + ) { + updates.push( + prisma.user.update({ + where: { + email: session.user.email + }, + data: { + fccProperUserId, + ...(userInfo.role === 'NONE' ? { role: 'STUDENT' } : {}) + } + }) + ); + } + + if (!classroom.fccUserIds.includes(userInfo.id)) { + updates.push( + prisma.classroom.update({ + where: { + classroomId + }, + data: { + fccUserIds: { push: userInfo.id } + } + }) + ); + } + + if (updates.length > 0) { + await prisma.$transaction(updates); + } + + return res.status(200).json({ + success: true, + fccProperUserId, + classroomAppUserId: userInfo.id, + alreadyLinked: userInfo.fccProperUserId === fccProperUserId, + alreadyInRoster: classroom.fccUserIds.includes(userInfo.id) }); - res.status(200).end(); } diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index 0fc8390af..fc96febcf 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -18,12 +18,33 @@ export async function getServerSideProps(ctx) { const classroomId = Array.isArray(joinParam) ? joinParam[0] : joinParam; let classroom = null; + let userIsConnected = false; if (classroomId) { try { classroom = await prisma.classroom.findUnique({ where: { classroomId }, - select: { classroomName: true, description: true, classroomId: true } + select: { + classroomName: true, + description: true, + classroomId: true, + fccUserIds: true + } }); + + if (userSession?.user?.email && classroom) { + const userInfo = await prisma.user.findUnique({ + where: { + email: userSession.user.email + }, + select: { + id: true + } + }); + + userIsConnected = Boolean( + userInfo && classroom.fccUserIds.includes(userInfo.id) + ); + } } catch (err) { classroom = null; } @@ -32,33 +53,48 @@ export async function getServerSideProps(ctx) { return { props: { userSession: userSession, - classroom + classroom, + userIsConnected } }; } -export default function JoinWithCode({ userSession, classroom }) { - const [formData] = useState({}); +export default function JoinWithCode({ + userSession, + classroom, + userIsConnected +}) { const router = useRouter(); const { joinCode } = router.query; + const classroomId = Array.isArray(joinCode) ? joinCode[0] : joinCode; + const [isConnected, setIsConnected] = useState(Boolean(userIsConnected)); const classroomRequest = async event => { event.preventDefault(); - formData.join = joinCode; + + if (!classroomId) { + DisplayNotification('Error', 'No classroom link was provided.'); + return; + } + try { const res = await fetch(`/api/student_email_join`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) + body: JSON.stringify({ classroomId }) }); - if (res.status === 409) { - DisplayNotification('Error', 'You have already joined this classroom.'); - } else { + + const payload = await res.json().catch(() => null); + + if (!res.ok || !payload?.success) { DisplayNotification( - 'Success', - 'Congrats! You are now enrolled in this class.' + 'Error', + payload?.message || 'Sorry, we could not connect your account.' ); + } else { + setIsConnected(true); + DisplayNotification('Success', 'Your FCC account is now connected.'); } } catch (error) { DisplayNotification( @@ -123,10 +159,7 @@ export default function JoinWithCode({ userSession, classroom }) { {classroom && (

    - Joining:{' '} - - {classroom.classroomName || classroom.classroomId} - + Joining: {classroom.classroomName}

    )}
    @@ -134,12 +167,23 @@ export default function JoinWithCode({ userSession, classroom }) {
    + {isConnected && ( +

    + You're connected to{' '} + {classroom.classroomName}! +

    + )}

    If you have not enabled Classroom access on freeCodeCamp, From 3844e91a346a238b68fc8c420581ba4dcc9321ae Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Wed, 24 Jun 2026 19:51:06 -0700 Subject: [PATCH 6/8] Added test suite for get-user-id and get-user-data calls This adds get-user-id calls on top of the get-user-data ones in PR 602. This assumes that PR 602 was successfully merged first. --- __tests__/utils/fccApi.test.js | 379 +++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 __tests__/utils/fccApi.test.js diff --git a/__tests__/utils/fccApi.test.js b/__tests__/utils/fccApi.test.js new file mode 100644 index 000000000..36cf20dab --- /dev/null +++ b/__tests__/utils/fccApi.test.js @@ -0,0 +1,379 @@ +global.fetch = jest.fn(); + +// Must be declared before require() calls so jest hoisting can intercept +// the dynamic import inside fetchClassroomStudentData. +jest.mock('../../util/challengeMapUtils', () => ({ + resolveAllStudentsToDashboardFormat: jest.fn(emailKeyedData => + Object.entries(emailKeyedData).map(([email, challenges]) => ({ + email, + certifications: challenges + })) + ) +})); + +const { fetchUserIdByEmail, fetchUserData } = require('../../util/fcc-api'); +const { + fetchClassroomStudentData +} = require('../../util/student/fetchStudentData'); +const { + resolveAllStudentsToDashboardFormat +} = require('../../util/challengeMapUtils'); + +describe('FCC API handshake', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.FCC_API_URL = 'http://localhost:3000'; + process.env.TPA_API_BEARER_TOKEN = 'test-bearer-token'; + }); + + afterEach(() => { + delete process.env.FCC_API_URL; + delete process.env.TPA_API_BEARER_TOKEN; + }); + + // --------------------------------------------------------------------------- + // fetchUserIdByEmail — /apps/classroom/get-user-id + // --------------------------------------------------------------------------- + describe('fetchUserIdByEmail', () => { + it('POSTs to the correct get-user-id endpoint with the email in the body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ userId: 'abc123' }) + }); + + await fetchUserIdByEmail('student@example.com'); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/apps/classroom/get-user-id', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'student@example.com' }) + }) + ); + }); + + it('includes the Bearer token in the Authorization header', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ userId: 'abc123' }) + }); + + await fetchUserIdByEmail('student@example.com'); + + const [, options] = global.fetch.mock.calls[0]; + expect(options.headers['Authorization']).toBe('Bearer test-bearer-token'); + expect(options.headers['Content-Type']).toBe('application/json'); + }); + + it('returns the userId from the response', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ userId: 'user_objectid_abc123' }) + }); + + const result = await fetchUserIdByEmail('student@example.com'); + + expect(result).toEqual({ userId: 'user_objectid_abc123' }); + }); + + it('returns an empty string userId when the user has not opted in to classroom mode', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ userId: '' }) + }); + + const result = await fetchUserIdByEmail('nooptin@example.com'); + + expect(result.userId).toBe(''); + }); + + it('throws when TPA_API_BEARER_TOKEN env var is missing', async () => { + delete process.env.TPA_API_BEARER_TOKEN; + + await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow( + 'TPA_API_BEARER_TOKEN is not set' + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('throws when the FCC API returns a non-ok response', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ error: 'Invalid token' }) + }); + + await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow( + 'Failed to look up fCC user ID' + ); + }); + + it('throws when the FCC API returns a 500 with no parseable JSON body', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => { + throw new SyntaxError('not json'); + } + }); + + await expect(fetchUserIdByEmail('student@example.com')).rejects.toThrow( + 'Failed to look up fCC user ID' + ); + }); + }); + + // --------------------------------------------------------------------------- + // fetchUserData — /apps/classroom/get-user-data + // --------------------------------------------------------------------------- + describe('fetchUserData', () => { + it('returns { data: {} } immediately for an empty userIds array without hitting the network', async () => { + const result = await fetchUserData([]); + + expect(result).toEqual({ data: {} }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns { data: {} } for null userIds without hitting the network', async () => { + const result = await fetchUserData(null); + + expect(result).toEqual({ data: {} }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('POSTs to the correct get-user-data endpoint with userIds in the body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { uid1: [] } }) + }); + + await fetchUserData(['uid1', 'uid2']); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/apps/classroom/get-user-data', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ userIds: ['uid1', 'uid2'] }) + }) + ); + }); + + it('includes the Bearer token in the Authorization header', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(['uid1']); + + const [, options] = global.fetch.mock.calls[0]; + expect(options.headers['Authorization']).toBe('Bearer test-bearer-token'); + }); + + it('returns challenge completion data keyed by fCC user ID', async () => { + const mockData = { + uid1: [{ id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 }] + }; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: mockData }) + }); + + const result = await fetchUserData(['uid1']); + + expect(result.data).toEqual(mockData); + }); + + it('splits requests into batches of 50 when more than 50 user IDs are provided', async () => { + const userIds = Array.from({ length: 55 }, (_, i) => `uid${i}`); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(userIds); + + expect(global.fetch).toHaveBeenCalledTimes(2); + const firstBatchBody = JSON.parse(global.fetch.mock.calls[0][1].body); + const secondBatchBody = JSON.parse(global.fetch.mock.calls[1][1].body); + expect(firstBatchBody.userIds).toHaveLength(50); + expect(secondBatchBody.userIds).toHaveLength(5); + }); + + it('sends exactly one request when userIds length equals the batch size limit (50)', async () => { + const userIds = Array.from({ length: 50 }, (_, i) => `uid${i}`); + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: {} }) + }); + + await fetchUserData(userIds); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('merges batch results into a single data object', async () => { + const userIds = Array.from({ length: 55 }, (_, i) => `uid${i}`); + const batch1Data = Object.fromEntries( + userIds.slice(0, 50).map(id => [id, []]) + ); + const batch2Data = Object.fromEntries( + userIds.slice(50).map(id => [id, []]) + ); + + global.fetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: batch1Data }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: batch2Data }) + }); + + const result = await fetchUserData(userIds); + + expect(Object.keys(result.data)).toHaveLength(55); + expect(result.data).toMatchObject(batch1Data); + expect(result.data).toMatchObject(batch2Data); + }); + + it('throws when the FCC API returns a non-ok response', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({ error: 'Forbidden' }) + }); + + await expect(fetchUserData(['uid1'])).rejects.toThrow( + 'Failed to fetch student data from fCC API' + ); + }); + }); + + // --------------------------------------------------------------------------- + // fetchClassroomStudentData — orchestrates fetchUserData + dashboard mapping + // --------------------------------------------------------------------------- + describe('fetchClassroomStudentData', () => { + it('returns an empty array when no students have a linked fccProperUserId', async () => { + const students = [ + { id: 's1', email: 'a@test.com', fccProperUserId: null }, + { id: 's2', email: 'b@test.com', fccProperUserId: null } + ]; + + const result = await fetchClassroomStudentData(students); + + expect(result).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('only requests data for students who have a linked fccProperUserId', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { 'fcc-uid-1': [] } }) + }); + + const students = [ + { id: 's1', email: 'linked@test.com', fccProperUserId: 'fcc-uid-1' }, + { id: 's2', email: 'unlinked@test.com', fccProperUserId: null } + ]; + + await fetchClassroomStudentData(students); + + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.userIds).toEqual(['fcc-uid-1']); + }); + + it('translates fccProperUserId back to student email before passing data to the dashboard formatter', async () => { + const completedChallenges = [ + { id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 } + ]; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { 'fcc-uid-1': completedChallenges } + }) + }); + + const students = [ + { + id: 's1', + email: 'student@test.com', + fccProperUserId: 'fcc-uid-1' + } + ]; + + await fetchClassroomStudentData(students); + + expect(resolveAllStudentsToDashboardFormat).toHaveBeenCalledWith( + expect.objectContaining({ + 'student@test.com': completedChallenges + }) + ); + }); + + it('does not include fCC user IDs as keys in the dashboard formatter input', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + data: { 'fcc-uid-1': [] } + }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + await fetchClassroomStudentData(students); + + const formatterArg = resolveAllStudentsToDashboardFormat.mock.calls[0][0]; + // Use Object.keys to avoid Jest's dot-notation interpretation of email addresses + expect(Object.keys(formatterArg)).not.toContain('fcc-uid-1'); + expect(Object.keys(formatterArg)).toContain('student@test.com'); + }); + + it('returns the dashboard-formatted result from resolveAllStudentsToDashboardFormat', async () => { + const challenges = [ + { id: 'bd7123c8c441eddfaeb5bdef', completedDate: 1700000000000 } + ]; + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: { 'fcc-uid-1': challenges } }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + const result = await fetchClassroomStudentData(students); + + // Our mock resolveAllStudentsToDashboardFormat returns { email, certifications: challenges } + expect(result).toEqual([ + { email: 'student@test.com', certifications: challenges } + ]); + }); + + it('drops fCC user IDs that do not map to any classroom student email', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ + // FCC returns an extra ID not in our students list + data: { 'fcc-uid-1': [], 'unexpected-uid': [] } + }) + }); + + const students = [ + { id: 's1', email: 'student@test.com', fccProperUserId: 'fcc-uid-1' } + ]; + + await fetchClassroomStudentData(students); + + const formatterArg = resolveAllStudentsToDashboardFormat.mock.calls[0][0]; + expect(Object.keys(formatterArg)).toEqual(['student@test.com']); + }); + }); +}); From a65655e860ad7b9f56bf8dffd72bd6789c4dc7af Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 30 Jun 2026 18:35:05 -0700 Subject: [PATCH 7/8] Updated fetch to use fetchUserIdByEmail helper function Added error comments Added (as a Student) label to Join Classroom heading if your role is TEACHER --- pages/api/student_email_join.js | 47 +++++++++++---------------------- pages/join/[...joinCode].js | 16 ++++++++--- 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/pages/api/student_email_join.js b/pages/api/student_email_join.js index 7280941ca..93e882807 100644 --- a/pages/api/student_email_join.js +++ b/pages/api/student_email_join.js @@ -70,41 +70,26 @@ export default async function handle(req, res) { }); } - const fccUserIdEndpoint = - process.env.FCC_GET_USER_ID_URL || - 'http://localhost:3000/apps/classroom/get-user-id'; - const fccUserIdToken = - process.env.FCC_GET_USER_ID_TOKEN || process.env.TPA_API_BEARER_TOKEN; - - if (!fccUserIdToken) { - return res.status(500).json({ + let fccProperUserId = ''; + try { + const { userId } = await fetchUserIdByEmail(session.user.email); + fccProperUserId = userId?.trim() || ''; + } catch (err) { + // fetchUserIdByEmail threw — the FCC API returned a non-ok response + // (e.g. 401 bad token, 500 server error, or a network failure). + return res.status(502).json({ success: false, - error: 'FCC_TOKEN_MISSING', - message: 'FCC bearer token is not configured.' + error: 'FCC_ID_NOT_FOUND', + message: + 'Email not found on FCC Proper or Classroom permission is not enabled.' }); } - const fccResponse = await fetch(fccUserIdEndpoint, { - method: 'POST', - headers: { - Authorization: `Bearer ${fccUserIdToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: session.user.email - }) - }); - - let fccPayload = null; - try { - fccPayload = await fccResponse.json(); - } catch (error) { - fccPayload = null; - } - - const fccProperUserId = fccPayload?.userId?.trim?.() || ''; - - if (!fccResponse.ok || !fccProperUserId) { + // Separate from the error above: the FCC API returned 200 but with an empty + // userId, which means the account exists but the user has not enabled + // Classroom mode on their fCC account. fetchUserIdByEmail does not throw in + // this case, so the try/catch above does not catch it. + if (!fccProperUserId) { return res.status(502).json({ success: false, error: 'FCC_ID_NOT_FOUND', diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index fc96febcf..fea0e07b4 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -19,6 +19,7 @@ export async function getServerSideProps(ctx) { let classroom = null; let userIsConnected = false; + let userRole = null; if (classroomId) { try { classroom = await prisma.classroom.findUnique({ @@ -37,13 +38,15 @@ export async function getServerSideProps(ctx) { email: userSession.user.email }, select: { - id: true + id: true, + role: true } }); userIsConnected = Boolean( userInfo && classroom.fccUserIds.includes(userInfo.id) ); + userRole = userInfo?.role ?? null; } } catch (err) { classroom = null; @@ -54,14 +57,16 @@ export async function getServerSideProps(ctx) { props: { userSession: userSession, classroom, - userIsConnected + userIsConnected, + userRole } }; } export default function JoinWithCode({ userSession, classroom, - userIsConnected + userIsConnected, + userRole }) { const router = useRouter(); const { joinCode } = router.query; @@ -156,6 +161,11 @@ export default function JoinWithCode({

    Join Classroom + {userRole === 'TEACHER' && ( + + (as a Student) + + )}

    {classroom && (

    From 2f359bde1b9a51033f0e6965373b1fcfb4a742a3 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 30 Jun 2026 18:53:54 -0700 Subject: [PATCH 8/8] Show (as a Student) for ADMIN roles too --- pages/join/[...joinCode].js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index fea0e07b4..ddf7065e1 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -161,7 +161,7 @@ export default function JoinWithCode({

    Join Classroom - {userRole === 'TEACHER' && ( + {(userRole === 'TEACHER' || userRole === 'ADMIN') && ( (as a Student)