feat(auth): enhance OAuth linking flow with account selection and error handling

- Added `prompt: 'select_account'` for Google and GitHub providers to force account selection during OAuth linking.
- Introduced localized error messages for account linking issues in multiple languages.
- Implemented a function to resolve OAuth error codes to specific i18n keys for better user feedback.
- Updated account settings page to display relevant error messages based on OAuth linking status.
This commit is contained in:
RainbowBird
2026-06-10 16:42:13 +08:00
parent aa5223ad73
commit 16f9aaa4e3
15 changed files with 124 additions and 4 deletions
+14
View File
@@ -586,6 +586,13 @@ export function createAuth(
google: {
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
// Force the provider's authorization page to let users choose an
// identity before linking. Without this, an existing provider session
// can silently reuse the previously authorized account and immediately
// hit account_already_linked_to_different_user.
// Source: @better-auth/core/src/oauth2/create-authorization-url.ts
// forwards provider `prompt` to the OAuth authorization URL.
prompt: 'select_account',
// NOTICE:
// Why: better-auth's google provider already maps email_verified
// through, but a stale Google profile that omits the claim falls
@@ -602,6 +609,13 @@ export function createAuth(
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
// Force GitHub's authorization page to let users choose an identity
// before linking. Without this, an existing github.com session can
// silently reuse the previously authorized account and immediately hit
// account_already_linked_to_different_user.
// Source: @better-auth/core/src/oauth2/create-authorization-url.ts
// forwards provider `prompt` to the OAuth authorization URL.
prompt: 'select_account',
// NOTICE:
// Why: better-auth derives emailVerified from the GitHub /user/emails
// response, but `emails.find(e => e.email === profile.email)?.verified`
+15
View File
@@ -46,6 +46,21 @@ describe('createAuth', () => {
expect(auth.options.account?.accountLinking?.allowDifferentEmails).toBe(true)
})
it('asks social providers to show the account picker during OAuth authorization', () => {
const auth = createAuth({} as unknown as Database, {
API_SERVER_URL: 'http://localhost:3000',
AUTH_GOOGLE_CLIENT_ID: 'google-client',
AUTH_GOOGLE_CLIENT_SECRET: 'google-secret',
AUTH_GITHUB_CLIENT_ID: 'github-client',
AUTH_GITHUB_CLIENT_SECRET: 'github-secret',
BETTER_AUTH_SECRET: 'test-secret-test-secret-test-secret',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as unknown as Env)
expect(auth.options.socialProviders?.google?.prompt).toBe('select_account')
expect(auth.options.socialProviders?.github?.prompt).toBe('select_account')
})
})
describe('seedTrustedClients', () => {
@@ -262,6 +262,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: This OAuth account is already linked to another AIRI account. Choose a different provider account, or unlink it from the other AIRI account first.
oauthCallbackFailed: We could not connect this OAuth account. Try again or choose another provider account.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
@@ -248,6 +248,8 @@ pages:
listFailed: No se pudo cargar las cuentas conectadas.
unlinkFailed: No se pudo desvincular esta cuenta.
linkFailed: No se pudo iniciar el flujo de vinculación.
accountAlreadyLinkedToDifferentUser: Esta cuenta OAuth ya está vinculada a otra cuenta de AIRI. Elige otra cuenta del proveedor o desvincúlala primero de la otra cuenta de AIRI.
oauthCallbackFailed: No pudimos conectar esta cuenta OAuth. Inténtalo de nuevo o elige otra cuenta del proveedor.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Seguridad
@@ -248,6 +248,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: Ce compte OAuth est déjà lié à un autre compte AIRI. Choisissez un autre compte fournisseur ou dissociez-le d'abord de l'autre compte AIRI.
oauthCallbackFailed: Impossible de connecter ce compte OAuth. Réessayez ou choisissez un autre compte fournisseur.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
@@ -248,6 +248,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: この OAuth アカウントは別の AIRI アカウントにすでに連携されています。別のプロバイダーアカウントを選ぶか、先に別の AIRI アカウントから連携を解除してください。
oauthCallbackFailed: この OAuth アカウントを連携できませんでした。もう一度試すか、別のプロバイダーアカウントを選んでください。
lastAccount: "これはあなたの唯一のサインイン方法です。\nリンクを解除する前にパスワードを設定してください。"
security:
tab: セキュリティ
@@ -248,6 +248,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: 이 OAuth 계정은 이미 다른 AIRI 계정에 연결되어 있습니다. 다른 제공자 계정을 선택하거나, 먼저 다른 AIRI 계정에서 연결을 해제하세요.
oauthCallbackFailed: 이 OAuth 계정을 연결할 수 없습니다. 다시 시도하거나 다른 제공자 계정을 선택하세요.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
@@ -248,6 +248,8 @@ pages:
listFailed: Не удалось загрузить подключенные учетные записи.
unlinkFailed: Не удалось отвязать эту учетную запись.
linkFailed: Не удалось запустить поток ссылок.
accountAlreadyLinkedToDifferentUser: Этот OAuth-аккаунт уже привязан к другому аккаунту AIRI. Выберите другой аккаунт провайдера или сначала отвяжите его от другого аккаунта AIRI.
oauthCallbackFailed: Не удалось подключить этот OAuth-аккаунт. Попробуйте еще раз или выберите другой аккаунт провайдера.
lastAccount: Это единственный способ входа в систему. Установите пароль перед отсоединением.
security:
tab: Безопасность
@@ -248,6 +248,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: Tài khoản OAuth này đã được liên kết với một tài khoản AIRI khác. Hãy chọn tài khoản nhà cung cấp khác hoặc hủy liên kết tài khoản đó khỏi tài khoản AIRI kia trước.
oauthCallbackFailed: Không thể kết nối tài khoản OAuth này. Hãy thử lại hoặc chọn tài khoản nhà cung cấp khác.
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
@@ -248,6 +248,8 @@ pages:
listFailed: 暂时无法加载已关联的帐户
unlinkFailed: 无法解除此帐户的关联
linkFailed: 暂时无法开始关联的流程,要之后再试试吗?
accountAlreadyLinkedToDifferentUser: 这个 OAuth 帐户已经关联到另一个 AIRI 帐户了。请选择另一个第三方帐户,或者先在另一个 AIRI 帐户中解除关联。
oauthCallbackFailed: 暂时无法关联这个 OAuth 帐户。请重试,或选择另一个第三方帐户。
lastAccount: 这是你当前唯一的登录方式,请务必先设置密码再解除关联哦
security:
tab: 安全
@@ -248,6 +248,8 @@ pages:
listFailed: Could not load connected accounts.
unlinkFailed: Could not unlink this account.
linkFailed: Could not start the link flow.
accountAlreadyLinkedToDifferentUser: 這個 OAuth 帳戶已經連結到另一個 AIRI 帳戶了。請選擇另一個第三方帳戶,或先在另一個 AIRI 帳戶中解除連結。
oauthCallbackFailed: 暫時無法連結這個 OAuth 帳戶。請重試,或選擇另一個第三方帳戶。
lastAccount: This is your only sign-in method. Set a password first before unlinking it.
security:
tab: Security
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
import { useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { resolveLinkedAccountOAuthErrorMessageKey, useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@@ -10,7 +10,7 @@ import { storeToRefs } from 'pinia'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
type SectionId = 'profile' | 'security' | 'connections' | 'danger'
@@ -20,6 +20,8 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
@@ -108,6 +110,7 @@ const profileSectionRef = ref<HTMLElement | null>(null)
const securitySectionRef = ref<HTMLElement | null>(null)
const connectionsSectionRef = ref<HTMLElement | null>(null)
const dangerSectionRef = ref<HTMLElement | null>(null)
const linkedAccountsRouteErrorKey = shallowRef<string | null>(null)
function scrollToSection(id: SectionId) {
activeSection.value = id
@@ -156,6 +159,25 @@ const {
},
})
watch(
() => route.query.error,
async (error) => {
const rawError = Array.isArray(error) ? error[0] : error
const messageKey = resolveLinkedAccountOAuthErrorMessageKey(rawError)
if (!messageKey)
return
linkedAccountsRouteErrorKey.value = messageKey
activeSection.value = 'connections'
const query = { ...route.query }
delete query.error
delete query.error_description
await router.replace({ query })
},
{ immediate: true },
)
const connectionsDateFormatter = computed(() => {
try {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })
@@ -180,11 +202,13 @@ function formatLinkedSince(iso: string): string {
}
function handleUnlinkProvider(providerId: string) {
linkedAccountsRouteErrorKey.value = null
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return unlinkLinkedProvider(providerId, providerName)
}
function handleLinkProvider(providerId: 'github' | 'google') {
linkedAccountsRouteErrorKey.value = null
const providerName = defaultSignInProviders.find(p => p.id === providerId)?.name ?? providerId
return linkLinkedProvider(providerId, providerName)
}
@@ -743,12 +767,12 @@ async function handleConfirmDelete(event: Event) {
</ul>
<div
v-if="linkedAccountsError"
v-if="linkedAccountsRouteErrorKey || linkedAccountsError"
:class="['text-sm text-red-500']"
role="alert"
aria-live="polite"
>
{{ linkedAccountsError }}
{{ linkedAccountsRouteErrorKey ? t(linkedAccountsRouteErrorKey) : linkedAccountsError }}
</div>
<div
v-else-if="linkedAccountsMessage"
@@ -1,5 +1,6 @@
export * from './audio'
export * from './canvas-alpha'
export * from './linked-account-errors'
export * from './llm-marker-parser'
export * from './markdown'
export * from './queues'
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { resolveLinkedAccountOAuthErrorMessageKey } from './linked-account-errors'
describe('resolveLinkedAccountOAuthErrorMessageKey', () => {
it('maps account_already_linked_to_different_user to the specific i18n key', () => {
expect(resolveLinkedAccountOAuthErrorMessageKey('account_already_linked_to_different_user')).toBe(
'settings.pages.account.connections.error.accountAlreadyLinkedToDifferentUser',
)
})
it('maps unknown OAuth callback errors to the fallback i18n key', () => {
expect(resolveLinkedAccountOAuthErrorMessageKey('unexpected_provider_error')).toBe(
'settings.pages.account.connections.error.oauthCallbackFailed',
)
})
it('ignores missing OAuth callback errors', () => {
expect(resolveLinkedAccountOAuthErrorMessageKey(undefined)).toBeNull()
expect(resolveLinkedAccountOAuthErrorMessageKey('')).toBeNull()
})
})
@@ -0,0 +1,26 @@
const linkedAccountOAuthErrorMessageKeys: Record<string, string> = {
account_already_linked_to_different_user: 'settings.pages.account.connections.error.accountAlreadyLinkedToDifferentUser',
}
/**
* Resolves Better Auth OAuth-link callback errors to settings-page i18n keys.
*
* Use when:
* - A link-social callback redirects back to an account settings page with
* `?error=...`.
*
* Expects:
* - `errorCode` is the raw query-string value from Better Auth.
*
* Returns:
* - A specific localized message key for known errors.
* - A generic localized message key for unknown non-empty errors.
* - `null` when there is no error to show.
*/
export function resolveLinkedAccountOAuthErrorMessageKey(errorCode: string | null | undefined): string | null {
if (!errorCode)
return null
return linkedAccountOAuthErrorMessageKeys[errorCode]
?? 'settings.pages.account.connections.error.oauthCallbackFailed'
}