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
7826e988
Commit
7826e988
authored
Apr 20, 2026
by
IanShaw027
Browse files
feat: support linuxdo pending bind 2fa callback
parent
fb6204ea
Changes
3
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/auth.ts
View file @
7826e988
...
@@ -196,6 +196,9 @@ export interface OAuthTokenResponse {
...
@@ -196,6 +196,9 @@ export interface OAuthTokenResponse {
export
interface
PendingOAuthBindLoginResponse
extends
Partial
<
OAuthTokenResponse
>
{
export
interface
PendingOAuthBindLoginResponse
extends
Partial
<
OAuthTokenResponse
>
{
redirect
?:
string
redirect
?:
string
error
?:
string
error
?:
string
requires_2fa
?:
boolean
temp_token
?:
string
user_email_masked
?:
string
adoption_required
?:
boolean
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_display_name
?:
string
suggested_avatar_url
?:
string
suggested_avatar_url
?:
string
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
7826e988
...
@@ -12,7 +12,13 @@
...
@@ -12,7 +12,13 @@
<transition
name=
"fade"
>
<transition
name=
"fade"
>
<div
<div
v-if=
"needsInvitation || needsAdoptionConfirmation || needsCreateAccount || needsBindLogin"
v-if=
"
needsInvitation ||
needsAdoptionConfirmation ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
"
class=
"space-y-4"
class=
"space-y-4"
>
>
<div
<div
...
@@ -186,6 +192,40 @@
...
@@ -186,6 +192,40 @@
</p>
</p>
</transition>
</transition>
</
template
>
</
template
>
<
template
v-else-if=
"needsTotpChallenge"
>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
Enter the 6-digit verification code for
<span
class=
"font-medium"
>
{{
totpUserEmailMasked
||
'
your account
'
}}
</span>
to finish binding this LinuxDo sign-in.
</p>
<div
class=
"space-y-3"
>
<input
v-model=
"totpCode"
data-testid=
"linuxdo-bind-login-totp"
type=
"text"
inputmode=
"numeric"
maxlength=
"6"
class=
"input w-full"
placeholder=
"123456"
:disabled=
"isSubmitting"
@
keyup.enter=
"handleSubmitTotpChallenge"
/>
<button
data-testid=
"linuxdo-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
>
</div>
</div>
</transition>
</transition>
...
@@ -226,6 +266,7 @@ import {
...
@@ -226,6 +266,7 @@ import {
exchangePendingOAuthCompletion
,
exchangePendingOAuthCompletion
,
getOAuthCompletionKind
,
getOAuthCompletionKind
,
isOAuthLoginCompletion
,
isOAuthLoginCompletion
,
login2FA
,
persistOAuthTokenContext
,
persistOAuthTokenContext
,
type
OAuthAdoptionDecision
,
type
OAuthAdoptionDecision
,
type
PendingOAuthExchangeResponse
type
PendingOAuthExchangeResponse
...
@@ -260,6 +301,11 @@ const bindLoginPassword = ref('')
...
@@ -260,6 +301,11 @@ const bindLoginPassword = ref('')
const
accountActionError
=
ref
(
''
)
const
accountActionError
=
ref
(
''
)
const
canReturnToCreateAccount
=
ref
(
false
)
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
needsCreateAccount
=
computed
(()
=>
pendingAccountAction
.
value
===
'
create_account
'
)
const
needsBindLogin
=
computed
(()
=>
pendingAccountAction
.
value
===
'
bind_login
'
)
const
needsBindLogin
=
computed
(()
=>
pendingAccountAction
.
value
===
'
bind_login
'
)
...
@@ -346,6 +392,11 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
...
@@ -346,6 +392,11 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
const
action
=
resolvePendingAccountAction
(
completion
)
const
action
=
resolvePendingAccountAction
(
completion
)
pendingAccountAction
.
value
=
action
pendingAccountAction
.
value
=
action
accountActionError
.
value
=
''
accountActionError
.
value
=
''
needsTotpChallenge
.
value
=
false
totpTempToken
.
value
=
''
totpCode
.
value
=
''
totpError
.
value
=
''
totpUserEmailMasked
.
value
=
''
const
email
=
extractPendingAccountEmail
(
completion
)
const
email
=
extractPendingAccountEmail
(
completion
)
if
(
action
===
'
create_account
'
)
{
if
(
action
===
'
create_account
'
)
{
...
@@ -364,6 +415,23 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
...
@@ -364,6 +415,23 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
canReturnToCreateAccount
.
value
=
false
canReturnToCreateAccount
.
value
=
false
}
}
function
applyTotpChallenge
(
completion
:
LinuxDoPendingActionResponse
):
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
()
{
function
switchToBindLoginMode
()
{
pendingAccountAction
.
value
=
'
bind_login
'
pendingAccountAction
.
value
=
'
bind_login
'
bindLoginEmail
.
value
=
bindLoginEmail
.
value
.
trim
()
||
pendingAccountEmail
.
value
.
trim
()
bindLoginEmail
.
value
=
bindLoginEmail
.
value
.
trim
()
||
pendingAccountEmail
.
value
.
trim
()
...
@@ -412,6 +480,10 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
...
@@ -412,6 +480,10 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
return
return
}
}
if
(
applyTotpChallenge
(
completion
))
{
return
}
applyPendingAccountAction
(
completion
)
applyPendingAccountAction
(
completion
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
needsInvitation
.
value
=
false
needsInvitation
.
value
=
false
...
@@ -501,6 +573,27 @@ async function handleBindLogin() {
...
@@ -501,6 +573,27 @@ async function handleBindLogin() {
}
}
}
}
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
()
=>
{
const
params
=
parseFragmentParams
()
const
params
=
parseFragmentParams
()
const
error
=
params
.
get
(
'
error
'
)
const
error
=
params
.
get
(
'
error
'
)
...
@@ -527,6 +620,10 @@ onMounted(async () => {
...
@@ -527,6 +620,10 @@ onMounted(async () => {
return
return
}
}
if
(
applyTotpChallenge
(
completion
as
LinuxDoPendingActionResponse
))
{
return
}
applyPendingAccountAction
(
completion
as
LinuxDoPendingActionResponse
)
applyPendingAccountAction
(
completion
as
LinuxDoPendingActionResponse
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
isProcessing
.
value
=
false
isProcessing
.
value
=
false
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
7826e988
...
@@ -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
login2FA
=
vi
.
fn
()
const
apiClientPost
=
vi
.
fn
()
const
apiClientPost
=
vi
.
fn
()
vi
.
mock
(
'
vue-router
'
,
()
=>
({
vi
.
mock
(
'
vue-router
'
,
()
=>
({
...
@@ -51,7 +52,8 @@ vi.mock('@/api/auth', async () => {
...
@@ -51,7 +52,8 @@ vi.mock('@/api/auth', async () => {
return
{
return
{
...
actual
,
...
actual
,
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletion
(...
args
),
completeLinuxDoOAuthRegistration
:
(...
args
:
any
[])
=>
completeLinuxDoOAuthRegistration
(...
args
)
completeLinuxDoOAuthRegistration
:
(...
args
:
any
[])
=>
completeLinuxDoOAuthRegistration
(...
args
),
login2FA
:
(...
args
:
any
[])
=>
login2FA
(...
args
)
}
}
})
})
...
@@ -63,6 +65,7 @@ describe('LinuxDoCallbackView', () => {
...
@@ -63,6 +65,7 @@ describe('LinuxDoCallbackView', () => {
setToken
.
mockReset
()
setToken
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
exchangePendingOAuthCompletion
.
mockReset
()
completeLinuxDoOAuthRegistration
.
mockReset
()
completeLinuxDoOAuthRegistration
.
mockReset
()
login2FA
.
mockReset
()
apiClientPost
.
mockReset
()
apiClientPost
.
mockReset
()
})
})
...
@@ -344,4 +347,57 @@ describe('LinuxDoCallbackView', () => {
...
@@ -344,4 +347,57 @@ describe('LinuxDoCallbackView', () => {
expect
(
setToken
).
toHaveBeenCalledWith
(
'
bind-access-token
'
)
expect
(
setToken
).
toHaveBeenCalledWith
(
'
bind-access-token
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile/security
'
)
expect
(
replace
).
toHaveBeenCalledWith
(
'
/profile/security
'
)
})
})
it
(
'
handles bind-login 2FA challenge before redirecting
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
bind_login_required
'
,
redirect
:
'
/profile
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
apiClientPost
.
mockResolvedValue
({
data
:
{
requires_2fa
:
true
,
temp_token
:
'
temp-123
'
,
user_email_masked
:
'
o***g@example.com
'
}
})
login2FA
.
mockResolvedValue
({
access_token
:
'
2fa-access-token
'
})
setToken
.
mockResolvedValue
({})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
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
(
wrapper
.
text
()).
toContain
(
'
o***g@example.com
'
)
expect
(
login2FA
).
not
.
toHaveBeenCalled
()
await
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-totp"]
'
).
setValue
(
'
123456
'
)
await
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-totp-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
login2FA
).
toHaveBeenCalledWith
({
temp_token
:
'
temp-123
'
,
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