Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
6ea3f42e
"backend/internal/handler/vscode:/vscode.git/clone" did not exist on "9e84e2fd2bed765d38ca4a438fbbf1739950ae32"
Commit
6ea3f42e
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: add oauth callback email binding ui
parent
6a75bd77
Changes
10
Show whitespace changes
Inline
Side-by-side
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
View file @
6ea3f42e
...
@@ -30,6 +30,20 @@ describe('oauth adoption auth api', () => {
...
@@ -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
()
=>
{
it
(
'
posts linuxdo invitation completion with adoption decisions
'
,
async
()
=>
{
const
{
completeLinuxDoOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
const
{
completeLinuxDoOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
...
@@ -45,6 +59,21 @@ describe('oauth adoption auth api', () => {
...
@@ -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
()
=>
{
it
(
'
posts oidc invitation completion with adoption decisions
'
,
async
()
=>
{
const
{
completeOIDCOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
const
{
completeOIDCOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
...
@@ -60,6 +89,21 @@ describe('oauth adoption auth api', () => {
...
@@ -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
()
=>
{
it
(
'
posts wechat invitation completion with adoption decisions
'
,
async
()
=>
{
const
{
completeWeChatOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
const
{
completeWeChatOAuthRegistration
}
=
await
import
(
'
@/api/auth
'
)
...
@@ -75,6 +119,21 @@ describe('oauth adoption auth api', () => {
...
@@ -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
()
=>
{
it
(
'
classifies oauth completion results as login or bind
'
,
async
()
=>
{
const
{
getOAuthCompletionKind
}
=
await
import
(
'
@/api/auth
'
)
const
{
getOAuthCompletionKind
}
=
await
import
(
'
@/api/auth
'
)
...
@@ -82,6 +141,38 @@ describe('oauth adoption auth api', () => {
...
@@ -82,6 +141,38 @@ describe('oauth adoption auth api', () => {
expect
(
getOAuthCompletionKind
({
redirect
:
'
/profile
'
})).
toBe
(
'
bind
'
)
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
()
=>
{
it
(
'
prepares an oauth bind access token cookie before redirect binding
'
,
async
()
=>
{
localStorage
.
setItem
(
'
auth_token
'
,
'
access-token-value
'
)
localStorage
.
setItem
(
'
auth_token
'
,
'
access-token-value
'
)
const
setCookie
=
vi
.
fn
()
const
setCookie
=
vi
.
fn
()
...
...
frontend/src/api/auth.ts
View file @
6ea3f42e
...
@@ -193,7 +193,7 @@ export interface OAuthTokenResponse {
...
@@ -193,7 +193,7 @@ export interface OAuthTokenResponse {
token_type
?:
string
token_type
?:
string
}
}
export
interface
PendingOAuth
Exchange
Response
extends
Partial
<
OAuthTokenResponse
>
{
export
interface
PendingOAuth
BindLogin
Response
extends
Partial
<
OAuthTokenResponse
>
{
redirect
?:
string
redirect
?:
string
error
?:
string
error
?:
string
adoption_required
?:
boolean
adoption_required
?:
boolean
...
@@ -201,6 +201,10 @@ export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse
...
@@ -201,6 +201,10 @@ export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse
suggested_avatar_url
?:
string
suggested_avatar_url
?:
string
}
}
export
type
PendingOAuthExchangeResponse
=
PendingOAuthBindLoginResponse
export
interface
PendingOAuthCreateAccountResponse
extends
OAuthTokenResponse
{}
export
type
OAuthCompletionKind
=
'
login
'
|
'
bind
'
export
type
OAuthCompletionKind
=
'
login
'
|
'
bind
'
export
interface
OAuthAdoptionDecision
{
export
interface
OAuthAdoptionDecision
{
...
@@ -235,6 +239,27 @@ export function getOAuthCompletionKind(
...
@@ -235,6 +239,27 @@ export function getOAuthCompletionKind(
return
isOAuthLoginCompletion
(
completion
)
?
'
login
'
:
'
bind
'
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
{
export
function
persistOAuthTokenContext
(
tokens
:
Partial
<
OAuthTokenResponse
>
):
void
{
if
(
tokens
.
refresh_token
)
{
if
(
tokens
.
refresh_token
)
{
setRefreshToken
(
tokens
.
refresh_token
)
setRefreshToken
(
tokens
.
refresh_token
)
...
@@ -431,11 +456,7 @@ export async function completeLinuxDoOAuthRegistration(
...
@@ -431,11 +456,7 @@ export async function completeLinuxDoOAuthRegistration(
invitationCode
:
string
,
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
decision
?:
OAuthAdoptionDecision
):
Promise
<
OAuthTokenResponse
>
{
):
Promise
<
OAuthTokenResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
return
createPendingLinuxDoOAuthAccount
(
invitationCode
,
decision
)
invitation_code
:
invitationCode
,
...
serializeOAuthAdoptionDecision
(
decision
)
})
return
data
}
}
/**
/**
...
@@ -447,34 +468,68 @@ export async function completeOIDCOAuthRegistration(
...
@@ -447,34 +468,68 @@ export async function completeOIDCOAuthRegistration(
invitationCode
:
string
,
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
decision
?:
OAuthAdoptionDecision
):
Promise
<
OAuthTokenResponse
>
{
):
Promise
<
OAuthTokenResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
return
createPendingOIDCOAuthAccount
(
invitationCode
,
decision
)
invitation_code
:
invitationCode
,
...
serializeOAuthAdoptionDecision
(
decision
)
})
return
data
}
}
export
async
function
completeWeChatOAuthRegistration
(
export
async
function
completeWeChatOAuthRegistration
(
invitationCode
:
string
,
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
decision
?:
OAuthAdoptionDecision
):
Promise
<
OAuthTokenResponse
>
{
):
Promise
<
OAuthTokenResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/wechat/complete-registration
'
,
{
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
,
invitation_code
:
invitationCode
,
...
serializeOAuthAdoptionDecision
(
decision
)
...
serializeOAuthAdoptionDecision
(
decision
)
})
}
)
return
data
return
data
}
}
export
async
function
exchangePendingOAuthCompletion
(
export
async
function
createPendingLinuxDoOAuthAccount
(
invitationCode
:
string
,
decision
?:
OAuthAdoptionDecision
decision
?:
OAuthAdoptionDecision
):
Promise
<
PendingOAuthExchangeResponse
>
{
):
Promise
<
PendingOAuthCreateAccountResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthExchangeResponse
>
(
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
'
,
'
/auth/oauth/pending/exchange
'
,
serializeOAuthAdoptionDecision
(
decision
)
serializeOAuthAdoptionDecision
(
decision
)
)
)
return
data
return
data
}
}
export
async
function
exchangePendingOAuthCompletion
(
decision
?:
OAuthAdoptionDecision
):
Promise
<
PendingOAuthExchangeResponse
>
{
return
completePendingOAuthBindLogin
(
decision
)
}
export
const
authAPI
=
{
export
const
authAPI
=
{
login
,
login
,
login2FA
,
login2FA
,
...
@@ -498,6 +553,13 @@ export const authAPI = {
...
@@ -498,6 +553,13 @@ export const authAPI = {
resetPassword
,
resetPassword
,
refreshToken
,
refreshToken
,
revokeAllSessions
,
revokeAllSessions
,
getPendingOAuthBindLoginKind
,
isPendingOAuthCreateAccountRequired
,
hasPendingOAuthSuggestedProfile
,
completePendingOAuthBindLogin
,
createPendingLinuxDoOAuthAccount
,
createPendingOIDCOAuthAccount
,
createPendingWeChatOAuthAccount
,
exchangePendingOAuthCompletion
,
exchangePendingOAuthCompletion
,
completeLinuxDoOAuthRegistration
,
completeLinuxDoOAuthRegistration
,
completeOIDCOAuthRegistration
,
completeOIDCOAuthRegistration
,
...
...
frontend/src/stores/app.ts
View file @
6ea3f42e
...
@@ -316,6 +316,7 @@ export const useAppStore = defineStore('app', () => {
...
@@ -316,6 +316,7 @@ export const useAppStore = defineStore('app', () => {
return
{
return
{
registration_enabled
:
false
,
registration_enabled
:
false
,
email_verify_enabled
:
false
,
email_verify_enabled
:
false
,
force_email_on_third_party_signup
:
false
,
registration_email_suffix_whitelist
:
[],
registration_email_suffix_whitelist
:
[],
promo_code_enabled
:
true
,
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
password_reset_enabled
:
false
,
...
...
frontend/src/types/index.ts
View file @
6ea3f42e
...
@@ -142,6 +142,7 @@ export interface CustomEndpoint {
...
@@ -142,6 +142,7 @@ export interface CustomEndpoint {
export
interface
PublicSettings
{
export
interface
PublicSettings
{
registration_enabled
:
boolean
registration_enabled
:
boolean
email_verify_enabled
:
boolean
email_verify_enabled
:
boolean
force_email_on_third_party_signup
:
boolean
registration_email_suffix_whitelist
:
string
[]
registration_email_suffix_whitelist
:
string
[]
promo_code_enabled
:
boolean
promo_code_enabled
:
boolean
password_reset_enabled
:
boolean
password_reset_enabled
:
boolean
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
6ea3f42e
...
@@ -11,7 +11,10 @@
...
@@ -11,7 +11,10 @@
</div>
</div>
<transition
name=
"fade"
>
<transition
name=
"fade"
>
<div
v-if=
"needsInvitation || needsAdoptionConfirmation"
class=
"space-y-4"
>
<div
v-if=
"needsInvitation || needsAdoptionConfirmation || needsCreateAccount || needsBindLogin"
class=
"space-y-4"
>
<div
<div
v-if=
"adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
v-if=
"adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
class=
"rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
class=
"rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
...
@@ -99,6 +102,90 @@
...
@@ -99,6 +102,90 @@
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
</button>
</button>
</
template
>
</
template
>
<
template
v-else-if=
"needsCreateAccount"
>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Enter an email address to create your account and continue.
</p>
<div
class=
"space-y-3"
>
<input
v-model=
"pendingAccountEmail"
data-testid=
"linuxdo-create-account-email"
type=
"email"
class=
"input w-full"
placeholder=
"you@example.com"
:disabled=
"isSubmitting"
@
keyup.enter=
"handleCreateAccount"
/>
<button
data-testid=
"linuxdo-create-account-submit"
class=
"btn btn-primary w-full"
:disabled=
"isSubmitting || !pendingAccountEmail.trim()"
@
click=
"handleCreateAccount"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Create account
'
}}
</button>
<button
class=
"btn btn-secondary w-full"
:disabled=
"isSubmitting"
@
click=
"switchToBindLoginMode"
>
I already have an account
</button>
</div>
<transition
name=
"fade"
>
<p
v-if=
"accountActionError"
class=
"text-sm text-red-600 dark:text-red-400"
>
{{
accountActionError
}}
</p>
</transition>
</
template
>
<
template
v-else-if=
"needsBindLogin"
>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Log in to an existing account to bind this LinuxDo sign-in.
</p>
<div
class=
"space-y-3"
>
<input
v-model=
"bindLoginEmail"
data-testid=
"linuxdo-bind-login-email"
type=
"email"
class=
"input w-full"
placeholder=
"you@example.com"
:disabled=
"isSubmitting"
@
keyup.enter=
"handleBindLogin"
/>
<input
v-model=
"bindLoginPassword"
data-testid=
"linuxdo-bind-login-password"
type=
"password"
class=
"input w-full"
placeholder=
"Password"
:disabled=
"isSubmitting"
@
keyup.enter=
"handleBindLogin"
/>
<button
data-testid=
"linuxdo-bind-login-submit"
class=
"btn btn-primary w-full"
:disabled=
"isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
@
click=
"handleBindLogin"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Log in and bind
'
}}
</button>
<button
v-if=
"canReturnToCreateAccount"
class=
"btn btn-secondary w-full"
:disabled=
"isSubmitting"
@
click=
"switchToCreateAccountMode"
>
Use a different email
</button>
</div>
<transition
name=
"fade"
>
<p
v-if=
"accountActionError"
class=
"text-sm text-red-600 dark:text-red-400"
>
{{
accountActionError
}}
</p>
</transition>
</
template
>
</div>
</div>
</transition>
</transition>
...
@@ -127,11 +214,12 @@
...
@@ -127,11 +214,12 @@
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
apiClient
}
from
'
@/api/client
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
import
{
completeLinuxDoOAuthRegistration
,
completeLinuxDoOAuthRegistration
,
...
@@ -165,8 +253,23 @@ const suggestedAvatarUrl = ref('')
...
@@ -165,8 +253,23 @@ const suggestedAvatarUrl = ref('')
const
adoptDisplayName
=
ref
(
true
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
pendingAccountAction
=
ref
<
'
none
'
|
'
create_account
'
|
'
bind_login
'
>
(
'
none
'
)
const
pendingAccountEmail
=
ref
(
''
)
const
bindLoginEmail
=
ref
(
''
)
const
bindLoginPassword
=
ref
(
''
)
const
accountActionError
=
ref
(
''
)
const
canReturnToCreateAccount
=
ref
(
false
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
const
needsCreateAccount
=
computed
(()
=>
pendingAccountAction
.
value
===
'
create_account
'
)
const
needsBindLogin
=
computed
(()
=>
pendingAccountAction
.
value
===
'
bind_login
'
)
type
LinuxDoPendingActionResponse
=
PendingOAuthExchangeResponse
&
{
step
?:
string
email
?:
string
resolved_email
?:
string
}
function
parseFragmentParams
():
URLSearchParams
{
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
...
@@ -189,6 +292,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
...
@@ -189,6 +292,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
}
}
}
}
function
serializeAdoptionDecision
(
decision
:
OAuthAdoptionDecision
):
Record
<
string
,
boolean
>
{
const
payload
:
Record
<
string
,
boolean
>
=
{}
if
(
typeof
decision
.
adoptDisplayName
===
'
boolean
'
)
{
payload
.
adopt_display_name
=
decision
.
adoptDisplayName
}
if
(
typeof
decision
.
adoptAvatar
===
'
boolean
'
)
{
payload
.
adopt_avatar
=
decision
.
adoptAvatar
}
return
payload
}
function
applyAdoptionSuggestionState
(
completion
:
{
function
applyAdoptionSuggestionState
(
completion
:
{
adoption_required
?:
boolean
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_display_name
?:
string
...
@@ -213,6 +327,62 @@ function hasSuggestedProfile(completion: {
...
@@ -213,6 +327,62 @@ function hasSuggestedProfile(completion: {
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
}
function
extractPendingAccountEmail
(
completion
:
LinuxDoPendingActionResponse
):
string
{
return
(
completion
.
email
||
completion
.
resolved_email
||
''
).
trim
()
}
function
resolvePendingAccountAction
(
completion
:
LinuxDoPendingActionResponse
):
'
none
'
|
'
create_account
'
|
'
bind_login
'
{
const
raw
=
(
completion
.
step
||
completion
.
error
||
''
).
trim
().
toLowerCase
()
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
create_account
'
}
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
)
{
return
'
bind_login
'
}
return
'
none
'
}
function
applyPendingAccountAction
(
completion
:
LinuxDoPendingActionResponse
)
{
const
action
=
resolvePendingAccountAction
(
completion
)
pendingAccountAction
.
value
=
action
accountActionError
.
value
=
''
const
email
=
extractPendingAccountEmail
(
completion
)
if
(
action
===
'
create_account
'
)
{
pendingAccountEmail
.
value
=
email
canReturnToCreateAccount
.
value
=
true
return
}
if
(
action
===
'
bind_login
'
)
{
bindLoginEmail
.
value
=
email
bindLoginPassword
.
value
=
''
canReturnToCreateAccount
.
value
=
false
return
}
canReturnToCreateAccount
.
value
=
false
}
function
switchToBindLoginMode
()
{
pendingAccountAction
.
value
=
'
bind_login
'
bindLoginEmail
.
value
=
bindLoginEmail
.
value
.
trim
()
||
pendingAccountEmail
.
value
.
trim
()
bindLoginPassword
.
value
=
''
accountActionError
.
value
=
''
canReturnToCreateAccount
.
value
=
true
}
function
switchToCreateAccountMode
()
{
pendingAccountAction
.
value
=
'
create_account
'
pendingAccountEmail
.
value
=
pendingAccountEmail
.
value
.
trim
()
||
bindLoginEmail
.
value
.
trim
()
accountActionError
.
value
=
''
}
function
getRequestErrorMessage
(
error
:
unknown
,
fallback
:
string
):
string
{
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
return
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
fallback
}
async
function
finalizeCompletion
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
async
function
finalizeCompletion
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
if
(
getOAuthCompletionKind
(
completion
)
===
'
bind
'
)
{
if
(
getOAuthCompletionKind
(
completion
)
===
'
bind
'
)
{
const
bindRedirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
'
/profile
'
)
const
bindRedirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
'
/profile
'
)
...
@@ -231,6 +401,29 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
...
@@ -231,6 +401,29 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
await
router
.
replace
(
redirect
)
await
router
.
replace
(
redirect
)
}
}
async
function
finalizePendingAccountResponse
(
completion
:
LinuxDoPendingActionResponse
)
{
applyAdoptionSuggestionState
(
completion
)
if
(
completion
.
error
===
'
invitation_required
'
)
{
pendingAccountAction
.
value
=
'
none
'
needsInvitation
.
value
=
true
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
return
}
applyPendingAccountAction
(
completion
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
return
}
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
redirectTo
.
value
)
await
finalizeCompletion
(
completion
,
redirect
)
}
async
function
handleSubmitInvitation
()
{
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
if
(
!
invitationCode
.
value
.
trim
())
return
...
@@ -260,12 +453,7 @@ async function handleContinueLogin() {
...
@@ -260,12 +453,7 @@ async function handleContinueLogin() {
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
await
finalizeCompletion
(
completion
,
redirectTo
.
value
)
await
finalizeCompletion
(
completion
,
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
appStore
.
showError
(
errorMessage
.
value
)
needsAdoptionConfirmation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
}
finally
{
}
finally
{
...
@@ -273,6 +461,46 @@ async function handleContinueLogin() {
...
@@ -273,6 +461,46 @@ async function handleContinueLogin() {
}
}
}
}
async
function
handleCreateAccount
()
{
accountActionError
.
value
=
''
const
email
=
pendingAccountEmail
.
value
.
trim
()
if
(
!
email
)
return
isSubmitting
.
value
=
true
try
{
const
{
data
}
=
await
apiClient
.
post
<
LinuxDoPendingActionResponse
>
(
'
/auth/oauth/pending/create-account
'
,
{
email
,
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
})
await
finalizePendingAccountResponse
(
data
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
async
function
handleBindLogin
()
{
accountActionError
.
value
=
''
const
email
=
bindLoginEmail
.
value
.
trim
()
const
password
=
bindLoginPassword
.
value
if
(
!
email
||
!
password
)
return
isSubmitting
.
value
=
true
try
{
const
{
data
}
=
await
apiClient
.
post
<
LinuxDoPendingActionResponse
>
(
'
/auth/oauth/pending/bind-login
'
,
{
email
,
password
,
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
})
await
finalizePendingAccountResponse
(
data
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
const
params
=
parseFragmentParams
()
const
params
=
parseFragmentParams
()
const
error
=
params
.
get
(
'
error
'
)
const
error
=
params
.
get
(
'
error
'
)
...
@@ -299,6 +527,12 @@ onMounted(async () => {
...
@@ -299,6 +527,12 @@ onMounted(async () => {
return
return
}
}
applyPendingAccountAction
(
completion
as
LinuxDoPendingActionResponse
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
isProcessing
.
value
=
false
return
}
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
isProcessing
.
value
=
false
...
@@ -307,12 +541,7 @@ onMounted(async () => {
...
@@ -307,12 +541,7 @@ onMounted(async () => {
await
finalizeCompletion
(
completion
,
redirect
)
await
finalizeCompletion
(
completion
,
redirect
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
isProcessing
.
value
=
false
}
}
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
6ea3f42e
...
@@ -15,7 +15,15 @@
...
@@ -15,7 +15,15 @@
<
/div
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation || needsAdoptionConfirmation
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
needsInvitation ||
needsEmailCollection ||
needsExistingAccountBinding ||
needsAdoptionConfirmation
"
class
=
"
space-y-4
"
>
<
div
<
div
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
...
@@ -100,6 +108,39 @@
...
@@ -100,6 +108,39 @@
<
/button
>
<
/button
>
<
/template
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsEmailCollection
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Continue
with
email
to
finish
setting
up
your
{{
providerName
}}
sign
-
in
.
<
/p
>
<
div
>
<
input
v
-
model
=
"
pendingEmail
"
type
=
"
email
"
class
=
"
input w-full
"
placeholder
=
"
you@example.com
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleContinueWithEmail
"
/>
<
/div
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !pendingEmail.trim()
"
@
click
=
"
handleContinueWithEmail
"
>
Continue
with
email
<
/button
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsExistingAccountBinding
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Sign
in
to
bind
{{
providerName
}}
to
the
existing
account
for
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
pendingEmail
}}
<
/span>
.
<
/p
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueToLogin
"
>
Sign
in
to
bind
<
/button
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Review
the
{{
providerName
}}
profile
details
before
continuing
.
Review
the
{{
providerName
}}
profile
details
before
continuing
.
...
@@ -174,9 +215,22 @@ const suggestedDisplayName = ref('')
...
@@ -174,9 +215,22 @@ const suggestedDisplayName = ref('')
const
suggestedAvatarUrl
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
pendingEmail
=
ref
(
''
)
const
needsEmailCollection
=
ref
(
false
)
const
needsExistingAccountBinding
=
ref
(
false
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
type
PendingOidcCompletion
=
PendingOAuthExchangeResponse
&
{
step
?:
string
pending_email
?:
string
resolved_email
?:
string
existing_account_email
?:
string
email
?:
string
provider_fallback
?:
string
intent
?:
string
}
function
parseFragmentParams
():
URLSearchParams
{
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
const
hash
=
raw
.
startsWith
(
'
#
'
)
?
raw
.
slice
(
1
)
:
raw
...
@@ -204,6 +258,34 @@ async function loadProviderName() {
...
@@ -204,6 +258,34 @@ async function loadProviderName() {
}
}
}
}
function
normalizedPendingState
(
value
:
string
|
null
|
undefined
):
string
{
return
value
?.
trim
().
toLowerCase
()
||
''
}
function
resolvePendingEmail
(
completion
:
PendingOidcCompletion
):
string
{
return
(
completion
.
pending_email
||
completion
.
existing_account_email
||
completion
.
resolved_email
||
completion
.
email
||
''
).
trim
()
}
function
requiresEmailCollection
(
completion
:
PendingOidcCompletion
):
boolean
{
const
state
=
normalizedPendingState
(
completion
.
step
||
completion
.
error
)
return
state
===
'
email_required
'
}
function
requiresExistingAccountBinding
(
completion
:
PendingOidcCompletion
):
boolean
{
const
state
=
normalizedPendingState
(
completion
.
step
||
completion
.
error
||
completion
.
intent
)
return
(
state
===
'
existing_account_binding_required
'
||
state
===
'
existing_account_required
'
||
state
===
'
adopt_existing_user_by_email
'
)
}
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
return
{
return
{
adoptDisplayName
:
adoptDisplayName
.
value
,
adoptDisplayName
:
adoptDisplayName
.
value
,
...
@@ -295,6 +377,35 @@ async function handleContinueLogin() {
...
@@ -295,6 +377,35 @@ async function handleContinueLogin() {
}
}
}
}
async
function
handleContinueWithEmail
()
{
const
email
=
pendingEmail
.
value
.
trim
()
if
(
!
email
)
{
return
}
await
router
.
replace
({
path
:
'
/register
'
,
query
:
{
email
,
redirect
:
redirectTo
.
value
,
provider
:
providerName
.
value
}
}
)
}
async
function
handleContinueToLogin
()
{
const
email
=
pendingEmail
.
value
.
trim
()
await
router
.
replace
({
path
:
'
/login
'
,
query
:
{
email
,
redirect
:
redirectTo
.
value
,
provider
:
providerName
.
value
}
}
)
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
void
loadProviderName
()
void
loadProviderName
()
...
@@ -310,12 +421,13 @@ onMounted(async () => {
...
@@ -310,12 +421,13 @@ onMounted(async () => {
}
}
try
{
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
const
completion
=
await
exchangePendingOAuthCompletion
()
as
PendingOidcCompletion
const
redirect
=
sanitizeRedirectPath
(
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
)
applyAdoptionSuggestionState
(
completion
)
applyAdoptionSuggestionState
(
completion
)
redirectTo
.
value
=
redirect
redirectTo
.
value
=
redirect
pendingEmail
.
value
=
resolvePendingEmail
(
completion
)
if
(
completion
.
error
===
'
invitation_required
'
)
{
if
(
completion
.
error
===
'
invitation_required
'
)
{
needsInvitation
.
value
=
true
needsInvitation
.
value
=
true
...
@@ -323,6 +435,18 @@ onMounted(async () => {
...
@@ -323,6 +435,18 @@ onMounted(async () => {
return
return
}
}
if
(
requiresEmailCollection
(
completion
))
{
needsEmailCollection
.
value
=
true
isProcessing
.
value
=
false
return
}
if
(
requiresExistingAccountBinding
(
completion
))
{
needsExistingAccountBinding
.
value
=
true
isProcessing
.
value
=
false
return
}
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
isProcessing
.
value
=
false
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
6ea3f42e
...
@@ -97,6 +97,40 @@
...
@@ -97,6 +97,40 @@
:
t
(
'
auth.oidc.completeRegistration
'
)
:
t
(
'
auth.oidc.completeRegistration
'
)
}}
}}
<
/button
>
<
/button
>
<
div
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
>
<
div
class
=
"
space-y-3
"
>
<
div
class
=
"
space-y-1
"
>
<
p
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
auth.alreadyHaveAccount
'
)
}}
<
/p
>
<
p
class
=
"
text-xs text-gray-500 dark:text-dark-400
"
>
Sign
in
to
an
existing
account
,
then
bind
this
WeChat
identity
to
it
.
<
/p
>
<
/div
>
<
input
v
-
model
=
"
existingAccountEmail
"
data
-
testid
=
"
existing-account-email
"
type
=
"
email
"
class
=
"
input w-full
"
:
placeholder
=
"
t('auth.emailPlaceholder')
"
:
disabled
=
"
isSubmitting
"
/>
<
button
data
-
testid
=
"
existing-account-submit
"
type
=
"
button
"
class
=
"
btn btn-secondary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleExistingAccountBinding
"
>
{{
t
(
'
auth.signIn
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
<
template
v
-
else
-
if
=
"
needsAdoptionConfirmation
"
>
...
@@ -144,8 +178,10 @@ import { useAuthStore, useAppStore } from '@/stores'
...
@@ -144,8 +178,10 @@ import { useAuthStore, useAppStore } from '@/stores'
import
{
import
{
completeWeChatOAuthRegistration
,
completeWeChatOAuthRegistration
,
exchangePendingOAuthCompletion
,
exchangePendingOAuthCompletion
,
getAuthToken
,
getOAuthCompletionKind
,
getOAuthCompletionKind
,
isOAuthLoginCompletion
,
isOAuthLoginCompletion
,
prepareOAuthBindAccessTokenCookie
,
persistOAuthTokenContext
,
persistOAuthTokenContext
,
type
OAuthAdoptionDecision
,
type
OAuthAdoptionDecision
,
type
PendingOAuthExchangeResponse
type
PendingOAuthExchangeResponse
...
@@ -168,6 +204,7 @@ const redirectTo = ref('/dashboard')
...
@@ -168,6 +204,7 @@ const redirectTo = ref('/dashboard')
const
adoptionRequired
=
ref
(
false
)
const
adoptionRequired
=
ref
(
false
)
const
suggestedDisplayName
=
ref
(
''
)
const
suggestedDisplayName
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
suggestedAvatarUrl
=
ref
(
''
)
const
existingAccountEmail
=
ref
(
''
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
needsAdoptionConfirmation
=
ref
(
false
)
...
@@ -190,6 +227,50 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
...
@@ -190,6 +227,50 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
return
path
return
path
}
}
function
resolveWeChatOAuthMode
():
'
open
'
|
'
mp
'
{
if
(
typeof
navigator
===
'
undefined
'
)
{
return
'
open
'
}
return
/MicroMessenger/i
.
test
(
navigator
.
userAgent
)
?
'
mp
'
:
'
open
'
}
function
resolveRedirectTarget
():
string
{
return
sanitizeRedirectPath
(
(
route
.
query
.
redirect
as
string
|
undefined
)
||
redirectTo
.
value
||
'
/dashboard
'
)
}
function
resolveWeChatStartURL
(
intent
:
'
bind_current_user
'
|
'
adopt_existing_user_by_email
'
):
string
{
const
apiBase
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
as
string
|
undefined
)
||
'
/api/v1
'
const
normalized
=
apiBase
.
replace
(
/
\/
$/
,
''
)
const
params
=
new
URLSearchParams
({
mode
:
resolveWeChatOAuthMode
(),
redirect
:
resolveRedirectTarget
(),
intent
,
}
)
const
email
=
existingAccountEmail
.
value
.
trim
()
if
(
email
)
{
params
.
set
(
'
email
'
,
email
)
}
return
`${normalized
}
/auth/oauth/wechat/start?${params.toString()
}
`
}
function
buildExistingAccountResumePath
():
string
{
const
params
=
new
URLSearchParams
({
wechat_bind_existing
:
'
1
'
,
redirect
:
resolveRedirectTarget
(),
}
)
const
email
=
existingAccountEmail
.
value
.
trim
()
if
(
email
)
{
params
.
set
(
'
email
'
,
email
)
}
return
`/auth/wechat/callback?${params.toString()
}
`
}
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
function
currentAdoptionDecision
():
OAuthAdoptionDecision
{
return
{
return
{
adoptDisplayName
:
adoptDisplayName
.
value
,
adoptDisplayName
:
adoptDisplayName
.
value
,
...
@@ -197,6 +278,23 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
...
@@ -197,6 +278,23 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
}
}
}
}
async
function
handleExistingAccountBinding
()
{
if
(
getAuthToken
())
{
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
resolveWeChatStartURL
(
'
bind_current_user
'
)
return
}
const
params
=
new
URLSearchParams
({
redirect
:
buildExistingAccountResumePath
(),
}
)
const
email
=
existingAccountEmail
.
value
.
trim
()
if
(
email
)
{
params
.
set
(
'
email
'
,
email
)
}
await
router
.
replace
(
`/login?${params.toString()
}
`
)
}
function
applyAdoptionSuggestionState
(
completion
:
PendingOAuthExchangeResponse
)
{
function
applyAdoptionSuggestionState
(
completion
:
PendingOAuthExchangeResponse
)
{
adoptionRequired
.
value
=
completion
.
adoption_required
===
true
adoptionRequired
.
value
=
completion
.
adoption_required
===
true
suggestedDisplayName
.
value
=
completion
.
suggested_display_name
||
''
suggestedDisplayName
.
value
=
completion
.
suggested_display_name
||
''
...
@@ -275,6 +373,16 @@ async function handleContinueLogin() {
...
@@ -275,6 +373,16 @@ async function handleContinueLogin() {
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
if
(
typeof
route
.
query
.
email
===
'
string
'
)
{
existingAccountEmail
.
value
=
route
.
query
.
email
}
if
(
route
.
query
.
wechat_bind_existing
===
'
1
'
&&
getAuthToken
())
{
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
resolveWeChatStartURL
(
'
bind_current_user
'
)
return
}
const
params
=
parseFragmentParams
()
const
params
=
parseFragmentParams
()
const
error
=
params
.
get
(
'
error
'
)
const
error
=
params
.
get
(
'
error
'
)
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
const
errorDesc
=
params
.
get
(
'
error_description
'
)
||
params
.
get
(
'
error_message
'
)
||
''
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
6ea3f42e
...
@@ -9,6 +9,7 @@ const showError = vi.fn()
...
@@ -9,6 +9,7 @@ const showError = vi.fn()
const
setToken
=
vi
.
fn
()
const
setToken
=
vi
.
fn
()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
completeLinuxDoOAuthRegistration
=
vi
.
fn
()
const
completeLinuxDoOAuthRegistration
=
vi
.
fn
()
const
apiClientPost
=
vi
.
fn
()
vi
.
mock
(
'
vue-router
'
,
()
=>
({
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
({
useRoute
:
()
=>
({
...
@@ -39,6 +40,12 @@ vi.mock('@/stores', () => ({
...
@@ -39,6 +40,12 @@ vi.mock('@/stores', () => ({
})
})
}))
}))
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
:
(...
args
:
any
[])
=>
apiClientPost
(...
args
)
}
}))
vi
.
mock
(
'
@/api/auth
'
,
async
()
=>
{
vi
.
mock
(
'
@/api/auth
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
@/api/auth
'
)
>
(
'
@/api/auth
'
)
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
@/api/auth
'
)
>
(
'
@/api/auth
'
)
return
{
return
{
...
@@ -56,6 +63,7 @@ describe('LinuxDoCallbackView', () => {
...
@@ -56,6 +63,7 @@ describe('LinuxDoCallbackView', () => {
setToken
.
mockReset
()
setToken
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
completeLinuxDoOAuthRegistration
.
mockReset
()
completeLinuxDoOAuthRegistration
.
mockReset
()
apiClientPost
.
mockReset
()
})
})
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
...
@@ -239,4 +247,101 @@ describe('LinuxDoCallbackView', () => {
...
@@ -239,4 +247,101 @@ describe('LinuxDoCallbackView', () => {
adoptAvatar
:
true
adoptAvatar
:
true
})
})
})
})
it
(
'
collects email for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
redirect
:
'
/welcome
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
apiClientPost
.
mockResolvedValue
({
data
:
{
access_token
:
'
new-access-token
'
,
refresh_token
:
'
new-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
}
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-create-account-email"]
'
).
setValue
(
'
new@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-create-account-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
apiClientPost
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/create-account
'
,
{
email
:
'
new@example.com
'
,
adopt_display_name
:
true
,
adopt_avatar
:
false
})
expect
(
setToken
).
toHaveBeenCalledWith
(
'
new-access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/welcome
'
)
})
it
(
'
shows bind-login form for existing account binding and submits credentials with adoption decisions
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
bind_login_required
'
,
redirect
:
'
/profile/security
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
apiClientPost
.
mockResolvedValue
({
data
:
{
access_token
:
'
bind-access-token
'
,
refresh_token
:
'
bind-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
}
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
0
].
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-email"]
'
).
setValue
(
'
existing@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-password"]
'
).
setValue
(
'
secret-password
'
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
apiClientPost
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/bind-login
'
,
{
email
:
'
existing@example.com
'
,
password
:
'
secret-password
'
,
adopt_display_name
:
false
,
adopt_avatar
:
true
})
expect
(
setToken
).
toHaveBeenCalledWith
(
'
bind-access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile/security
'
)
})
})
})
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
View file @
6ea3f42e
...
@@ -184,6 +184,77 @@ describe('OidcCallbackView', () => {
...
@@ -184,6 +184,77 @@ describe('OidcCallbackView', () => {
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile
'
)
})
})
it
(
'
renders pending email collection ui and routes to register with the entered email
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
redirect
:
'
/profile
'
,
provider_fallback
:
'
ExampleID
'
})
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
setToken
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
Continue with email
'
)
await
wrapper
.
get
(
'
input[type="email"]
'
).
setValue
(
'
alice@example.com
'
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
replace
).
toHaveBeenCalledWith
({
path
:
'
/register
'
,
query
:
{
email
:
'
alice@example.com
'
,
redirect
:
'
/profile
'
,
provider
:
'
ExampleID
'
}
})
})
it
(
'
renders existing-account binding ui and routes to login
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
existing_account_binding_required
'
,
redirect
:
'
/profile
'
,
existing_account_email
:
'
alice@example.com
'
})
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
alice@example.com
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Sign in to bind
'
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
replace
).
toHaveBeenCalledWith
({
path
:
'
/login
'
,
query
:
{
email
:
'
alice@example.com
'
,
redirect
:
'
/profile
'
,
provider
:
'
ExampleID
'
}
})
})
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
error
:
'
invitation_required
'
,
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
6ea3f42e
...
@@ -5,14 +5,19 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
...
@@ -5,14 +5,19 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
const
{
const
{
exchangePendingOAuthCompletionMock
,
exchangePendingOAuthCompletionMock
,
completeWeChatOAuthRegistrationMock
,
completeWeChatOAuthRegistrationMock
,
prepareOAuthBindAccessTokenCookieMock
,
getAuthTokenMock
,
replaceMock
,
replaceMock
,
setTokenMock
,
setTokenMock
,
showSuccessMock
,
showSuccessMock
,
showErrorMock
,
showErrorMock
,
routeState
,
routeState
,
locationState
,
}
=
vi
.
hoisted
(()
=>
({
}
=
vi
.
hoisted
(()
=>
({
exchangePendingOAuthCompletionMock
:
vi
.
fn
(),
exchangePendingOAuthCompletionMock
:
vi
.
fn
(),
completeWeChatOAuthRegistrationMock
:
vi
.
fn
(),
completeWeChatOAuthRegistrationMock
:
vi
.
fn
(),
prepareOAuthBindAccessTokenCookieMock
:
vi
.
fn
(),
getAuthTokenMock
:
vi
.
fn
(),
replaceMock
:
vi
.
fn
(),
replaceMock
:
vi
.
fn
(),
setTokenMock
:
vi
.
fn
(),
setTokenMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
...
@@ -20,6 +25,14 @@ const {
...
@@ -20,6 +25,14 @@ const {
routeState
:
{
routeState
:
{
query
:
{}
as
Record
<
string
,
unknown
>
,
query
:
{}
as
Record
<
string
,
unknown
>
,
},
},
locationState
:
{
current
:
{
href
:
'
http://localhost/auth/wechat/callback
'
,
hash
:
''
,
search
:
''
,
pathname
:
'
/auth/wechat/callback
'
}
as
{
href
:
string
;
hash
:
string
;
search
:
string
;
pathname
:
string
},
},
}))
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
vi
.
mock
(
'
vue-router
'
,
()
=>
({
...
@@ -94,6 +107,8 @@ vi.mock('@/api/auth', async () => {
...
@@ -94,6 +107,8 @@ vi.mock('@/api/auth', async () => {
...
actual
,
...
actual
,
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletionMock
(...
args
),
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletionMock
(...
args
),
completeWeChatOAuthRegistration
:
(...
args
:
any
[])
=>
completeWeChatOAuthRegistrationMock
(...
args
),
completeWeChatOAuthRegistration
:
(...
args
:
any
[])
=>
completeWeChatOAuthRegistrationMock
(...
args
),
prepareOAuthBindAccessTokenCookie
:
(...
args
:
any
[])
=>
prepareOAuthBindAccessTokenCookieMock
(...
args
),
getAuthToken
:
(...
args
:
any
[])
=>
getAuthTokenMock
(...
args
),
}
}
})
})
...
@@ -105,8 +120,24 @@ describe('WechatCallbackView', () => {
...
@@ -105,8 +120,24 @@ describe('WechatCallbackView', () => {
setTokenMock
.
mockReset
()
setTokenMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
showErrorMock
.
mockReset
()
prepareOAuthBindAccessTokenCookieMock
.
mockReset
()
getAuthTokenMock
.
mockReset
()
routeState
.
query
=
{}
routeState
.
query
=
{}
localStorage
.
clear
()
localStorage
.
clear
()
locationState
.
current
=
{
href
:
'
http://localhost/auth/wechat/callback
'
,
hash
:
''
,
search
:
''
,
pathname
:
'
/auth/wechat/callback
'
}
Object
.
defineProperty
(
window
,
'
location
'
,
{
configurable
:
true
,
value
:
locationState
.
current
,
})
Object
.
defineProperty
(
window
.
navigator
,
'
userAgent
'
,
{
configurable
:
true
,
value
:
'
Mozilla/5.0
'
,
})
})
})
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
it
(
'
does not send adoption decisions during the initial exchange
'
,
async
()
=>
{
...
@@ -269,4 +300,61 @@ describe('WechatCallbackView', () => {
...
@@ -269,4 +300,61 @@ describe('WechatCallbackView', () => {
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
wechat-invite-token
'
)
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
wechat-invite-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/subscriptions
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/subscriptions
'
)
})
})
it
(
'
offers existing-account email collection during invitation flow
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/usage
'
,
})
getAuthTokenMock
.
mockReturnValue
(
null
)
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
const
emailInput
=
wrapper
.
get
(
'
[data-testid="existing-account-email"]
'
)
await
emailInput
.
setValue
(
'
user@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="existing-account-submit"]
'
).
trigger
(
'
click
'
)
expect
(
replaceMock
).
toHaveBeenCalledTimes
(
1
)
expect
(
replaceMock
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
/login?
'
)
expect
(
replaceMock
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
wechat_bind_existing%3D1
'
)
expect
(
replaceMock
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
email=user%40example.com
'
)
})
it
(
'
restarts the current-user bind flow after returning from login
'
,
async
()
=>
{
routeState
.
query
=
{
wechat_bind_existing
:
'
1
'
,
redirect
:
'
/profile
'
}
getAuthTokenMock
.
mockReturnValue
(
'
existing-auth-token
'
)
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
expect
(
exchangePendingOAuthCompletionMock
).
not
.
toHaveBeenCalled
()
expect
(
prepareOAuthBindAccessTokenCookieMock
).
toHaveBeenCalledTimes
(
1
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/start?
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
})
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment