mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Merge pull request #944 from miraserver/feat/backend-mode
feat: add Backend Mode toggle to disable user self-service
This commit is contained in:
@@ -40,6 +40,7 @@ export interface SystemSettings {
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
sora_client_enabled: boolean
|
||||
backend_mode_enabled: boolean
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
@@ -106,6 +107,7 @@ export interface UpdateSettingsRequest {
|
||||
purchase_subscription_enabled?: boolean
|
||||
purchase_subscription_url?: string
|
||||
sora_client_enabled?: boolean
|
||||
backend_mode_enabled?: boolean
|
||||
custom_menu_items?: CustomMenuItem[]
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Regular User View -->
|
||||
<template v-else>
|
||||
<template v-else-if="!appStore.backendModeEnabled">
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="item in userNavItems"
|
||||
|
||||
@@ -3922,6 +3922,9 @@ export default {
|
||||
site: {
|
||||
title: 'Site Settings',
|
||||
description: 'Customize site branding',
|
||||
backendMode: 'Backend Mode',
|
||||
backendModeDescription:
|
||||
'Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.',
|
||||
siteName: 'Site Name',
|
||||
siteNamePlaceholder: 'Sub2API',
|
||||
siteNameHint: 'Displayed in emails and page titles',
|
||||
|
||||
@@ -4094,6 +4094,9 @@ export default {
|
||||
site: {
|
||||
title: '站点设置',
|
||||
description: '自定义站点品牌',
|
||||
backendMode: 'Backend 模式',
|
||||
backendModeDescription:
|
||||
'禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。',
|
||||
siteName: '站点名称',
|
||||
siteNameHint: '显示在邮件和页面标题中',
|
||||
siteNamePlaceholder: 'Sub2API',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user