From 6ea3f42e2f825f165c98a63fa6b5f472f6853b33 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Mon, 20 Apr 2026 19:30:19 +0800 Subject: [PATCH] feat: add oauth callback email binding ui --- .../api/__tests__/auth-oauth-adoption.spec.ts | 91 +++++++ frontend/src/api/auth.ts | 102 +++++-- frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 1 + .../src/views/auth/LinuxDoCallbackView.vue | 257 +++++++++++++++++- frontend/src/views/auth/OidcCallbackView.vue | 128 ++++++++- .../src/views/auth/WechatCallbackView.vue | 108 ++++++++ .../__tests__/LinuxDoCallbackView.spec.ts | 105 +++++++ .../auth/__tests__/OidcCallbackView.spec.ts | 71 +++++ .../auth/__tests__/WechatCallbackView.spec.ts | 88 ++++++ 10 files changed, 916 insertions(+), 36 deletions(-) diff --git a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts index 9c0b4d55..f95332fb 100644 --- a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts +++ b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts @@ -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() diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c11bd90b..98b20154 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -193,7 +193,7 @@ export interface OAuthTokenResponse { token_type?: string } -export interface PendingOAuthExchangeResponse extends Partial { +export interface PendingOAuthBindLoginResponse extends Partial { redirect?: string error?: string adoption_required?: boolean @@ -201,6 +201,10 @@ export interface PendingOAuthExchangeResponse extends Partial +): 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): void { if (tokens.refresh_token) { setRefreshToken(tokens.refresh_token) @@ -431,11 +456,7 @@ export async function completeLinuxDoOAuthRegistration( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { - const { data } = await apiClient.post('/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 { - const { data } = await apiClient.post('/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 { - const { data } = await apiClient.post('/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 { + const { data } = await apiClient.post( + `/auth/oauth/${provider}/complete-registration`, + { + invitation_code: invitationCode, + ...serializeOAuthAdoptionDecision(decision) + } + ) + return data +} + +export async function createPendingLinuxDoOAuthAccount( + invitationCode: string, + decision?: OAuthAdoptionDecision +): Promise { + return createPendingOAuthAccount('linuxdo', invitationCode, decision) +} + +export async function createPendingOIDCOAuthAccount( + invitationCode: string, + decision?: OAuthAdoptionDecision +): Promise { + return createPendingOAuthAccount('oidc', invitationCode, decision) +} + +export async function createPendingWeChatOAuthAccount( + invitationCode: string, + decision?: OAuthAdoptionDecision +): Promise { + return createPendingOAuthAccount('wechat', invitationCode, decision) +} + +export async function completePendingOAuthBindLogin( + decision?: OAuthAdoptionDecision +): Promise { + const { data } = await apiClient.post( + '/auth/oauth/pending/exchange', + serializeOAuthAdoptionDecision(decision) + ) return data } export async function exchangePendingOAuthCompletion( decision?: OAuthAdoptionDecision ): Promise { - const { data } = await apiClient.post( - '/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, diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 1b1af87b..a8e03a51 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -316,6 +316,7 @@ export const useAppStore = defineStore('app', () => { return { registration_enabled: false, email_verify_enabled: false, + force_email_on_third_party_signup: false, registration_email_suffix_whitelist: [], promo_code_enabled: true, password_reset_enabled: false, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a19d6c26..a4b2277b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -142,6 +142,7 @@ export interface CustomEndpoint { export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean + force_email_on_third_party_signup: boolean registration_email_suffix_whitelist: string[] promo_code_enabled: boolean password_reset_enabled: boolean diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index 6dc8f242..00b73868 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -11,7 +11,10 @@ -
+
+ + + +
@@ -127,11 +214,12 @@