From 2ac4e90d56a78192da7292c8fac51be9e8192ef6 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 9 Jun 2026 17:46:25 +0800 Subject: [PATCH] feat(auth): add account linking support for different email providers --- apps/server/src/libs/auth.ts | 5 ++ .../composables/use-linked-accounts.test.ts | 55 +++++++++++++++++++ .../src/composables/use-linked-accounts.ts | 1 + 3 files changed, 61 insertions(+) create mode 100644 packages/stage-ui/src/composables/use-linked-accounts.test.ts diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index d7dd1c8f4..de10e72ef 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -575,6 +575,11 @@ export function createAuth( // https://github.com/better-auth/better-auth/issues/5892 account: { skipStateCookieCheck: true, + accountLinking: { + // Product requirement: signed-in users may attach OAuth identities + // whose provider email differs from their AIRI account email. + allowDifferentEmails: true, + }, }, socialProviders: { diff --git a/packages/stage-ui/src/composables/use-linked-accounts.test.ts b/packages/stage-ui/src/composables/use-linked-accounts.test.ts new file mode 100644 index 000000000..0728eb9e9 --- /dev/null +++ b/packages/stage-ui/src/composables/use-linked-accounts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSSRApp, ref } from 'vue' +import { renderToString } from 'vue/server-renderer' + +import { useLinkedAccounts } from './use-linked-accounts' + +describe('useLinkedAccounts', () => { + it('passes the profile page URL as the OAuth link error callback URL', async () => { + const linkSocial = vi.fn(async () => ({ + data: { status: true, redirect: false }, + error: null, + })) + + const holder: { + linkedAccounts?: ReturnType + } = {} + const app = createSSRApp({ + setup() { + holder.linkedAccounts = useLinkedAccounts({ + client: { + listAccounts: vi.fn(async () => ({ data: [], error: null })), + unlinkAccount: vi.fn(async () => ({ data: null, error: null })), + linkSocial, + }, + isAuthenticated: ref(false), + describeError: () => '', + buildCallbackURL: () => 'https://auth.airi.build/ui/profile', + messages: { + listFailed: 'list failed', + unlinkFailed: 'unlink failed', + linkFailed: 'link failed', + lastAccount: 'last account', + unlinked: provider => `${provider} unlinked`, + linkStarted: provider => `${provider} link started`, + }, + }) + + return () => null + }, + }) + + await renderToString(app) + + if (!holder.linkedAccounts) + throw new Error('Expected linked accounts composable to initialize') + + await holder.linkedAccounts.link('github', 'GitHub') + + expect(linkSocial).toHaveBeenCalledWith({ + provider: 'github', + callbackURL: 'https://auth.airi.build/ui/profile', + errorCallbackURL: 'https://auth.airi.build/ui/profile', + }) + }) +}) diff --git a/packages/stage-ui/src/composables/use-linked-accounts.ts b/packages/stage-ui/src/composables/use-linked-accounts.ts index 111030d57..8a795241d 100644 --- a/packages/stage-ui/src/composables/use-linked-accounts.ts +++ b/packages/stage-ui/src/composables/use-linked-accounts.ts @@ -196,6 +196,7 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) { const { data, error: apiError } = await args.client.linkSocial({ provider: providerId, callbackURL, + errorCallbackURL: callbackURL, }) if (apiError) throw new Error(apiError.message ?? 'linkSocial failed')