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
bffcc204
Commit
bffcc204
authored
Apr 20, 2026
by
IanShaw027
Browse files
fix: complete oidc pending auth callback flows
parent
724f8e89
Changes
2
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/auth/OidcCallbackView.vue
View file @
bffcc204
...
@@ -18,9 +18,10 @@
...
@@ -18,9 +18,10 @@
<
div
<
div
v
-
if
=
"
v
-
if
=
"
needsInvitation ||
needsInvitation ||
needsEmailCollection ||
needsAdoptionConfirmation ||
needsExistingAccountBinding ||
needsCreateAccount ||
needsAdoptionConfirmation
needsBindLogin ||
needsTotpChallenge
"
"
class
=
"
space-y-4
"
class
=
"
space-y-4
"
>
>
...
@@ -108,46 +109,131 @@
...
@@ -108,46 +109,131 @@
<
/button
>
<
/button
>
<
/template
>
<
/template
>
<
template
v
-
else
-
if
=
"
needs
EmailCollec
tion
"
>
<
template
v
-
else
-
if
=
"
needs
AdoptionConfirma
tion
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Continue
with
email
to
finish
setting
up
your
{{
providerName
}}
sign
-
in
.
Review
the
{{
providerName
}}
profile
details
before
continu
in
g
.
<
/p
>
<
/p
>
<
div
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueLogin
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
<
/button
>
<
/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
<
input
v
-
model
=
"
pendingEmail
"
v
-
model
=
"
pendingAccountEmail
"
data
-
testid
=
"
oidc-create-account-email
"
type
=
"
email
"
type
=
"
email
"
class
=
"
input w-full
"
class
=
"
input w-full
"
placeholder
=
"
you@example.com
"
placeholder
=
"
you@example.com
"
:
disabled
=
"
isSubmitting
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleC
ontinueWithEmail
"
@
keyup
.
enter
=
"
handleC
reateAccount
"
/>
/>
<
button
data
-
testid
=
"
oidc-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
>
<
/div
>
<
button
<
transition
name
=
"
fade
"
>
class
=
"
btn btn-primary w-full
"
<
p
v
-
if
=
"
accountActionError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
:
disabled
=
"
isSubmitting || !pendingEmail.trim()
"
{{
accountActionError
}}
@
click
=
"
handleContinueWithEmail
"
<
/p
>
>
<
/transition
>
Continue
with
email
<
/button
>
<
/template
>
<
/template
>
<
template
v
-
else
-
if
=
"
needs
ExistingAccount
Bindin
g
"
>
<
template
v
-
else
-
if
=
"
needsBind
Log
in
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Sign
in
to
bind
{{
providerName
}}
to
the
existing
account
for
Log
in
to
an
existing
account
to
bind
this
{{
providerName
}}
sign
-
in
.
<
span
class
=
"
font-medium text-gray-900 dark:text-white
"
>
{{
pendingEmail
}}
<
/span>
.
<
/p
>
<
/p
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueToLogin
"
>
<
div
class
=
"
space-y-3
"
>
Sign
in
to
bind
<
input
<
/button
>
v
-
model
=
"
bindLoginEmail
"
data
-
testid
=
"
oidc-bind-login-email
"
type
=
"
email
"
class
=
"
input w-full
"
placeholder
=
"
you@example.com
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleBindLogin
"
/>
<
input
v
-
model
=
"
bindLoginPassword
"
data
-
testid
=
"
oidc-bind-login-password
"
type
=
"
password
"
class
=
"
input w-full
"
placeholder
=
"
Password
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleBindLogin
"
/>
<
button
data
-
testid
=
"
oidc-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
>
<
/template
>
<
template
v
-
else
-
if
=
"
needs
AdoptionConfirmation
"
>
<
template
v
-
else
-
if
=
"
needs
TotpChallenge
"
>
<
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
.
Enter
the
6
-
digit
verification
code
for
<
span
class
=
"
font-medium
"
>
{{
totpUserEmailMasked
||
'
your account
'
}}
<
/span
>
to
finish
binding
this
{{
providerName
}}
sign
-
in
.
<
/p
>
<
/p
>
<
button
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
handleContinueLogin
"
>
<
div
class
=
"
space-y-3
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
<
input
<
/button
>
v
-
model
=
"
totpCode
"
data
-
testid
=
"
oidc-bind-login-totp
"
type
=
"
text
"
inputmode
=
"
numeric
"
maxlength
=
"
6
"
class
=
"
input w-full
"
placeholder
=
"
123456
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleSubmitTotpChallenge
"
/>
<
button
data
-
testid
=
"
oidc-bind-login-totp-submit
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || totpCode.trim().length !== 6
"
@
click
=
"
handleSubmitTotpChallenge
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Verify and continue
'
}}
<
/button
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
totpError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
totpError
}}
<
/p
>
<
/transition
>
<
/template
>
<
/template
>
<
/div
>
<
/div
>
<
/transition
>
<
/transition
>
...
@@ -177,11 +263,12 @@
...
@@ -177,11 +263,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
{
completeOIDCOAuthRegistration
,
completeOIDCOAuthRegistration
,
...
@@ -189,6 +276,7 @@ import {
...
@@ -189,6 +276,7 @@ import {
getOAuthCompletionKind
,
getOAuthCompletionKind
,
getPublicSettings
,
getPublicSettings
,
isOAuthLoginCompletion
,
isOAuthLoginCompletion
,
login2FA
,
persistOAuthTokenContext
,
persistOAuthTokenContext
,
type
OAuthAdoptionDecision
,
type
OAuthAdoptionDecision
,
type
PendingOAuthExchangeResponse
type
PendingOAuthExchangeResponse
...
@@ -203,7 +291,6 @@ const appStore = useAppStore()
...
@@ -203,7 +291,6 @@ const appStore = useAppStore()
const
isProcessing
=
ref
(
true
)
const
isProcessing
=
ref
(
true
)
const
errorMessage
=
ref
(
''
)
const
errorMessage
=
ref
(
''
)
const
needsInvitation
=
ref
(
false
)
const
needsInvitation
=
ref
(
false
)
const
invitationCode
=
ref
(
''
)
const
invitationCode
=
ref
(
''
)
const
isSubmitting
=
ref
(
false
)
const
isSubmitting
=
ref
(
false
)
...
@@ -215,11 +302,22 @@ const suggestedDisplayName = ref('')
...
@@ -215,11 +302,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
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
needsTotpChallenge
=
ref
(
false
)
const
totpTempToken
=
ref
(
''
)
const
totpCode
=
ref
(
''
)
const
totpError
=
ref
(
''
)
const
totpUserEmailMasked
=
ref
(
''
)
const
needsCreateAccount
=
computed
(()
=>
pendingAccountAction
.
value
===
'
create_account
'
)
const
needsBindLogin
=
computed
(()
=>
pendingAccountAction
.
value
===
'
bind_login
'
)
type
PendingOidcCompletion
=
PendingOAuthExchangeResponse
&
{
type
PendingOidcCompletion
=
PendingOAuthExchangeResponse
&
{
step
?:
string
step
?:
string
...
@@ -229,6 +327,9 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
...
@@ -229,6 +327,9 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
email
?:
string
email
?:
string
provider_fallback
?:
string
provider_fallback
?:
string
intent
?:
string
intent
?:
string
requires_2fa
?:
boolean
temp_token
?:
string
user_email_masked
?:
string
}
}
function
parseFragmentParams
():
URLSearchParams
{
function
parseFragmentParams
():
URLSearchParams
{
...
@@ -258,34 +359,6 @@ async function loadProviderName() {
...
@@ -258,34 +359,6 @@ 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
,
...
@@ -293,6 +366,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
...
@@ -293,6 +366,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
...
@@ -317,6 +401,100 @@ function hasSuggestedProfile(completion: {
...
@@ -317,6 +401,100 @@ function hasSuggestedProfile(completion: {
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
}
function
normalizedPendingState
(
value
:
string
|
null
|
undefined
):
string
{
return
value
?.
trim
().
toLowerCase
()
||
''
}
function
extractPendingAccountEmail
(
completion
:
PendingOidcCompletion
):
string
{
return
(
completion
.
pending_email
||
completion
.
existing_account_email
||
completion
.
resolved_email
||
completion
.
email
||
''
).
trim
()
}
function
resolvePendingAccountAction
(
completion
:
PendingOidcCompletion
):
'
none
'
|
'
create_account
'
|
'
bind_login
'
{
const
raw
=
normalizedPendingState
(
completion
.
step
||
completion
.
error
||
completion
.
intent
)
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
create_account
'
}
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
||
raw
===
'
existing_account_binding_required
'
||
raw
===
'
existing_account_required
'
||
raw
===
'
adopt_existing_user_by_email
'
)
{
return
'
bind_login
'
}
return
'
none
'
}
function
applyPendingAccountAction
(
completion
:
PendingOidcCompletion
)
{
const
action
=
resolvePendingAccountAction
(
completion
)
pendingAccountAction
.
value
=
action
accountActionError
.
value
=
''
needsTotpChallenge
.
value
=
false
totpTempToken
.
value
=
''
totpCode
.
value
=
''
totpError
.
value
=
''
totpUserEmailMasked
.
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
applyTotpChallenge
(
completion
:
PendingOidcCompletion
):
boolean
{
if
(
completion
.
requires_2fa
!==
true
||
!
completion
.
temp_token
)
{
return
false
}
pendingAccountAction
.
value
=
'
none
'
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
needsTotpChallenge
.
value
=
true
totpTempToken
.
value
=
completion
.
temp_token
totpCode
.
value
=
''
totpError
.
value
=
''
totpUserEmailMasked
.
value
=
completion
.
user_email_masked
||
''
isProcessing
.
value
=
false
return
true
}
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
'
)
...
@@ -335,6 +513,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
...
@@ -335,6 +513,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
await
router
.
replace
(
redirect
)
await
router
.
replace
(
redirect
)
}
}
async
function
finalizePendingAccountResponse
(
completion
:
PendingOidcCompletion
)
{
applyAdoptionSuggestionState
(
completion
)
if
(
completion
.
error
===
'
invitation_required
'
)
{
pendingAccountAction
.
value
=
'
none
'
needsInvitation
.
value
=
true
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
return
}
if
(
applyTotpChallenge
(
completion
))
{
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
...
@@ -364,12 +569,7 @@ async function handleContinueLogin() {
...
@@ -364,12 +569,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
{
...
@@ -377,33 +577,65 @@ async function handleContinueLogin() {
...
@@ -377,33 +577,65 @@ async function handleContinueLogin() {
}
}
}
}
async
function
handleContinueWithEmail
()
{
async
function
handleCreateAccount
()
{
const
email
=
pendingEmail
.
value
.
trim
()
accountActionError
.
value
=
''
if
(
!
email
)
{
const
email
=
pendingAccountEmail
.
value
.
trim
()
return
if
(
!
email
)
return
}
await
router
.
replace
({
isSubmitting
.
value
=
true
path
:
'
/register
'
,
try
{
query
:
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOidcCompletion
>
(
'
/auth/oauth/pending/create-account
'
,
{
email
,
email
,
redirect
:
redirectTo
.
value
,
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
provider
:
providerName
.
value
}
)
}
await
finalizePendingAccountResponse
(
data
)
}
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
}
async
function
handleContinueToLogin
()
{
async
function
handleBindLogin
()
{
const
email
=
pendingEmail
.
value
.
trim
()
accountActionError
.
value
=
''
const
email
=
bindLoginEmail
.
value
.
trim
()
const
password
=
bindLoginPassword
.
value
if
(
!
email
||
!
password
)
return
await
router
.
replace
({
isSubmitting
.
value
=
true
path
:
'
/login
'
,
try
{
query
:
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOidcCompletion
>
(
'
/auth/oauth/pending/bind-login
'
,
{
email
,
email
,
redirect
:
redirectTo
.
value
,
password
,
provider
:
providerName
.
value
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
}
}
)
}
)
await
finalizePendingAccountResponse
(
data
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
async
function
handleSubmitTotpChallenge
()
{
totpError
.
value
=
''
const
code
=
totpCode
.
value
.
trim
()
if
(
!
totpTempToken
.
value
||
code
.
length
!==
6
)
return
isSubmitting
.
value
=
true
try
{
const
completion
=
await
login2FA
({
temp_token
:
totpTempToken
.
value
,
totp_code
:
code
}
)
await
authStore
.
setToken
(
completion
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
totpError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
...
@@ -427,7 +659,6 @@ onMounted(async () => {
...
@@ -427,7 +659,6 @@ onMounted(async () => {
)
)
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
...
@@ -435,14 +666,12 @@ onMounted(async () => {
...
@@ -435,14 +666,12 @@ onMounted(async () => {
return
return
}
}
if
(
requiresEmailCollection
(
completion
))
{
if
(
applyTotpChallenge
(
completion
))
{
needsEmailCollection
.
value
=
true
isProcessing
.
value
=
false
return
return
}
}
if
(
requiresExist
ingAccount
Binding
(
completion
)
)
{
applyPend
ingAccount
Action
(
completion
)
needsExistingAccountBinding
.
value
=
true
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
isProcessing
.
value
=
false
isProcessing
.
value
=
false
return
return
}
}
...
@@ -455,12 +684,7 @@ onMounted(async () => {
...
@@ -455,12 +684,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/__tests__/OidcCallbackView.spec.ts
View file @
bffcc204
...
@@ -10,6 +10,8 @@ const setToken = vi.fn()
...
@@ -10,6 +10,8 @@ const setToken = vi.fn()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
exchangePendingOAuthCompletion
=
vi
.
fn
()
const
completeOIDCOAuthRegistration
=
vi
.
fn
()
const
completeOIDCOAuthRegistration
=
vi
.
fn
()
const
getPublicSettings
=
vi
.
fn
()
const
getPublicSettings
=
vi
.
fn
()
const
login2FA
=
vi
.
fn
()
const
apiClientPost
=
vi
.
fn
()
vi
.
mock
(
'
vue-router
'
,
()
=>
({
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
({
useRoute
:
()
=>
({
...
@@ -45,13 +47,20 @@ vi.mock('@/stores', () => ({
...
@@ -45,13 +47,20 @@ 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
{
...
actual
,
...
actual
,
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
completeOIDCOAuthRegistration
:
(...
args
:
any
[])
=>
completeOIDCOAuthRegistration
(...
args
),
completeOIDCOAuthRegistration
:
(...
args
:
any
[])
=>
completeOIDCOAuthRegistration
(...
args
),
getPublicSettings
:
(...
args
:
any
[])
=>
getPublicSettings
(...
args
)
getPublicSettings
:
(...
args
:
any
[])
=>
getPublicSettings
(...
args
),
login2FA
:
(...
args
:
any
[])
=>
login2FA
(...
args
)
}
}
})
})
...
@@ -64,6 +73,8 @@ describe('OidcCallbackView', () => {
...
@@ -64,6 +73,8 @@ describe('OidcCallbackView', () => {
exchangePendingOAuthCompletion
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
completeOIDCOAuthRegistration
.
mockReset
()
completeOIDCOAuthRegistration
.
mockReset
()
getPublicSettings
.
mockReset
()
getPublicSettings
.
mockReset
()
login2FA
.
mockReset
()
apiClientPost
.
mockReset
()
getPublicSettings
.
mockResolvedValue
({
getPublicSettings
.
mockResolvedValue
({
oidc_oauth_provider_name
:
'
ExampleID
'
oidc_oauth_provider_name
:
'
ExampleID
'
})
})
...
@@ -132,9 +143,7 @@ describe('OidcCallbackView', () => {
...
@@ -132,9 +143,7 @@ describe('OidcCallbackView', () => {
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
await
checkboxes
[
0
].
setValue
(
false
)
await
checkboxes
[
0
].
setValue
(
false
)
const
buttons
=
wrapper
.
findAll
(
'
button
'
)
await
wrapper
.
findAll
(
'
button
'
)[
0
].
trigger
(
'
click
'
)
expect
(
buttons
).
toHaveLength
(
1
)
await
buttons
[
0
].
trigger
(
'
click
'
)
await
flushPromises
()
await
flushPromises
()
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
2
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
2
)
...
@@ -184,12 +193,21 @@ describe('OidcCallbackView', () => {
...
@@ -184,12 +193,21 @@ describe('OidcCallbackView', () => {
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile
'
)
})
})
it
(
'
renders
pending email collec
tion
ui
and
routes to register w
it
h
the
entered email
'
,
async
()
=>
{
it
(
'
renders
adoption choices for invita
tion
flow
and
subm
it
s
the
selected values
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
error
:
'
invitation_required
'
,
redirect
:
'
/profile
'
,
redirect
:
'
/dashboard
'
,
provider_fallback
:
'
ExampleID
'
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
})
})
completeOIDCOAuthRegistration
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
refresh_token
:
'
refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
OidcCallbackView
,
{
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
global
:
{
...
@@ -204,28 +222,35 @@ describe('OidcCallbackView', () => {
...
@@ -204,28 +222,35 @@ describe('OidcCallbackView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
setToken
).
not
.
toHaveBeenCalled
(
)
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Continue with email
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
await
wrapper
.
get
(
'
input[type="
email
"]
'
).
setValue
(
'
alice@example.com
'
)
await
wrapper
.
find
(
'
input[type="
text
"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
expect
(
replace
).
toHaveBeenCalledWith
({
expect
(
completeOIDCOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
path
:
'
/register
'
,
adoptDisplayName
:
true
,
query
:
{
adoptAvatar
:
false
email
:
'
alice@example.com
'
,
redirect
:
'
/profile
'
,
provider
:
'
ExampleID
'
}
})
})
})
})
it
(
'
renders existing-account binding ui and routes to login
'
,
async
()
=>
{
it
(
'
collects email for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
existing_account_binding_required
'
,
error
:
'
email_required
'
,
redirect
:
'
/profile
'
,
redirect
:
'
/welcome
'
,
existing_account_email
:
'
alice@example.com
'
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.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
(
OidcCallbackView
,
{
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
global
:
{
...
@@ -240,34 +265,90 @@ describe('OidcCallbackView', () => {
...
@@ -240,34 +265,90 @@ describe('OidcCallbackView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
alice@example.com
'
)
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Sign in to bind
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="oidc-create-account-email"]
'
).
setValue
(
'
new@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="oidc-create-account-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
await
wrapper
.
get
(
'
button
'
).
trigger
(
'
click
'
)
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
'
)
})
expect
(
replace
).
toHaveBeenCalledWith
({
it
(
'
shows bind-login form for existing account binding and submits credentials with adoption decisions
'
,
async
()
=>
{
path
:
'
/login
'
,
exchangePendingOAuthCompletion
.
mockResolvedValue
({
query
:
{
error
:
'
adopt_existing_user_by_email
'
,
email
:
'
alice@example.com
'
,
redirect
:
'
/profile/security
'
,
redirect
:
'
/profile
'
,
email
:
'
existing@example.com
'
,
provider
:
'
ExampleID
'
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.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
(
OidcCallbackView
,
{
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="oidc-bind-login-email"]
'
).
setValue
(
'
existing@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="oidc-bind-login-password"]
'
).
setValue
(
'
secret-password
'
)
await
wrapper
.
get
(
'
[data-testid="oidc-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
'
)
})
})
it
(
'
renders adoption choices for invitation flow and submits the selected values
'
,
async
()
=>
{
it
(
'
handles bind-login 2FA challenge before redirecting
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
error
:
'
adopt_existing_user_by_email
'
,
redirect
:
'
/dashboard
'
,
redirect
:
'
/profile
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
})
})
completeOIDCOAuthRegistration
.
mockResolvedValue
({
apiClientPost
.
mockResolvedValue
({
access_token
:
'
access-token
'
,
data
:
{
refresh_token
:
'
refresh-token
'
,
requires_2fa
:
true
,
expires_in
:
3600
,
temp_token
:
'
temp-123
'
,
token_type
:
'
Bearer
'
user_email_masked
:
'
o***g@example.com
'
}
})
login2FA
.
mockResolvedValue
({
access_token
:
'
2fa-access-token
'
})
})
setToken
.
mockResolvedValue
({})
setToken
.
mockResolvedValue
({})
...
@@ -284,20 +365,22 @@ describe('OidcCallbackView', () => {
...
@@ -284,20 +365,22 @@ describe('OidcCallbackView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
OIDC Nick
'
)
await
wrapper
.
get
(
'
[data-testid="oidc-bind-login-password"]
'
).
setValue
(
'
secret-password
'
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledTimes
(
1
)
await
wrapper
.
get
(
'
[data-testid="oidc-bind-login-submit"]
'
).
trigger
(
'
click
'
)
expect
(
exchangePendingOAuthCompletion
).
toHaveBeenCalledWith
()
await
flushPromises
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
o***g@example.com
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
expect
(
login2FA
).
not
.
toHaveBeenCalled
(
)
await
checkboxes
[
1
]
.
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="oidc-bind-login-totp"]
'
)
.
setValue
(
'
123456
'
)
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
get
(
'
[data-testid="oidc-bind-login-totp-submit"]
'
).
trigger
(
'
click
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
await
flushPromises
(
)
expect
(
completeOIDCOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
expect
(
login2FA
).
toHaveBeenCalledWith
({
adoptDisplayName
:
true
,
temp_token
:
'
temp-123
'
,
adoptAvatar
:
false
totp_code
:
'
123456
'
})
})
expect
(
setToken
).
toHaveBeenCalledWith
(
'
2fa-access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile
'
)
})
})
})
})
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