diff --git a/app/pages/membership/youtube.vue b/app/pages/membership/youtube.vue index 5fbb4bec..21324a95 100644 --- a/app/pages/membership/youtube.vue +++ b/app/pages/membership/youtube.vue @@ -13,7 +13,7 @@ * `youtube-bridge-sync` Edge Function (via POST /api/membership/youtube-sync) * then reads the role and attaches the membership. * - * Redirect: `linkIdentity` comes back HERE (`?linked=1&code=…`), not to + * Redirect: `linkIdentity` comes back HERE (`?code=…`), not to * /auth/callback. The callback has no redirect parameter (it uses the /login * localStorage stash), it records a `login_success` and can divert a user to * /welcome, and on a GoTrue error redirect it drops the `error_code` that tells @@ -168,7 +168,11 @@ try { const { error } = await supabase.auth.linkIdentity({ provider: 'discord', - options: { redirectTo: `${window.location.origin}${SELF_PATH}?linked=1` }, + // No query string: Supabase matches redirectTo against the Redirect URLs + // allowlist as a whole address, so `?linked=1` failed the exact entry and + // GoTrue fell back to the Site URL (seen 2026-09-28). The returned + // `code` / `error_code` already mark the return. + options: { redirectTo: `${window.location.origin}${SELF_PATH}` }, }); if (error) { outcome.value = outcomeForLinkError((error as { code?: string }).code, error.message); @@ -282,7 +286,8 @@ return; } - if (back.linked) { + // Back from Discord: the code (or the legacy ?linked=1) marks the return. + if (back.linked || back.code) { await checkMembership(); return; } diff --git a/tests/unit/pages/membership-youtube.test.ts b/tests/unit/pages/membership-youtube.test.ts index 301f0a52..f085dd6f 100644 --- a/tests/unit/pages/membership-youtube.test.ts +++ b/tests/unit/pages/membership-youtube.test.ts @@ -6,9 +6,10 @@ * Covers: * - signed out → sign-in card that returns here * - no Discord identity → Link Discord calls linkIdentity('discord') with a - * redirect back to /membership/youtube?linked=1 + * redirect back to /membership/youtube (no query string: the allowlist + * matches the whole address) * - Discord already linked → "Check my YouTube membership" instead - * - ?linked=1&code= → PKCE exchange, URL cleaned, sync runs at once + * - ?code= → PKCE exchange, URL cleaned, sync runs at once * - every sync status and error status renders its own outcome * - linkIdentity errors: identity already linked elsewhere / linking disabled * @@ -132,7 +133,7 @@ describe('no Discord identity yet', () => { await flushPromises(); expect(supabase.auth.linkIdentity).toHaveBeenCalledWith({ provider: 'discord', - options: { redirectTo: `${window.location.origin}/membership/youtube?linked=1` }, + options: { redirectTo: `${window.location.origin}/membership/youtube` }, }); }); @@ -189,11 +190,11 @@ describe('Discord already linked', () => { }); }); -describe('return from Discord (?linked=1)', () => { +describe('return from Discord (?code=)', () => { it('exchanges the code, cleans the URL and syncs at once', async () => { const { supabase } = stubEnvironment({ supabase: makeSupabaseStub({ providers: ['discord'] }), - query: { linked: '1', code: 'abc' }, + query: { code: 'abc' }, fetchImpl: () => Promise.resolve({ status: 'linked', plan: 'pro' }), }); const wrapper = await mountPage(); @@ -207,7 +208,7 @@ describe('return from Discord (?linked=1)', () => { const supabase = makeSupabaseStub({ providers: ['discord'] }); supabase.auth.exchangeCodeForSession.mockResolvedValue({ data: { session: null }, error: { message: 'x' } } as any); vi.spyOn(console, 'error').mockImplementation(() => {}); - stubEnvironment({ supabase, query: { linked: '1', code: 'abc' } }); + stubEnvironment({ supabase, query: { code: 'abc' } }); await mountPage(); expect(fetchMock).toHaveBeenCalledTimes(1); });