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
85fc54b2
Commit
85fc54b2
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix(frontend): restore pending auth session flow
parent
4f6966d7
Changes
6
Show whitespace changes
Inline
Side-by-side
frontend/src/router/__tests__/guards.spec.ts
View file @
85fc54b2
...
...
@@ -52,6 +52,7 @@ interface MockAuthState {
isAdmin
:
boolean
isSimpleMode
:
boolean
backendModeEnabled
:
boolean
hasPendingAuthSession
:
boolean
}
/**
...
...
@@ -78,7 +79,18 @@ function simulateGuard(
}
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
))
||
callbackPaths
.
includes
(
toPath
)
||
(
authState
.
hasPendingAuthSession
&&
pendingAuthPaths
.
includes
(
toPath
))
if
(
!
isAllowed
)
{
return
'
/login
'
}
}
...
...
@@ -115,7 +127,18 @@ function simulateGuard(
return
null
}
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
))
||
callbackPaths
.
includes
(
toPath
)
||
(
authState
.
hasPendingAuthSession
&&
pendingAuthPaths
.
includes
(
toPath
))
if
(
!
isAllowed
)
{
return
'
/login
'
}
}
...
...
@@ -136,6 +159,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
it
(
'
访问需要认证的页面重定向到 /login
'
,
()
=>
{
...
...
@@ -167,6 +191,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
it
(
'
访问 /login 重定向到 /dashboard
'
,
()
=>
{
...
...
@@ -203,6 +228,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
it
(
'
访问 /login 重定向到 /admin/dashboard
'
,
()
=>
{
...
...
@@ -230,6 +256,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/subscriptions
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -241,6 +268,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/redeem
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -252,6 +280,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/groups
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
...
...
@@ -263,6 +292,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/subscriptions
'
,
...
...
@@ -278,6 +308,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -289,6 +320,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/keys
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -302,6 +334,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/home
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
...
...
@@ -313,6 +346,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -324,6 +358,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -335,6 +370,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/setup
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -346,6 +382,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/dashboard
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -357,6 +394,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
...
...
@@ -368,6 +406,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
...
...
@@ -379,6 +418,7 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -390,9 +430,46 @@ describe('路由守卫逻辑', () => {
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: callback routes are allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/auth/wechat/callback
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /register is allowed when a pending auth session exists
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
true
,
}
const
redirect
=
simulateGuard
(
'
/register
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /email-verify is blocked without a pending auth session
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/email-verify
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
})
})
})
frontend/src/router/index.ts
View file @
85fc54b2
...
...
@@ -341,6 +341,16 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
admin.users.description
'
}
},
{
path
:
'
/admin/users/auth-identity-migration-reports
'
,
name
:
'
AdminAuthIdentityMigrationReports
'
,
component
:
()
=>
import
(
'
@/views/admin/AuthIdentityMigrationReportsView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Auth Identity Migration Reports
'
}
},
{
path
:
'
/admin/groups
'
,
name
:
'
AdminGroups
'
,
...
...
@@ -538,6 +548,29 @@ const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例
let
routePrefetch
:
ReturnType
<
typeof
useRoutePrefetch
>
|
null
=
null
const
BACKEND_MODE_ALLOWED_PATHS
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
BACKEND_MODE_CALLBACK_PATHS
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
]
const
BACKEND_MODE_PENDING_AUTH_PATHS
=
[
'
/register
'
,
'
/email-verify
'
]
function
isBackendModePublicRouteAllowed
(
path
:
string
,
hasPendingAuthSession
:
boolean
):
boolean
{
if
(
BACKEND_MODE_ALLOWED_PATHS
.
some
((
allowedPath
)
=>
path
===
allowedPath
||
path
.
startsWith
(
allowedPath
)))
{
return
true
}
if
(
BACKEND_MODE_CALLBACK_PATHS
.
some
((
callbackPath
)
=>
path
===
callbackPath
))
{
return
true
}
if
(
hasPendingAuthSession
&&
BACKEND_MODE_PENDING_AUTH_PATHS
.
some
((
allowedPath
)
=>
path
===
allowedPath
))
{
return
true
}
return
false
}
router
.
beforeEach
((
to
,
_from
,
next
)
=>
{
// 开始导航加载状态
...
...
@@ -590,7 +623,7 @@ router.beforeEach((to, _from, next) => {
}
// Backend mode: block public pages for unauthenticated users (except login, key-usage, setup)
if
(
appStore
.
backendModeEnabled
&&
!
authStore
.
isAuthenticated
)
{
const
isAllowed
=
BACKEND_MODE_ALLOWED_PATHS
.
some
((
p
)
=>
to
.
path
===
p
||
to
.
path
.
startsWith
(
p
)
)
const
isAllowed
=
isBackendModePublicRouteAllowed
(
to
.
path
,
authStore
.
hasPendingAuthSession
)
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
...
...
@@ -650,7 +683,7 @@ router.beforeEach((to, _from, next) => {
next
()
return
}
const
isAllowed
=
BACKEND_MODE_ALLOWED_PATHS
.
some
((
p
)
=>
to
.
path
===
p
||
to
.
path
.
startsWith
(
p
)
)
const
isAllowed
=
isBackendModePublicRouteAllowed
(
to
.
path
,
authStore
.
hasPendingAuthSession
)
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
...
...
frontend/src/stores/__tests__/auth.spec.ts
View file @
85fc54b2
...
...
@@ -211,6 +211,78 @@ describe('useAuthStore', () => {
expect
(
store
.
isAuthenticated
).
toBe
(
true
)
})
it
(
'
恢复持久化 pending auth session
'
,
()
=>
{
localStorage
.
setItem
(
'
pending_auth_session
'
,
JSON
.
stringify
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
wechat
'
,
redirect
:
'
/profile
'
,
})
)
const
store
=
useAuthStore
()
store
.
checkAuth
()
expect
(
store
.
hasPendingAuthSession
).
toBe
(
true
)
expect
(
store
.
pendingAuthSession
).
toEqual
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
wechat
'
,
redirect
:
'
/profile
'
,
})
})
})
describe
(
'
pending auth session
'
,
()
=>
{
it
(
'
persists and clears pending auth session state
'
,
()
=>
{
const
store
=
useAuthStore
()
store
.
setPendingAuthSession
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
wechat
'
,
redirect
:
'
/profile
'
,
})
expect
(
store
.
hasPendingAuthSession
).
toBe
(
true
)
expect
(
JSON
.
parse
(
localStorage
.
getItem
(
'
pending_auth_session
'
)
||
'
null
'
)).
toEqual
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
wechat
'
,
redirect
:
'
/profile
'
,
})
store
.
clearPendingAuthSession
()
expect
(
store
.
hasPendingAuthSession
).
toBe
(
false
)
expect
(
localStorage
.
getItem
(
'
pending_auth_session
'
)).
toBeNull
()
})
it
(
'
preserves pending auth session when registration fails
'
,
async
()
=>
{
const
store
=
useAuthStore
()
store
.
setPendingAuthSession
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
oidc
'
,
redirect
:
'
/register
'
,
})
mockRegister
.
mockRejectedValue
(
new
Error
(
'
Register failed
'
))
await
expect
(
store
.
register
({
email
:
'
user@example.com
'
,
password
:
'
secret-123
'
})
).
rejects
.
toThrow
(
'
Register failed
'
)
expect
(
store
.
hasPendingAuthSession
).
toBe
(
true
)
expect
(
store
.
pendingAuthSession
).
toEqual
({
token
:
'
pending-token
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
oidc
'
,
redirect
:
'
/register
'
,
})
})
})
// --- isAdmin ---
...
...
frontend/src/stores/auth.ts
View file @
85fc54b2
...
...
@@ -12,9 +12,56 @@ const AUTH_TOKEN_KEY = 'auth_token'
const
AUTH_USER_KEY
=
'
auth_user
'
const
REFRESH_TOKEN_KEY
=
'
refresh_token
'
const
TOKEN_EXPIRES_AT_KEY
=
'
token_expires_at
'
// 存储过期时间戳而非有效期
const
PENDING_AUTH_SESSION_KEY
=
'
pending_auth_session
'
const
AUTO_REFRESH_INTERVAL
=
60
*
1000
// 60 seconds for user data refresh
const
TOKEN_REFRESH_BUFFER
=
120
*
1000
// 120 seconds before expiry to refresh token
type
PendingAuthTokenField
=
'
pending_auth_token
'
|
'
pending_oauth_token
'
interface
PendingAuthSessionSummary
{
token
:
string
token_field
:
PendingAuthTokenField
provider
:
string
redirect
?:
string
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}
function
getPersistedPendingAuthSession
():
PendingAuthSessionSummary
|
null
{
const
raw
=
localStorage
.
getItem
(
PENDING_AUTH_SESSION_KEY
)
if
(
!
raw
)
{
return
null
}
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
PendingAuthSessionSummary
if
(
!
parsed
?.
token
||
!
parsed
?.
provider
)
{
return
null
}
return
{
token
:
parsed
.
token
,
token_field
:
parsed
.
token_field
||
'
pending_auth_token
'
,
provider
:
parsed
.
provider
,
redirect
:
parsed
.
redirect
,
adoption_required
:
parsed
.
adoption_required
,
suggested_display_name
:
parsed
.
suggested_display_name
,
suggested_avatar_url
:
parsed
.
suggested_avatar_url
}
}
catch
{
localStorage
.
removeItem
(
PENDING_AUTH_SESSION_KEY
)
return
null
}
}
function
persistPendingAuthSession
(
session
:
PendingAuthSessionSummary
):
void
{
localStorage
.
setItem
(
PENDING_AUTH_SESSION_KEY
,
JSON
.
stringify
(
session
))
}
function
clearPendingAuthSessionStorage
():
void
{
localStorage
.
removeItem
(
PENDING_AUTH_SESSION_KEY
)
}
export
const
useAuthStore
=
defineStore
(
'
auth
'
,
()
=>
{
// ==================== State ====================
...
...
@@ -23,6 +70,7 @@ export const useAuthStore = defineStore('auth', () => {
const
refreshTokenValue
=
ref
<
string
|
null
>
(
null
)
const
tokenExpiresAt
=
ref
<
number
|
null
>
(
null
)
// 过期时间戳(毫秒)
const
runMode
=
ref
<
'
standard
'
|
'
simple
'
>
(
'
standard
'
)
const
pendingAuthSession
=
ref
<
PendingAuthSessionSummary
|
null
>
(
null
)
let
refreshIntervalId
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
let
tokenRefreshTimeoutId
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
...
...
@@ -37,6 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
})
const
isSimpleMode
=
computed
(()
=>
runMode
.
value
===
'
simple
'
)
const
hasPendingAuthSession
=
computed
(()
=>
pendingAuthSession
.
value
!==
null
)
// ==================== Actions ====================
...
...
@@ -50,6 +99,7 @@ export const useAuthStore = defineStore('auth', () => {
const
savedUser
=
localStorage
.
getItem
(
AUTH_USER_KEY
)
const
savedRefreshToken
=
localStorage
.
getItem
(
REFRESH_TOKEN_KEY
)
const
savedExpiresAt
=
localStorage
.
getItem
(
TOKEN_EXPIRES_AT_KEY
)
pendingAuthSession
.
value
=
getPersistedPendingAuthSession
()
if
(
savedToken
&&
savedUser
)
{
try
{
...
...
@@ -73,7 +123,7 @@ export const useAuthStore = defineStore('auth', () => {
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to parse saved user data:
'
,
error
)
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
true
}
)
}
}
}
...
...
@@ -196,7 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
return
response
}
catch
(
error
)
{
// Clear any partial state on error
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
pendingAuthSession
.
value
!==
null
}
)
throw
error
}
}
...
...
@@ -214,7 +264,7 @@ export const useAuthStore = defineStore('auth', () => {
setAuthFromResponse
(
response
)
return
user
.
value
!
}
catch
(
error
)
{
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
pendingAuthSession
.
value
!==
null
}
)
throw
error
}
}
...
...
@@ -243,6 +293,7 @@ export const useAuthStore = defineStore('auth', () => {
// Persist to localStorage
localStorage
.
setItem
(
AUTH_TOKEN_KEY
,
response
.
access_token
)
localStorage
.
setItem
(
AUTH_USER_KEY
,
JSON
.
stringify
(
userData
))
clearPendingAuthSession
()
// Start auto-refresh interval for user data
startAutoRefresh
()
...
...
@@ -270,7 +321,7 @@ export const useAuthStore = defineStore('auth', () => {
return
user
.
value
!
}
catch
(
error
)
{
// Clear any partial state on error
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
pendingAuthSession
.
value
!==
null
}
)
throw
error
}
}
...
...
@@ -312,13 +363,29 @@ export const useAuthStore = defineStore('auth', () => {
scheduleTokenRefreshAt
(
tokenExpiresAt
.
value
)
}
clearPendingAuthSession
()
return
userData
}
catch
(
error
)
{
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
pendingAuthSession
.
value
!==
null
}
)
throw
error
}
}
function
setPendingAuthSession
(
session
:
PendingAuthSessionSummary
|
null
):
void
{
pendingAuthSession
.
value
=
session
if
(
session
)
{
persistPendingAuthSession
(
session
)
return
}
clearPendingAuthSessionStorage
()
}
function
clearPendingAuthSession
():
void
{
setPendingAuthSession
(
null
)
}
/**
* User logout
* Clears all authentication state and persisted data
...
...
@@ -357,7 +424,7 @@ export const useAuthStore = defineStore('auth', () => {
}
catch
(
error
)
{
// If refresh fails with 401, clear auth state
if
((
error
as
{
status
?:
number
}).
status
===
401
)
{
clearAuth
()
clearAuth
(
{
preservePendingAuthSession
:
pendingAuthSession
.
value
!==
null
}
)
}
throw
error
}
...
...
@@ -367,7 +434,7 @@ export const useAuthStore = defineStore('auth', () => {
* Clear all authentication state
* Internal helper function
*/
function
clearAuth
():
void
{
function
clearAuth
(
options
?:
{
preservePendingAuthSession
?:
boolean
}
):
void
{
// Stop auto-refresh
stopAutoRefresh
()
// Stop token refresh
...
...
@@ -381,6 +448,14 @@ export const useAuthStore = defineStore('auth', () => {
localStorage
.
removeItem
(
AUTH_USER_KEY
)
localStorage
.
removeItem
(
REFRESH_TOKEN_KEY
)
localStorage
.
removeItem
(
TOKEN_EXPIRES_AT_KEY
)
if
(
options
?.
preservePendingAuthSession
)
{
pendingAuthSession
.
value
=
getPersistedPendingAuthSession
()
return
}
pendingAuthSession
.
value
=
null
clearPendingAuthSessionStorage
()
}
// ==================== Return Store API ====================
...
...
@@ -390,11 +465,13 @@ export const useAuthStore = defineStore('auth', () => {
user
,
token
,
runMode
:
readonly
(
runMode
),
pendingAuthSession
:
readonly
(
pendingAuthSession
),
// Computed
isAuthenticated
,
isAdmin
,
isSimpleMode
,
hasPendingAuthSession
,
// Actions
login
,
...
...
@@ -403,6 +480,8 @@ export const useAuthStore = defineStore('auth', () => {
setToken
,
logout
,
checkAuth
,
refreshUser
refreshUser
,
setPendingAuthSession
,
clearPendingAuthSession
}
})
frontend/src/views/auth/EmailVerifyView.vue
View file @
85fc54b2
...
...
@@ -176,7 +176,8 @@ import { AuthLayout } from '@/components/layout'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
,
sendVerifyCode
}
from
'
@/api/auth
'
import
{
persistOAuthTokenContext
,
getPublicSettings
,
sendVerifyCode
}
from
'
@/api/auth
'
import
{
apiClient
}
from
'
@/api/client
'
import
{
buildAuthErrorMessage
}
from
'
@/utils/authError
'
import
{
isRegistrationEmailSuffixAllowed
,
...
...
@@ -202,11 +203,33 @@ const countdown = ref<number>(0)
let
countdownTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
// Registration data from sessionStorage
type
PendingAuthTokenField
=
'
pending_auth_token
'
|
'
pending_oauth_token
'
type
PendingAuthSessionSummary
=
{
token
:
string
token_field
:
PendingAuthTokenField
provider
:
string
redirect
?:
string
}
type
PendingOAuthCreateAccountResponse
=
{
access_token
:
string
refresh_token
?:
string
expires_in
?:
number
token_type
?:
string
}
const
email
=
ref
<
string
>
(
''
)
const
password
=
ref
<
string
>
(
''
)
const
initialTurnstileToken
=
ref
<
string
>
(
''
)
const
promoCode
=
ref
<
string
>
(
''
)
const
invitationCode
=
ref
<
string
>
(
''
)
const
pendingAuthToken
=
ref
<
string
>
(
''
)
const
pendingAuthTokenField
=
ref
<
PendingAuthTokenField
>
(
'
pending_auth_token
'
)
const
pendingProvider
=
ref
<
string
>
(
''
)
const
pendingRedirect
=
ref
<
string
>
(
''
)
const
pendingAdoptionDecision
=
ref
<
{
adoptDisplayName
?:
boolean
adoptAvatar
?:
boolean
}
|
null
>
(
null
)
const
hasRegisterData
=
ref
<
boolean
>
(
false
)
// Public settings
...
...
@@ -228,6 +251,8 @@ const errors = ref({
// ==================== Lifecycle ====================
onMounted
(
async
()
=>
{
const
activePendingSession
=
authStore
.
pendingAuthSession
as
PendingAuthSessionSummary
|
null
// Load registration data from sessionStorage
const
registerDataStr
=
sessionStorage
.
getItem
(
'
register_data
'
)
if
(
registerDataStr
)
{
...
...
@@ -238,10 +263,25 @@ onMounted(async () => {
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
invitationCode
.
value
=
registerData
.
invitation_code
||
''
pendingAuthToken
.
value
=
registerData
.
pending_auth_token
||
activePendingSession
?.
token
||
''
pendingAuthTokenField
.
value
=
registerData
.
pending_auth_token_field
||
activePendingSession
?.
token_field
||
'
pending_auth_token
'
pendingProvider
.
value
=
registerData
.
pending_provider
||
activePendingSession
?.
provider
||
''
pendingRedirect
.
value
=
registerData
.
pending_redirect
||
activePendingSession
?.
redirect
||
''
pendingAdoptionDecision
.
value
=
registerData
.
pending_adoption_decision
?
{
adoptDisplayName
:
registerData
.
pending_adoption_decision
.
adopt_display_name
===
true
,
adoptAvatar
:
registerData
.
pending_adoption_decision
.
adopt_avatar
===
true
}
:
null
hasRegisterData
.
value
=
!!
(
email
.
value
&&
password
.
value
)
}
catch
{
hasRegisterData
.
value
=
false
}
}
else
if
(
activePendingSession
)
{
pendingAuthToken
.
value
=
activePendingSession
.
token
pendingAuthTokenField
.
value
=
activePendingSession
.
token_field
pendingProvider
.
value
=
activePendingSession
.
provider
pendingRedirect
.
value
=
activePendingSession
.
redirect
||
''
}
// Load public settings
...
...
@@ -323,9 +363,10 @@ async function sendCode(): Promise<void> {
const
response
=
await
sendVerifyCode
({
email
:
email
.
value
,
[
pendingAuthTokenField
.
value
]:
pendingAuthToken
.
value
||
undefined
,
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
turnstile_token
:
resendTurnstileToken
.
value
||
initialTurnstileToken
.
value
||
undefined
}
)
}
as
Parameters
<
typeof
sendVerifyCode
>
[
0
]
)
codeSent
.
value
=
true
startCountdown
(
response
.
countdown
)
...
...
@@ -395,6 +436,22 @@ async function handleVerify(): Promise<void> {
return
}
if
(
pendingProvider
.
value
)
{
const
{
data
}
=
await
apiClient
.
post
<
PendingOAuthCreateAccountResponse
>
(
'
/auth/oauth/pending/create-account
'
,
{
email
:
email
.
value
,
password
:
password
.
value
,
verify_code
:
verifyCode
.
value
.
trim
(),
invitation_code
:
invitationCode
.
value
||
undefined
,
adopt_display_name
:
pendingAdoptionDecision
.
value
?.
adoptDisplayName
,
adopt_avatar
:
pendingAdoptionDecision
.
value
?.
adoptAvatar
}
)
persistOAuthTokenContext
(
data
)
await
authStore
.
setToken
(
data
.
access_token
)
authStore
.
clearPendingAuthSession
?.()
}
else
{
// Register with verification code
await
authStore
.
register
({
email
:
email
.
value
,
...
...
@@ -404,6 +461,7 @@ async function handleVerify(): Promise<void> {
promo_code
:
promoCode
.
value
||
undefined
,
invitation_code
:
invitationCode
.
value
||
undefined
}
)
}
// Clear session data
sessionStorage
.
removeItem
(
'
register_data
'
)
...
...
@@ -412,7 +470,7 @@ async function handleVerify(): Promise<void> {
appStore
.
showSuccess
(
t
(
'
auth.accountCreatedSuccess
'
,
{
siteName
:
siteName
.
value
}
))
// Redirect to dashboard
await
router
.
push
(
'
/dashboard
'
)
await
router
.
push
(
pendingRedirect
.
value
||
'
/dashboard
'
)
}
catch
(
error
:
unknown
)
{
errorMessage
.
value
=
buildAuthErrorMessage
(
error
,
{
fallback
:
t
(
'
auth.verifyFailed
'
)
...
...
frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts
0 → 100644
View file @
85fc54b2
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
EmailVerifyView
from
'
@/views/auth/EmailVerifyView.vue
'
const
{
pushMock
,
showSuccessMock
,
showErrorMock
,
registerMock
,
setTokenMock
,
clearPendingAuthSessionMock
,
getPublicSettingsMock
,
sendVerifyCodeMock
,
persistOAuthTokenContextMock
,
apiClientPostMock
,
authStoreState
,
}
=
vi
.
hoisted
(()
=>
({
pushMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showErrorMock
:
vi
.
fn
(),
registerMock
:
vi
.
fn
(),
setTokenMock
:
vi
.
fn
(),
clearPendingAuthSessionMock
:
vi
.
fn
(),
getPublicSettingsMock
:
vi
.
fn
(),
sendVerifyCodeMock
:
vi
.
fn
(),
persistOAuthTokenContextMock
:
vi
.
fn
(),
apiClientPostMock
:
vi
.
fn
(),
authStoreState
:
{
pendingAuthSession
:
null
as
null
|
{
token
:
string
token_field
:
'
pending_auth_token
'
|
'
pending_oauth_token
'
provider
:
string
redirect
?:
string
adoption_required
?:
boolean
suggested_display_name
?:
string
suggested_avatar_url
?:
string
}
},
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRouter
:
()
=>
({
push
:
pushMock
,
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
createI18n
:
()
=>
({
global
:
{
t
:
(
key
:
string
)
=>
key
,
},
}),
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
|
number
>
)
=>
{
if
(
key
===
'
auth.accountCreatedSuccess
'
)
{
return
`Account created for
${
params
?.
siteName
??
'
Sub2API
'
}
`
}
return
key
},
locale
:
{
value
:
'
en
'
},
}),
}))
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAuthStore
:
()
=>
({
pendingAuthSession
:
authStoreState
.
pendingAuthSession
,
register
:
(...
args
:
any
[])
=>
registerMock
(...
args
),
setToken
:
(...
args
:
any
[])
=>
setTokenMock
(...
args
),
clearPendingAuthSession
:
(...
args
:
any
[])
=>
clearPendingAuthSessionMock
(...
args
),
}),
useAppStore
:
()
=>
({
showSuccess
:
(...
args
:
any
[])
=>
showSuccessMock
(...
args
),
showError
:
(...
args
:
any
[])
=>
showErrorMock
(...
args
),
}),
}))
vi
.
mock
(
'
@/api/auth
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
@/api/auth
'
)
>
(
'
@/api/auth
'
)
return
{
...
actual
,
getPublicSettings
:
(...
args
:
any
[])
=>
getPublicSettingsMock
(...
args
),
sendVerifyCode
:
(...
args
:
any
[])
=>
sendVerifyCodeMock
(...
args
),
persistOAuthTokenContext
:
(...
args
:
any
[])
=>
persistOAuthTokenContextMock
(...
args
),
}
})
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
:
(...
args
:
any
[])
=>
apiClientPostMock
(...
args
),
},
}))
describe
(
'
EmailVerifyView
'
,
()
=>
{
beforeEach
(()
=>
{
pushMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
registerMock
.
mockReset
()
setTokenMock
.
mockReset
()
clearPendingAuthSessionMock
.
mockReset
()
getPublicSettingsMock
.
mockReset
()
sendVerifyCodeMock
.
mockReset
()
persistOAuthTokenContextMock
.
mockReset
()
apiClientPostMock
.
mockReset
()
authStoreState
.
pendingAuthSession
=
null
sessionStorage
.
clear
()
getPublicSettingsMock
.
mockResolvedValue
({
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
site_name
:
'
Sub2API
'
,
registration_email_suffix_whitelist
:
[],
})
sendVerifyCodeMock
.
mockResolvedValue
({
countdown
:
60
})
setTokenMock
.
mockResolvedValue
({})
})
it
(
'
submits pending auth account creation when session storage has no pending metadata but auth store does
'
,
async
()
=>
{
authStoreState
.
pendingAuthSession
=
{
token
:
'
pending-token-1
'
,
token_field
:
'
pending_auth_token
'
,
provider
:
'
wechat
'
,
redirect
:
'
/profile
'
,
}
sessionStorage
.
setItem
(
'
register_data
'
,
JSON
.
stringify
({
email
:
'
fresh@example.com
'
,
password
:
'
secret-123
'
,
})
)
apiClientPostMock
.
mockResolvedValue
({
data
:
{
access_token
:
'
oauth-access-token
'
,
refresh_token
:
'
oauth-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
,
},
})
const
wrapper
=
mount
(
EmailVerifyView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /><slot name="footer" /></div>
'
},
Icon
:
true
,
TurnstileWidget
:
true
,
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
get
(
'
#code
'
).
setValue
(
'
123456
'
)
await
wrapper
.
get
(
'
form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
apiClientPostMock
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/create-account
'
,
{
email
:
'
fresh@example.com
'
,
password
:
'
secret-123
'
,
verify_code
:
'
123456
'
,
})
expect
(
persistOAuthTokenContextMock
).
toHaveBeenCalledWith
({
access_token
:
'
oauth-access-token
'
,
refresh_token
:
'
oauth-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
oauth-access-token
'
)
expect
(
clearPendingAuthSessionMock
).
toHaveBeenCalled
()
expect
(
pushMock
).
toHaveBeenCalledWith
(
'
/profile
'
)
expect
(
registerMock
).
not
.
toHaveBeenCalled
()
})
it
(
'
keeps the normal email registration flow unchanged
'
,
async
()
=>
{
sessionStorage
.
setItem
(
'
register_data
'
,
JSON
.
stringify
({
email
:
'
normal@example.com
'
,
password
:
'
secret-456
'
,
promo_code
:
'
PROMO
'
,
invitation_code
:
'
INVITE
'
,
})
)
registerMock
.
mockResolvedValue
({})
const
wrapper
=
mount
(
EmailVerifyView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /><slot name="footer" /></div>
'
},
Icon
:
true
,
TurnstileWidget
:
true
,
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
get
(
'
#code
'
).
setValue
(
'
654321
'
)
await
wrapper
.
get
(
'
form
'
).
trigger
(
'
submit.prevent
'
)
await
flushPromises
()
expect
(
registerMock
).
toHaveBeenCalledWith
({
email
:
'
normal@example.com
'
,
password
:
'
secret-456
'
,
verify_code
:
'
654321
'
,
turnstile_token
:
undefined
,
promo_code
:
'
PROMO
'
,
invitation_code
:
'
INVITE
'
,
})
expect
(
apiClientPostMock
).
not
.
toHaveBeenCalled
()
expect
(
pushMock
).
toHaveBeenCalledWith
(
'
/dashboard
'
)
})
})
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