Skip to content

feat(fe): add student roster and enrollment verification to course join - #3652

Open
jinukkkim wants to merge 6 commits into
mainfrom
t2787-course-enrollment-security
Open

feat(fe): add student roster and enrollment verification to course join#3652
jinukkkim wants to merge 6 commits into
mainfrom
t2787-course-enrollment-security

Conversation

@jinukkkim

@jinukkkim jinukkkim commented Jul 21, 2026

Copy link
Copy Markdown

Description

강의 입장 보안 개선 (교수님 요청: 학번만으로는 계정 이름이 영문으로 표시되어 누구인지 알아보기 어려움).

프론트엔드 전용 PR. 백엔드가 아직 구현되지 않아, 리뷰어가 preview에서 흐름을 직접 확인할 수 있도록 임시 mock을 함께 포함했습니다. 자세한 내용은 아래 "Preview 데모용 임시 mock" 참고.

구현 내용

1. Student Roster 입력 (교수/관리자)

  • Create / Edit / Duplicate Course 폼에 Students 섹션과 Student Roster 팝업 추가 (StudentRosterModal.tsx, 신규).
  • 학번+이름을 한 쌍으로 입력하는 표. Excel/Sheets에서 복사한 텍스트를 붙여넣으면 tab/개행 기준으로 파싱해 여러 행·열에 한 번에 채웁니다. 기본 5행, 직접 입력·행 추가·전체 삭제 지원.
  • 저장 시 학번이 빈 행은 제외하고 createWhitelist(groupId, studentIds, names)로 전송.
  • 기존 .xlsx 업로드 방식은 대체하지 않고 Member 탭의 초대 흐름(InviteByCode)에 그대로 유지됩니다. 즉 현재 화이트리스트 입력 경로가 두 개입니다 (신규 로스터 팝업 = 학번+이름, 기존 업로드 = 학번만). 통합 여부는 별도 논의가 필요합니다.

2. 멤버 목록에 로스터 이름 표시

  • 강의 멤버 목록에서 계정 이름 대신 교수님이 로스터에 입력한 이름을 우선 표시하고, 없으면 계정 이름으로 fallback (GroupTable.tsx).
  • 현재는 getWhitelistEntries를 별도 쿼리로 호출해 프론트에서 학번 기준으로 병합합니다. 이 쿼리는 errorPolicy: 'ignore'로 호출하므로, 실패하거나 백엔드가 아직 지원하지 않으면 전원 계정 이름으로 표시될 뿐 목록 자체는 깨지지 않습니다.

3. 수강생 측 "Verify Your Enrollment" 흐름 (RegisterCourseButton.tsx)

  • 초대코드 입력 → 서버가 계정 학번으로 자동 확인(stage=auto) → 화이트리스트 불일치면 학번 직접 입력 화면(stage=manual)으로 전환 → 성공/잠금 화면.
  • 남은 시도 횟수와 잠금 여부는 전적으로 백엔드 응답을 그대로 표시합니다. 프론트는 횟수를 세지 않습니다 (attemptsRemaining을 문구에 그대로 출력, 마지막 1회일 때만 별도 문구).
  • 403 이외/reason 없는 응답은 409(이미 가입), 404(강의 없음), 403(권한 없음), 기타로 나눠 일반 에러 화면 처리.

공용 컴포넌트 변경 (리뷰 시 확인 필요)

로스터 모달 레이아웃을 맞추면서 다른 화면에도 영향을 주는 파일 두 개를 건드렸습니다:

파일 변경 영향
components/Modal.tsx ScrollArea에 min-h-0 추가 Modal을 쓰는 모든 화면
app/globals.css 전역 shake keyframe 추가 신규 추가이므로 기존 영향 없음 (학번 오입력 시 입력창 흔들림에 사용)

백엔드에 필요한 구현

이 PR의 프론트엔드 코드가 전제하고 있는 계약입니다.

  • GroupWhitelist에 nullable name 컬럼 추가
  • createWhitelist(groupId, studentIds, names)names: [String!] 인자 추가 (studentIds와 같은 순서, nullable)
  • getWhitelistEntries(groupId): [GroupWhitelist!]! 쿼리 추가 (Edit Course 로스터 prefill + 멤버 목록 이름 병합용)
  • POST /course/:groupId/joinstage(auto/manual), studentId 쿼리 파라미터 추가
  • 위 join 엔드포인트의 403 응답 body:
    • { reason: 'WHITELIST_VIOLATION', attemptsRemaining?: number }
    • { reason: 'ENROLLMENT_LOCKED' }
    • 시도 횟수 제한/잠금 정책(현재 mock은 3회)과 잠금 해제 조건은 백엔드에서 결정해야 합니다.
  • (선택) getGroupMembers 응답에 로스터 이름을 직접 병합해주면, 프론트의 별도 쿼리 + 클라이언트 병합을 제거할 수 있습니다.

Preview 데모용 임시 mock

백엔드가 없어 preview에서 흐름을 볼 수 없었기 때문에, 기존 기능 코드는 건드리지 않고 mock 파일만 추가했습니다.

  • mock-schema.graphql — codegen이 검증에 사용할 스키마 stub. codegen.tsschema를 배열로 바꿔 실제 schema.gql과 병합합니다. 이것만으로는 빌드/타입체크만 통과하고 런타임 동작은 하지 않습니다.
  • mocks/enrollment-demo-* — MSW로 join 요청과 whitelist GraphQL 3개만 가로채 실제 응답 형태를 흉내냅니다. 나머지 요청은 전부 실제 백엔드로 통과합니다. 계정 전환에도 유지되도록 roster를 localStorage에 저장하고, 잠금은 JWT의 userId로 분리해 한 사용자의 실패가 다른 사용자를 잠그지 않습니다.
  • next.config.ts에서 next-pwa를 껐습니다 — PWA가 scope //sw.js를 등록하는데 MSW의 /mockServiceWorker.js도 같은 scope가 필요해, 프로덕션 빌드에서 PWA 워커가 이기면 mock이 동작하지 않기 때문입니다.

preview 확인 방법

  1. instructor로 로그인
  2. Management - Course에서 새 강좌 생성 또는 기존 강좌 수정 → roster에 임의의 학번과 이름 입력하고 저장
  3. Member - Invite - "Invite by Invitation Code" 켜서 초대코드 복사
  4. (계정 변경할 필요 없음) course 페이지 - Register → 방금 복사한 초대코드 입력 → "Verify Your Enrollment" 화면
  5. 틀린 학번 입력 → 재시도 카운트다운 → 3번 틀리면 lockout
  6. (lockout된 강좌는 재시도 안 되니, 성공 케이스는 다른 계정으로) 학번을 정확히 입력 → 성공

백엔드 merge 후 해야 할 일

  • 백엔드 계약(위 "백엔드에 필요한 구현")이 main에 merge되고 apps/backend/schema.gql이 갱신됐는지 확인
  • mock 파일 삭제
    • apps/frontend/mock-schema.graphql
    • apps/frontend/mocks/enrollment-demo-store.ts
    • apps/frontend/mocks/enrollment-demo-handlers.ts
    • apps/frontend/mocks/enrollment-demo-worker.ts
    • apps/frontend/instrumentation-client.ts
    • apps/frontend/public/mockServiceWorker.js
  • 설정 원복
    • apps/frontend/codegen.tsschema'../backend/schema.gql' 문자열로 되돌리기
    • apps/frontend/next.config.tsdisable: truedisable: process.env.NODE_ENV === 'development'
    • apps/frontend/package.jsonmsw.workerDirectory 필드 제거
    • .prettierignore / eslint.config.mjsmockServiceWorker.js ignore 항목 제거
    • (msw 자체는 원래 vitest용 devDependency이므로 제거하지 말 것)
  • 실제 백엔드로 재검증 (mock이 가려주던 부분이라 여기서 처음 드러납니다)
    • 로스터 저장 후 Edit Course 재진입 시 prefill 되는지
    • 멤버 목록에 로스터 이름이 뜨는지 (계정 이름 fallback 포함)
    • Duplicate Course 시 로스터가 복사되는지
    • 자동 확인(stage=auto) → 학번 입력(stage=manual) 전환이 실제 403 reason으로 동작하는지
    • 시도 횟수 문구가 백엔드의 attemptsRemaining과 맞는지, 잠금 후 재시도가 막히는지
    • 로스터가 없는 강의는 기존처럼 바로 가입되는지 (회귀 확인)
  • 백엔드가 getGroupMembers에 이름을 병합해준다면 GroupTable.tsx의 별도 쿼리·클라이언트 병합 제거
  • 테스트 추가 (아래 체크박스 미완료 상태)
  • Modal.tsxmin-h-0 변경이 다른 화면에 회귀를 만들지 않았는지 확인

Additional context

  • CI의 typecheck / build-frontend는 mock-schema.graphql 덕분에 통과합니다. 이 파일을 지우면 백엔드 스키마가 갱신되기 전까지 다시 실패합니다.
  • 화이트리스트 입력 경로가 두 개(신규 로스터 팝업 / 기존 xlsx 업로드)로 공존합니다. 통합할지 여부 의견 주시면 반영하겠습니다.

Before submitting the PR, please make sure you do the following

Closes TAS-2787

Frontend-only implementation of student roster (name+studentId) editing
in Create/Edit/Duplicate Course, and the Verify Your Enrollment flow in
RegisterCourseButton. Assumes a backend contract (GroupWhitelist.name,
createWhitelist names arg, getWhitelistEntries query, join stage/studentId
params) that isn't implemented yet. Checkpoint before temporarily adding
matching backend code locally to verify end-to-end, to be reverted after.
강의 멤버 목록에서 계정 이름 대신 교수님이 로스터에 입력한 이름을
우선 표시한다. getWhitelistEntries를 학번 기준으로 매칭하고,
이름이 없거나 조회가 실패하면 기존 계정 이름으로 fallback한다.
로컬 e2e 검증 중 발견한 버그를 수정한다.
valibotResolver가 courseSchema에 없는 roster 필드를 제출 데이터에서
지워버려 Create/Edit Course에서 로스터가 누락되는 문제를 getValues로
우회한다. Verify Your Enrollment 모달이 size="sm"일 때 시도 횟수
안내문이 잘려 보이지 않던 문제와 Student Roster의 Clear all이 초기
행 수(5개)가 아닌 1개로 초기화되던 문제도 함께 수정한다.

RegisterCourseButton은 실제 API 에러 응답(409 already joined, 404
group not found, 403 not authorized)에 맞춰 안내 문구를 세분화한다.
@jinukkkim jinukkkim added ⛳️ team-frontend preview 이 라벨이 붙어있어야 프론트엔드 Preview 환경이 생성됩니다 labels Jul 21, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a student roster (whitelist) feature for courses, allowing instructors to upload a list of student IDs and names during course creation, duplication, and editing. It also refactors the course registration button into a multi-step dialog that verifies student IDs against the roster. The review feedback highlights several key improvement opportunities: de-duplicating student IDs in the roster modal before saving, using unique keys instead of array indices for dynamic table rows to prevent rendering bugs, and adding client-side validation to prevent sending empty or whitespace-only invitation codes and student IDs to the server.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +120 to +123
onClick: () => {
onSave(draft.filter((row) => row.studentId.trim()))
onOpenChange(false)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

복사-붙여넣기 과정에서 실수로 중복된 학번(Student ID)이 포함될 수 있습니다. 중복된 학번이 백엔드로 전송되면 데이터 무결성 제약 조건 등으로 인해 에러가 발생할 수 있으므로, 저장 시점에 학번을 기준으로 중복을 제거(De-duplicate)하는 로직을 추가하는 것이 안전합니다.

        onClick: () => {
          const seen = new Set<string>()
          const uniqueRows = draft.filter((row) => {
            const trimmedId = row.studentId.trim()
            if (!trimmedId || seen.has(trimmedId)) {
              return false
            }
            seen.add(trimmedId)
            return true
          })
          onSave(uniqueRows)
          onOpenChange(false)
        }

Comment on lines +164 to +165
{draft.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-[#eeeef1]">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

동적으로 행을 추가하거나 삭제할 수 있는 테이블에서 배열의 rowIndexkey로 사용하면, 행 삭제 시 React가 컴포넌트 상태를 잘못 매핑하여 입력 포커스가 사라지거나 엉뚱한 행의 데이터가 남는 등의 렌더링 버그가 발생할 수 있습니다. 각 행에 고유한 ID(예: crypto.randomUUID() 또는 단순 카운터 기반 ID)를 부여하여 key로 사용하는 것을 권장합니다.

Comment on lines +168 to +173
const handleVerifyEnrollment = async () => {
if (!foundCourseId) {
return
}
try {
await safeFetcherWithAuth.post(`course/${foundCourse?.id}/join`, {
searchParams: { invitation: invitationCode }
})
queryClient.invalidateQueries({ queryKey: ['joinedCourses'] })
toast.success('Successfully registered course.')
setIsVerifyDialogOpen(false)
setIsRegisterDialogOpen(false)
await joinCourse(foundCourseId, 'manual', manualStudentId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

manualStudentId가 비어있거나 공백만 있는 경우에도 서버에 검증 요청을 보내게 됩니다. 불필요한 API 호출을 방지하고 사용자에게 즉각적인 피드백을 주기 위해, 요청을 보내기 전에 클라이언트 측에서 유효성 검사(예: 빈 값 체크)를 수행하는 것이 좋습니다.

  const handleVerifyEnrollment = async () => {
    if (!foundCourseId) {
      return
    }
    const trimmedSid = manualStudentId.trim()
    if (!trimmedSid) {
      setSidError('Please enter your Student ID.')
      setSidShakeKey((k) => k + 1)
      return
    }
    try {
      await joinCourse(foundCourseId, 'manual', trimmedSid)

Comment on lines +132 to +136
const handleSubmitCode = async () => {
let groupId: number
try {
const data = await safeFetcherWithAuth
.get('course/invite', {
searchParams: { invitation: invitationCode }
})
.get('course/invite', { searchParams: { invitation: invitationCode } })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

invitationCode가 비어있거나 공백만 있는 경우에도 서버에 요청을 보내게 됩니다. 불필요한 API 호출을 방지하기 위해, 요청을 보내기 전에 클라이언트 측에서 유효성 검사를 수행하는 것이 좋습니다.

  const handleSubmitCode = async () => {
    const trimmedCode = invitationCode.trim()
    if (!trimmedCode) {
      setCodeError('Please enter an invitation code.')
      setCodeShakeKey((k) => k + 1)
      return
    }
    let groupId: number
    try {
      const data = await safeFetcherWithAuth
        .get('course/invite', { searchParams: { invitation: trimmedCode } })

@skkuding-bot

skkuding-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

Syncing Preview App Failed

Application: frontend
Revision: 4540bb7d6dc59605b8029f71c43b480dc75b7054
Health Status: Degraded

Open Preview | View in Argo CD

@jinukkkim jinukkkim added no-preview and removed preview 이 라벨이 붙어있어야 프론트엔드 Preview 환경이 생성됩니다 labels Jul 21, 2026
@HajunPark-skku

Copy link
Copy Markdown
Contributor

학번 중복 이슈가 조금 걸리긴하는데 그 부분만 빼고는 괜찮아보입니다~
제미나이 피드백 참고해서 한번 확인 부탁드려요

@jinukkkim jinukkkim self-assigned this Jul 31, 2026
@skkuding-bot skkuding-bot Bot added preview 이 라벨이 붙어있어야 프론트엔드 Preview 환경이 생성됩니다 and removed no-preview labels Aug 6, 2026
백엔드가 아직 roster/enrollment 검증 계약을 구현하지 않아 PR preview에서
기능을 확인할 수 없었다. 리뷰어가 preview로 플로우를 볼 수 있도록 기존
코드를 수정하지 않고 mock 파일만 추가한다.

- mock-schema.graphql: codegen이 검증에 사용할 스키마 stub
  (GroupWhitelist.name, getWhitelistEntries, createWhitelist의 names 인자).
  codegen.ts의 schema를 배열로 바꿔 실제 schema.gql과 병합한다.
  이것만으로는 빌드/타입체크만 통과하고 런타임 동작은 하지 않는다.
- mocks/enrollment-demo-*: MSW로 join 요청과 whitelist GraphQL만 가로채
  실제 응답 형태(WHITELIST_VIOLATION, ENROLLMENT_LOCKED, attemptsRemaining)를
  흉내낸다. 나머지 요청은 모두 실제 백엔드로 통과시킨다.
  계정 전환에도 roster가 유지되도록 localStorage에 저장하고, lockout은
  JWT의 userId로 분리해 한 유저의 실패가 다른 유저를 잠그지 않게 한다.
- mockServiceWorker.js는 msw가 생성한 vendor 파일이라 prettier/eslint
  ignore에 추가한다.

주의: instrumentation-client.ts가 조건 없이 worker를 시작하므로 이 상태로
머지하면 실서비스에서도 mock이 동작한다. 백엔드 구현이 머지된 뒤 mock
파일들을 삭제하고 프론트를 다시 연결해야 한다.
@jinukkkim
jinukkkim force-pushed the t2787-course-enrollment-security branch from 1aee42d to 8e5d524 Compare August 6, 2026 06:26
@skkuding-bot

skkuding-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

Syncing Preview App Succeeded

Application: frontend
Revision: 8e5d524768300f3dfb6864ec7d2d40133ed0c433
Health Status: Healthy

Open Preview | View in Argo CD

next-pwa가 scope '/'에 /sw.js를 등록하는데, MSW의 /mockServiceWorker.js도
같은 scope가 필요하다. 서비스워커는 한 scope에 하나만 페이지를 제어할 수
있어서 프로덕션 빌드에서는 PWA 워커가 이겨 목이 요청을 가로채지 못했다.
next-pwa의 disable 옵션이 development에서만 켜져 있어 로컬에서는 정상
동작하고 preview에서만 실패했다.

목 제거 시 disable을 원래의 NODE_ENV 검사로 되돌려야 한다.
@skkuding skkuding deleted a comment from skkuding-bot Bot Aug 6, 2026
@skkuding-bot

skkuding-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

Syncing Preview App Succeeded

Application: frontend
Revision: fe6566b78819fb65f0b496492d954aa53970b313
Health Status: Healthy

Open Preview | View in Argo CD

4540bb7d에서 공용 shadcn dialog의 닫기 버튼 크기를 조정했으나(버튼
h-5→h-6, X 아이콘 h-5→h-4) 이는 이번 기능에 필요한 변경이 아니었다.
로스터 모달과 수강 인증 모달은 각각 자체 닫기 버튼을 쓰거나
hideCloseButton으로 공용 버튼을 끄고 있어 실제 사용처가 없다.

이 파일은 앱 전체의 모든 Dialog가 사용하므로 PR 범위를 벗어난 시각적
변경을 되돌린다. 디자인 통일이 필요하면 별도 PR로 진행한다.
@skkuding-bot

skkuding-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

Syncing Preview App Succeeded

Application: frontend
Revision: 381b2a1b71e9ffbeb96f974b79560d53357daa70
Health Status: Healthy

Open Preview | View in Argo CD

@skkuding-bot

skkuding-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

Syncing Preview App Failed

Application: frontend
Revision: t2787-course-enrollment-security
Health Status: Healthy

Open Preview | View in Argo CD

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview 이 라벨이 붙어있어야 프론트엔드 Preview 환경이 생성됩니다 ⛳️ team-frontend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants