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
a1dc0089
Unverified
Commit
a1dc0089
authored
Mar 14, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 14, 2026
Browse files
Merge pull request #944 from miraserver/feat/backend-mode
feat: add Backend Mode toggle to disable user self-service
parents
dfbcc363
6826149a
Changes
27
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/zh.ts
View file @
a1dc0089
...
...
@@ -4094,6 +4094,9 @@ export default {
site
:
{
title
:
'
站点设置
'
,
description
:
'
自定义站点品牌
'
,
backendMode
:
'
Backend 模式
'
,
backendModeDescription
:
'
禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。
'
,
siteName
:
'
站点名称
'
,
siteNameHint
:
'
显示在邮件和页面标题中
'
,
siteNamePlaceholder
:
'
Sub2API
'
,
...
...
frontend/src/router/__tests__/guards.spec.ts
View file @
a1dc0089
...
...
@@ -51,6 +51,7 @@ interface MockAuthState {
isAuthenticated
:
boolean
isAdmin
:
boolean
isSimpleMode
:
boolean
backendModeEnabled
:
boolean
}
/**
...
...
@@ -70,8 +71,17 @@ function simulateGuard(
authState
.
isAuthenticated
&&
(
toPath
===
'
/login
'
||
toPath
===
'
/register
'
)
)
{
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAdmin
)
{
return
null
}
return
authState
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
}
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
return
'
/login
'
}
}
return
null
// 允许通过
}
...
...
@@ -99,6 +109,17 @@ function simulateGuard(
}
}
// Backend mode: admin gets full access, non-admin blocked
if
(
authState
.
backendModeEnabled
)
{
if
(
authState
.
isAuthenticated
&&
authState
.
isAdmin
)
{
return
null
}
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
if
(
!
allowed
.
some
((
path
)
=>
toPath
===
path
||
toPath
.
startsWith
(
path
)))
{
return
'
/login
'
}
}
return
null
// 允许通过
}
...
...
@@ -114,6 +135,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问需要认证的页面重定向到 /login
'
,
()
=>
{
...
...
@@ -144,6 +166,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问 /login 重定向到 /dashboard
'
,
()
=>
{
...
...
@@ -179,6 +202,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
false
,
}
it
(
'
访问 /login 重定向到 /admin/dashboard
'
,
()
=>
{
...
...
@@ -205,6 +229,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/subscriptions
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -215,6 +240,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/redeem
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/dashboard
'
)
...
...
@@ -225,6 +251,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/groups
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
...
...
@@ -235,6 +262,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/admin/subscriptions
'
,
...
...
@@ -249,6 +277,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
...
...
@@ -259,9 +288,111 @@ describe('路由守卫逻辑', () => {
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
true
,
backendModeEnabled
:
false
,
}
const
redirect
=
simulateGuard
(
'
/keys
'
,
{},
authState
)
expect
(
redirect
).
toBeNull
()
})
})
describe
(
'
Backend Mode
'
,
()
=>
{
it
(
'
unauthenticated: /home redirects to /login
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/home
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
})
it
(
'
unauthenticated: /login is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /key-usage is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /setup is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/setup
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
admin: /admin/dashboard is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/admin/dashboard
'
,
{
requiresAdmin
:
true
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
admin: /login redirects to /admin/dashboard
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
true
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBe
(
'
/admin/dashboard
'
)
})
it
(
'
non-admin authenticated: /dashboard redirects to /login
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/dashboard
'
,
{},
authState
)
expect
(
redirect
).
toBe
(
'
/login
'
)
})
it
(
'
non-admin authenticated: /login is allowed (no redirect loop)
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/login
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
non-admin authenticated: /key-usage is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
true
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
}
const
redirect
=
simulateGuard
(
'
/key-usage
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
})
})
frontend/src/router/index.ts
View file @
a1dc0089
...
...
@@ -423,6 +423,7 @@ let authInitialized = false
const
navigationLoading
=
useNavigationLoadingState
()
// 延迟初始化预加载,传入 router 实例
let
routePrefetch
:
ReturnType
<
typeof
useRoutePrefetch
>
|
null
=
null
const
BACKEND_MODE_ALLOWED_PATHS
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
router
.
beforeEach
((
to
,
_from
,
next
)
=>
{
// 开始导航加载状态
...
...
@@ -463,10 +464,24 @@ router.beforeEach((to, _from, next) => {
if
(
!
requiresAuth
)
{
// If already authenticated and trying to access login/register, redirect to appropriate dashboard
if
(
authStore
.
isAuthenticated
&&
(
to
.
path
===
'
/login
'
||
to
.
path
===
'
/register
'
))
{
// In backend mode, non-admin users should NOT be redirected away from login
// (they are blocked from all protected routes, so redirecting would cause a loop)
if
(
appStore
.
backendModeEnabled
&&
!
authStore
.
isAdmin
)
{
next
()
return
}
// Admin users go to admin dashboard, regular users go to user dashboard
next
(
authStore
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
)
return
}
// 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
))
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
}
}
next
()
return
}
...
...
@@ -505,6 +520,19 @@ router.beforeEach((to, _from, next) => {
}
}
// Backend mode: admin gets full access, non-admin blocked
if
(
appStore
.
backendModeEnabled
)
{
if
(
authStore
.
isAuthenticated
&&
authStore
.
isAdmin
)
{
next
()
return
}
const
isAllowed
=
BACKEND_MODE_ALLOWED_PATHS
.
some
((
p
)
=>
to
.
path
===
p
||
to
.
path
.
startsWith
(
p
))
if
(
!
isAllowed
)
{
next
(
'
/login
'
)
return
}
}
// All checks passed, allow navigation
next
()
})
...
...
frontend/src/stores/app.ts
View file @
a1dc0089
...
...
@@ -47,6 +47,7 @@ export const useAppStore = defineStore('app', () => {
// ==================== Computed ====================
const
hasActiveToasts
=
computed
(()
=>
toasts
.
value
.
length
>
0
)
const
backendModeEnabled
=
computed
(()
=>
cachedPublicSettings
.
value
?.
backend_mode_enabled
??
false
)
const
loadingCount
=
ref
<
number
>
(
0
)
...
...
@@ -331,6 +332,7 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items
:
[],
linuxdo_oauth_enabled
:
false
,
sora_client_enabled
:
false
,
backend_mode_enabled
:
false
,
version
:
siteVersion
.
value
}
}
...
...
@@ -404,6 +406,7 @@ export const useAppStore = defineStore('app', () => {
// Computed
hasActiveToasts
,
backendModeEnabled
,
// Actions
toggleSidebar
,
...
...
frontend/src/types/index.ts
View file @
a1dc0089
...
...
@@ -106,6 +106,7 @@ export interface PublicSettings {
custom_menu_items
:
CustomMenuItem
[]
linuxdo_oauth_enabled
:
boolean
sora_client_enabled
:
boolean
backend_mode_enabled
:
boolean
version
:
string
}
...
...
frontend/src/views/admin/SettingsView.vue
View file @
a1dc0089
...
...
@@ -1070,6 +1070,21 @@
<
/p
>
<
/div
>
<
div
class
=
"
space-y-6 p-6
"
>
<!--
Backend
Mode
-->
<
div
class
=
"
flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20
"
>
<
div
>
<
h3
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.settings.site.backendMode
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.settings.site.backendModeDescription
'
)
}}
<
/p
>
<
/div
>
<
Toggle
v
-
model
=
"
form.backend_mode_enabled
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-1 gap-6 md:grid-cols-2
"
>
<
div
>
<
label
class
=
"
mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300
"
>
...
...
@@ -1785,6 +1800,7 @@ const form = reactive<SettingsForm>({
contact_info
:
''
,
doc_url
:
''
,
home_content
:
''
,
backend_mode_enabled
:
false
,
hide_ccs_import_button
:
false
,
purchase_subscription_enabled
:
false
,
purchase_subscription_url
:
''
,
...
...
@@ -1962,6 +1978,7 @@ async function loadSettings() {
try
{
const
settings
=
await
adminAPI
.
settings
.
getSettings
()
Object
.
assign
(
form
,
settings
)
form
.
backend_mode_enabled
=
settings
.
backend_mode_enabled
form
.
default_subscriptions
=
Array
.
isArray
(
settings
.
default_subscriptions
)
?
settings
.
default_subscriptions
.
filter
((
item
)
=>
item
.
group_id
>
0
&&
item
.
validity_days
>
0
)
...
...
@@ -2060,6 +2077,7 @@ async function saveSettings() {
contact_info
:
form
.
contact_info
,
doc_url
:
form
.
doc_url
,
home_content
:
form
.
home_content
,
backend_mode_enabled
:
form
.
backend_mode_enabled
,
hide_ccs_import_button
:
form
.
hide_ccs_import_button
,
purchase_subscription_enabled
:
form
.
purchase_subscription_enabled
,
purchase_subscription_url
:
form
.
purchase_subscription_url
,
...
...
frontend/src/views/auth/LoginView.vue
View file @
a1dc0089
...
...
@@ -12,7 +12,7 @@
</div>
<!-- LinuxDo Connect OAuth 登录 -->
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled"
:disabled=
"isLoading"
/>
<LinuxDoOAuthSection
v-if=
"linuxdoOAuthEnabled
&& !backendModeEnabled
"
:disabled=
"isLoading"
/>
<!-- Login Form -->
<form
@
submit.prevent=
"handleLogin"
class=
"space-y-5"
>
...
...
@@ -78,7 +78,7 @@
</p>
<span
v-else
></span>
<router-link
v-if=
"passwordResetEnabled"
v-if=
"passwordResetEnabled
&& !backendModeEnabled
"
to=
"/forgot-password"
class=
"text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
...
...
@@ -151,7 +151,7 @@
</div>
<!-- Footer -->
<template
#footer
>
<template
v-if=
"!backendModeEnabled"
#footer
>
<p
class=
"text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.dontHaveAccount
'
)
}}
<router-link
...
...
@@ -206,6 +206,7 @@ const showPassword = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
backendModeEnabled
=
ref
<
boolean
>
(
false
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
...
...
@@ -245,6 +246,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
backendModeEnabled
.
value
=
settings
.
backend_mode_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
...
...
Prev
1
2
Next
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