fix(auth): handle zero-permission users gracefully and prevent redirect loops

This commit is contained in:
2026-08-11 07:36:01 +08:00
parent 04c12ad340
commit 2e02eee66c
6 changed files with 84 additions and 7 deletions

View File

@ -1,5 +1,6 @@
import { redirect } from '@tanstack/react-router'
import { can, useAuthStore } from '@/auth/store'
import { getFirstAccessibleRoute } from '@/components/layout/nav'
/**
* Admin permission keys — mirrors the backend catalog
@ -50,6 +51,7 @@ export type Permission = (typeof PERM)[keyof typeof PERM]
export function requirePermission(permission: Permission): void {
const { user } = useAuthStore.getState()
if (user && !can(permission)) {
throw redirect({ to: '/' })
const target = getFirstAccessibleRoute()
throw redirect({ to: target && target !== '/' ? target : '/' })
}
}

View File

@ -51,5 +51,8 @@ export function useCan(permission: string): boolean {
}
export function isAuthenticated(): boolean {
return Boolean(useAuthStore.getState().accessToken)
const { accessToken, user, roles, permissions } = useAuthStore.getState()
if (!accessToken) return false
if (user && roles.length === 0 && permissions.length === 0) return false
return true
}

View File

@ -1,4 +1,5 @@
import type { LucideIcon } from 'lucide-react'
import { can } from '@/auth/store'
import {
BookOpenText,
Bot,
@ -100,3 +101,15 @@ export const NAV_GROUPS: NavGroup[] = [
],
},
]
export function getFirstAccessibleRoute(): string | null {
for (const group of NAV_GROUPS) {
for (const item of group.items) {
if (!item.permission || can(item.permission)) {
return item.to
}
}
}
return null
}

View File

@ -2,6 +2,7 @@ import { Link, Outlet, createFileRoute, redirect, useNavigate } from '@tanstack/
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { LogOut, Moon, Sun } from 'lucide-react'
import { toast } from 'sonner'
import { fetchMe } from '@/api/modules/auth'
import { can, isAuthenticated, useAuthStore } from '@/auth/store'
import { NAV_GROUPS } from '@/components/layout/nav'
@ -41,11 +42,19 @@ function AuthedLayout() {
useEffect(() => {
if (me.data) {
const roles = me.data.meta?.roles ?? []
const permissions = me.data.meta?.permissions ?? []
if (roles.length === 0 && permissions.length === 0) {
useAuthStore.getState().clear()
toast.error('账号未分配管理后台权限,已退出登录')
void navigate({ to: '/login' })
return
}
useAuthStore
.getState()
.setSession(me.data.data, me.data.meta?.roles ?? [], me.data.meta?.permissions ?? [])
.setSession(me.data.data, roles, permissions)
}
}, [me.data])
}, [me.data, navigate])
useEffect(() => {
if (me.isError && !isAuthenticated()) {

View File

@ -1,7 +1,51 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { ShieldAlert, LogOut } from 'lucide-react'
import { getFirstAccessibleRoute } from '@/components/layout/nav'
import { useAuthStore } from '@/auth/store'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export const Route = createFileRoute('/_authed/')({
beforeLoad: () => {
throw redirect({ to: '/categories' })
const target = getFirstAccessibleRoute()
if (target && target !== '/') {
throw redirect({ to: target })
}
},
component: DashboardIndexPage,
})
function DashboardIndexPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const logout = () => {
useAuthStore.getState().clear()
void navigate({ to: '/login' })
}
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md text-center shadow-xs">
<CardHeader>
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400">
<ShieldAlert className="size-6" />
</div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription>
{user?.display_name ?? user?.username ?? user?.uid}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
访
</p>
<Button variant="outline" onClick={logout} className="gap-2">
<LogOut className="size-4" />
退
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@ -45,7 +45,13 @@ function LoginPage() {
const me = await fetchMe()
const roles = me.meta?.roles ?? []
useAuthStore.getState().setSession(me.data, roles, me.meta?.permissions ?? [])
const permissions = me.meta?.permissions ?? []
if (roles.length === 0 && permissions.length === 0) {
useAuthStore.getState().clear()
toast.error('该账号未分配管理后台权限,无法登录')
return
}
useAuthStore.getState().setSession(me.data, roles, permissions)
await navigate({ to: '/' })
} catch (error) {