Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions app/pages/membership/youtube.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 7 additions & 6 deletions tests/unit/pages/membership-youtube.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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` },
});
});

Expand Down Expand Up @@ -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();
Expand All @@ -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);
});
Expand Down
Loading