Conversation
whnbaek
commented
Apr 8, 2026
- Canvas token signup flow with profile preview
- Sync page with add-only diff (3-state groups: member/pending/new)
- Dashboard canvas section, group canvas badge, student ID delete UI
- i18n support (ko/en) for all canvas strings
- Archive old EmailForm/SignupForm (preserved, not deleted)
- Canvas token signup flow with profile preview - Sync page with add-only diff (3-state groups: member/pending/new) - Dashboard canvas section, group canvas badge, student ID delete UI - i18n support (ko/en) for all canvas strings - Archive old EmailForm/SignupForm (preserved, not deleted)
There was a problem hiding this comment.
Pull request overview
This PR introduces a Canvas Access Token–based signup and account sync experience, including UI for previewing/applying diffs (student IDs/emails/groups) and end-to-end i18n coverage for the new Canvas flows, while archiving the legacy email-based signup components.
Changes:
- Added Canvas-based signup flow with preview UI and new API client (
api/canvas.ts) plus proxy routes underapp/(nolocale)/canvas/*andapp/(nolocale)/signup/canvas. - Added an authorized Canvas sync page with selectable “add-only” diffs and integrated Canvas entry point on the dashboard.
- Updated i18n (ko/en), group data shape (
identifier), and replaced student ID UI with a delete-focused management view.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
middleware.ts |
Adds nolocale route exceptions for new Canvas and user endpoints. |
locale/ko.json |
Adds Canvas strings + updates student ID wording and delete label. |
locale/en.json |
Adds Canvas strings + updates student ID wording and delete label. |
app/[locale]/signup/SignupForm.tsx |
Archives legacy email-based signup form. |
app/[locale]/signup/page.tsx |
Switches signup page to Canvas-based flow. |
app/[locale]/signup/EmailForm.tsx |
Archives legacy signup email form. |
app/[locale]/signup/CanvasSignupFlow.tsx |
New Canvas token signup + preview UI (client component). |
app/[locale]/(authorized)/StudentIdForm.tsx |
Replaces add form with student ID list + delete actions. |
app/[locale]/(authorized)/page.tsx |
Adds dashboard section linking to Canvas sync page. |
app/[locale]/(authorized)/group/GroupItem.tsx |
Adds “Canvas linked” badge based on group identifier. |
app/[locale]/(authorized)/canvas/page.tsx |
New authorized Canvas page shell. |
app/[locale]/(authorized)/canvas/CanvasSync.tsx |
New Canvas sync UI: token input + diff rendering. |
app/[locale]/(authorized)/canvas/CanvasDiff.tsx |
Diff viewer with selectable add-only actions + apply flow. |
app/(nolocale)/user/student-numbers/route.ts |
Adds DELETE proxy to backend for student number removal. |
app/(nolocale)/user/email/route.ts |
Adds DELETE proxy to backend for email removal. |
app/(nolocale)/signup/canvas/route.ts |
Adds Canvas signup proxy endpoint. |
app/(nolocale)/canvas/sync/route.ts |
Adds Canvas sync proxy endpoint (authorized via cookie forwarding). |
app/(nolocale)/canvas/preview/route.ts |
Adds Canvas preview proxy endpoint. |
app/(nolocale)/canvas/apply/route.ts |
Adds Canvas apply proxy endpoint (authorized via cookie forwarding). |
api/index.ts |
Extends group schema/type to include identifier. |
api/canvas.ts |
Adds Canvas API client methods/types for preview/sync/apply/signup. |
.env.development |
Changes dev API_BASE default to localhost. |
Comments suppressed due to low confidence (1)
api/index.ts:41
identifierwas added as a required field inlistGroupsSchema. If the backend doesn't always include this field (e.g., during rollout or older deployments),zod.parsewill throw and the entire groups page will fail. Consider makingidentifieroptional with a safe default (or feature-detect) to keep the client backwards-compatible.
const listGroupsSchema = z.array(
z.object({
idx: z.number(),
name: z.record(z.string()),
description: z.record(z.string()),
identifier: z.string(),
isPending: z.boolean(),
isMember: z.boolean(),
isDirectMember: z.boolean(),
isOwner: z.boolean(),
}),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!resp.ok) { | ||
| showToast({ type: 'error', message: dict.error.unknown }); | ||
| return; | ||
| } | ||
| showToast({ type: 'info', message: '학번이 삭제되었습니다.' }); | ||
| router.refresh(); |
There was a problem hiding this comment.
The delete success toast is hard-coded in Korean, which bypasses the existing i18n dictionary and will display Korean even when the UI locale is en. Add a localized message key (e.g., studentId.deleteSuccess) and use it here; consider also reading resp.json().message on failure so users get actionable errors instead of always dict.error.unknown.
| export async function DELETE(request: Request): Promise<Response> { | ||
| const cookie = headers().get('cookie') || ''; | ||
| let body; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | ||
| } | ||
| const resp = await fetch(apiUrl('/api/user/student-numbers'), { | ||
| method: 'DELETE', | ||
| headers: { 'content-type': 'application/json', cookie }, | ||
| body: JSON.stringify(body), | ||
| }); |
There was a problem hiding this comment.
DELETE does not validate/parse the request body (unlike POST, which uses bodySchema.parse(...)). This can forward unexpected payloads to the backend and makes error handling inconsistent; consider reusing a Zod schema for the delete payload (e.g., { studentNumber: z.string() }) and returning a structured 400 on validation failure.
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); |
There was a problem hiding this comment.
The 400 response message 'Invalid body' is hard-coded and not localized, while POST uses getDictionary(getLocaleFromCookie()) for user-facing messages. Consider using the same locale-aware dictionary (or returning a stable error code) for consistency.
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | |
| return NextResponse.json({ code: 'invalid_body' }, { status: 400 }); |
|
|
||
| export async function DELETE(request: Request): Promise<Response> { | ||
| const cookie = headers().get('cookie') || ''; | ||
| let body; | ||
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | ||
| } | ||
| const resp = await fetch(apiUrl('/api/user/email'), { | ||
| method: 'DELETE', | ||
| headers: { 'content-type': 'application/json', cookie }, | ||
| body: JSON.stringify(body), |
There was a problem hiding this comment.
This handler forwards request.json() directly to the backend without any schema validation. To avoid passing malformed/unexpected payloads (and to provide consistent 400 responses), validate the DELETE body with Zod (similar to other route handlers) before proxying.
| export async function DELETE(request: Request): Promise<Response> { | |
| const cookie = headers().get('cookie') || ''; | |
| let body; | |
| try { | |
| body = await request.json(); | |
| } catch { | |
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | |
| } | |
| const resp = await fetch(apiUrl('/api/user/email'), { | |
| method: 'DELETE', | |
| headers: { 'content-type': 'application/json', cookie }, | |
| body: JSON.stringify(body), | |
| import { z } from 'zod'; | |
| const deleteUserEmailBodySchema = z.object({ | |
| email: z.string().email(), | |
| }); | |
| export async function DELETE(request: Request): Promise<Response> { | |
| const cookie = headers().get('cookie') || ''; | |
| let rawBody; | |
| try { | |
| rawBody = await request.json(); | |
| } catch { | |
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | |
| } | |
| const parsedBody = deleteUserEmailBodySchema.safeParse(rawBody); | |
| if (!parsedBody.success) { | |
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | |
| } | |
| const resp = await fetch(apiUrl('/api/user/email'), { | |
| method: 'DELETE', | |
| headers: { 'content-type': 'application/json', cookie }, | |
| body: JSON.stringify(parsedBody.data), |
| try { | ||
| body = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); |
There was a problem hiding this comment.
The 400 response message 'Invalid body' is hard-coded. If this message can surface to users, consider localizing it (via getDictionary(getLocaleFromCookie())) or returning a stable error code and letting the client render the localized string.
| return NextResponse.json({ message: 'Invalid body' }, { status: 400 }); | |
| return NextResponse.json({ code: 'INVALID_BODY' }, { status: 400 }); |
| body: JSON.stringify({ actions }), | ||
| credentials: 'same-origin', | ||
| }); | ||
| if (!resp.ok) { throw new Error('적용 실패'); } |
There was a problem hiding this comment.
applyCanvas throws a generic hard-coded error ('적용 실패') and discards any backend-provided error details. Parse the response JSON (if present) and propagate a meaningful message (or an error code) so the UI can show a localized, actionable error to the user.
| if (!resp.ok) { throw new Error('적용 실패'); } | |
| if (!resp.ok) { | |
| const body = await resp.json().catch(() => ({})); | |
| throw new Error(body?.message ?? body?.code ?? '적용 실패'); | |
| } |
| <Link className="w-full max-w-32" href={`/${locale}/canvas`}> | ||
| <Button className="w-full font-bold" type="button" color="primary"> | ||
| {dict.canvas.title} | ||
| </Button> | ||
| </Link> |
There was a problem hiding this comment.
This renders a <button> inside a Next <Link> (which renders an <a>), creating nested interactive elements and invalid HTML (accessibility issue). Prefer rendering a styled <a> (or add an asChild/href variant to Button), or move the navigation to router.push on button click.
| @@ -1 +1 @@ | |||
| API_BASE="https://id-dev-api.bacchus.io" | |||
| API_BASE="http://localhost:50080" | |||
There was a problem hiding this comment.
Changing .env.development to point API_BASE at http://localhost:50080 will break dev environments that rely on the shared dev API endpoint. If this is meant to be a local override, consider keeping the committed default and using .env.local (gitignored) or documenting the local setup in README instead.
| API_BASE="http://localhost:50080" | |
| # Do not commit machine-specific API_BASE overrides here. | |
| # For local development against a local backend, set API_BASE in .env.local instead. |
| const CANVAS_URL = 'https://myetl.snu.ac.kr'; | ||
|
|
||
| function renderWithLink(text: string): ReactNode { | ||
| if (!text.includes('{link}')) return text; | ||
| const [before, after] = text.split('{link}'); | ||
| return ( | ||
| <> | ||
| {before} | ||
| <a href={CANVAS_URL} target="_blank" rel="noopener noreferrer" className="text-sky-600 hover:underline"> | ||
| myetl.snu.ac.kr | ||
| </a> | ||
| {after} | ||
| </> | ||
| ); | ||
| } |
There was a problem hiding this comment.
CANVAS_URL and renderWithLink are duplicated here and in signup/CanvasSignupFlow.tsx. Consider extracting a shared helper/component (e.g., CanvasLink / renderCanvasLink) to avoid divergence between signup and sync flows when the copy or URL needs to change.
| onClick={handlePreview} | ||
| className="w-full" | ||
| > | ||
| {loading ? d.syncing : dict.signUp.form.signUpButton} |
There was a problem hiding this comment.
In the token preview step, the primary button uses dict.signUp.form.signUpButton ("Sign up" / "가입하기") even though it only triggers the Canvas preview (handlePreview). This is likely confusing; consider using a dedicated Canvas label (e.g., dict.canvas.buttonSync / Check Sync) for this step and reserving the sign-up label for the final submit.
| {loading ? d.syncing : dict.signUp.form.signUpButton} | |
| {loading ? d.syncing : d.buttonSync} |