feat: add oauth callback email binding ui

This commit is contained in:
IanShaw027
2026-04-20 19:30:19 +08:00
parent 6a75bd77e3
commit 6ea3f42e2f
10 changed files with 916 additions and 36 deletions

View File

@@ -30,6 +30,20 @@ describe('oauth adoption auth api', () => {
})
})
it('posts bind-login decisions when finalizing pending oauth bind flow', async () => {
const { completePendingOAuthBindLogin } = await import('@/api/auth')
await completePendingOAuthBindLogin({
adoptDisplayName: true,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts linuxdo invitation completion with adoption decisions', async () => {
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
@@ -45,6 +59,21 @@ describe('oauth adoption auth api', () => {
})
})
it('posts linuxdo create-account completion with adoption decisions', async () => {
const { createPendingLinuxDoOAuthAccount } = await import('@/api/auth')
await createPendingLinuxDoOAuthAccount('invite-code', {
adoptDisplayName: false,
adoptAvatar: true
})
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: false,
adopt_avatar: true
})
})
it('posts oidc invitation completion with adoption decisions', async () => {
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
@@ -60,6 +89,21 @@ describe('oauth adoption auth api', () => {
})
})
it('posts oidc create-account completion with adoption decisions', async () => {
const { createPendingOIDCOAuthAccount } = await import('@/api/auth')
await createPendingOIDCOAuthAccount('invite-code', {
adoptDisplayName: true,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: true,
adopt_avatar: false
})
})
it('posts wechat invitation completion with adoption decisions', async () => {
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
@@ -75,6 +119,21 @@ describe('oauth adoption auth api', () => {
})
})
it('posts wechat create-account completion with adoption decisions', async () => {
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
await createPendingWeChatOAuthAccount('invite-code', {
adoptDisplayName: false,
adoptAvatar: false
})
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
invitation_code: 'invite-code',
adopt_display_name: false,
adopt_avatar: false
})
})
it('classifies oauth completion results as login or bind', async () => {
const { getOAuthCompletionKind } = await import('@/api/auth')
@@ -82,6 +141,38 @@ describe('oauth adoption auth api', () => {
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
})
it('provides bind-login utility helpers for invitation and suggested profile states', async () => {
const {
getPendingOAuthBindLoginKind,
hasPendingOAuthSuggestedProfile,
isPendingOAuthCreateAccountRequired
} = await import('@/api/auth')
expect(getPendingOAuthBindLoginKind({ access_token: 'access-token' })).toBe('login')
expect(getPendingOAuthBindLoginKind({ redirect: '/profile' })).toBe('bind')
expect(
isPendingOAuthCreateAccountRequired({
error: 'invitation_required'
})
).toBe(true)
expect(
isPendingOAuthCreateAccountRequired({
error: 'other'
})
).toBe(false)
expect(
hasPendingOAuthSuggestedProfile({
suggested_display_name: 'OAuth Nick'
})
).toBe(true)
expect(
hasPendingOAuthSuggestedProfile({
suggested_avatar_url: 'https://cdn.example/avatar.png'
})
).toBe(true)
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
})
it('prepares an oauth bind access token cookie before redirect binding', async () => {
localStorage.setItem('auth_token', 'access-token-value')
const setCookie = vi.fn()

View File

@@ -193,7 +193,7 @@ export interface OAuthTokenResponse {
token_type?: string
}
export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse> {
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
redirect?: string
error?: string
adoption_required?: boolean
@@ -201,6 +201,10 @@ export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse
suggested_avatar_url?: string
}
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {}
export type OAuthCompletionKind = 'login' | 'bind'
export interface OAuthAdoptionDecision {
@@ -235,6 +239,27 @@ export function getOAuthCompletionKind(
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
}
export function getPendingOAuthBindLoginKind(
completion: PendingOAuthBindLoginResponse
): OAuthCompletionKind {
return getOAuthCompletionKind(completion)
}
export function isPendingOAuthCreateAccountRequired(
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
): boolean {
return completion.error === 'invitation_required'
}
export function hasPendingOAuthSuggestedProfile(
completion: Pick<
PendingOAuthBindLoginResponse,
'suggested_display_name' | 'suggested_avatar_url'
>
): boolean {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
if (tokens.refresh_token) {
setRefreshToken(tokens.refresh_token)
@@ -431,11 +456,7 @@ export async function completeLinuxDoOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<OAuthTokenResponse> {
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
invitation_code: invitationCode,
...serializeOAuthAdoptionDecision(decision)
})
return data
return createPendingLinuxDoOAuthAccount(invitationCode, decision)
}
/**
@@ -447,32 +468,66 @@ export async function completeOIDCOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<OAuthTokenResponse> {
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
invitation_code: invitationCode,
...serializeOAuthAdoptionDecision(decision)
})
return data
return createPendingOIDCOAuthAccount(invitationCode, decision)
}
export async function completeWeChatOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<OAuthTokenResponse> {
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
invitation_code: invitationCode,
...serializeOAuthAdoptionDecision(decision)
})
return createPendingWeChatOAuthAccount(invitationCode, decision)
}
async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat',
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthCreateAccountResponse> {
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`,
{
invitation_code: invitationCode,
...serializeOAuthAdoptionDecision(decision)
}
)
return data
}
export async function createPendingLinuxDoOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision)
}
export async function createPendingOIDCOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision)
}
export async function createPendingWeChatOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision)
}
export async function completePendingOAuthBindLogin(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthBindLoginResponse> {
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
'/auth/oauth/pending/exchange',
serializeOAuthAdoptionDecision(decision)
)
return data
}
export async function exchangePendingOAuthCompletion(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthExchangeResponse> {
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
'/auth/oauth/pending/exchange',
serializeOAuthAdoptionDecision(decision)
)
return data
return completePendingOAuthBindLogin(decision)
}
export const authAPI = {
@@ -498,6 +553,13 @@ export const authAPI = {
resetPassword,
refreshToken,
revokeAllSessions,
getPendingOAuthBindLoginKind,
isPendingOAuthCreateAccountRequired,
hasPendingOAuthSuggestedProfile,
completePendingOAuthBindLogin,
createPendingLinuxDoOAuthAccount,
createPendingOIDCOAuthAccount,
createPendingWeChatOAuthAccount,
exchangePendingOAuthCompletion,
completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration,