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:
John Doe
2026-03-12 02:42:57 +03:00
parent 9d81467937
commit 6826149a8f
27 changed files with 833 additions and 17 deletions

View File

@@ -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()
})
})
})

View File

@@ -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()
})