Skip to content

feat: Canvas-based signup and sync UI#20

Merged
whnbaek merged 1 commit into
mainfrom
canvas
Apr 8, 2026
Merged

feat: Canvas-based signup and sync UI#20
whnbaek merged 1 commit into
mainfrom
canvas

Conversation

@whnbaek

@whnbaek whnbaek commented Apr 8, 2026

Copy link
Copy Markdown
Member
  • 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)
Copilot AI review requested due to automatic review settings April 8, 2026 10:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 under app/(nolocale)/canvas/* and app/(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

  • identifier was added as a required field in listGroupsSchema. If the backend doesn't always include this field (e.g., during rollout or older deployments), zod.parse will throw and the entire groups page will fail. Consider making identifier optional 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.

Comment on lines +169 to +174
if (!resp.ok) {
showToast({ type: 'error', message: dict.error.unknown });
return;
}
showToast({ type: 'info', message: '학번이 삭제되었습니다.' });
router.refresh();

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +75
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),
});

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
try {
body = await request.json();
} catch {
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });
return NextResponse.json({ code: 'invalid_body' }, { status: 400 });

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +16

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),

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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),

Copilot uses AI. Check for mistakes.
try {
body = await request.json();
} catch {
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });
return NextResponse.json({ code: 'INVALID_BODY' }, { status: 400 });

Copilot uses AI. Check for mistakes.
Comment thread api/canvas.ts
body: JSON.stringify({ actions }),
credentials: 'same-origin',
});
if (!resp.ok) { throw new Error('적용 실패'); }

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
if (!resp.ok) { throw new Error('적용 실패'); }
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body?.message ?? body?.code ?? '적용 실패');
}

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +40
<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>

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread .env.development
@@ -1 +1 @@
API_BASE="https://id-dev-api.bacchus.io"
API_BASE="http://localhost:50080"

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +26
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}
</>
);
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
onClick={handlePreview}
className="w-full"
>
{loading ? d.syncing : dict.signUp.form.signUpButton}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
{loading ? d.syncing : dict.signUp.form.signUpButton}
{loading ? d.syncing : d.buttonSync}

Copilot uses AI. Check for mistakes.
@whnbaek whnbaek merged commit 1021a9f into main Apr 8, 2026
5 checks passed
@whnbaek whnbaek deleted the canvas branch April 8, 2026 11:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants