mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-12 19:04:45 +08:00
feat: add Backend Mode toggle to disable user self-service
Add a system-wide "Backend Mode" that disables user self-registration and self-service while keeping admin panel and API gateway fully functional. When enabled, only admin can log in; all user-facing routes return 403. Backend: - New setting key `backend_mode_enabled` with atomic cached reads (60s TTL) - BackendModeUserGuard middleware blocks non-admin authenticated routes - BackendModeAuthGuard middleware blocks registration/password-reset auth routes - Login/Login2FA/RefreshToken handlers reject non-admin when enabled - TokenPairWithUser struct for role-aware token refresh - 20 unit tests (middleware + service layer) Frontend: - Router guards redirect unauthenticated users to /login - Admin toggle in Settings page - Login page hides register link and footer in backend mode - 9 unit tests for router guard logic - i18n support (en/zh) 27 files changed, 833 insertions(+), 17 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user