feat(auth): wire permission gating through menu, routes and actions

The store's can()/useCan() helpers existed but were unused. Now:
- src/auth/permissions.ts PERM catalog mirrors backend keys
- every nav item carries a permission; sidebar filter reuses can()
- requirePermission() beforeLoad guard on all child routes (direct URLs
  redirect home when the session lacks the key)
- useCan() hides/disables mutation buttons: moderation actions,
  retranslate, robot-author edit/pause, category + locale CRUD, and the
  reserved-SID assign path (admin:sid:override hint)
- 403 responses toast 无权限执行此操作; can() unit tests added

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-12 06:42:31 +08:00
co-authored by Claude Fable 5
parent 6ed8de4de6
commit 46056ae4b0
26 changed files with 1480 additions and 133 deletions
+37 -22
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
@@ -15,10 +17,12 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
export const Route = createFileRoute('/_authed/categories/')({
beforeLoad: () => requirePermission(PERM.CATEGORY_READ),
component: CategoriesPage,
})
function CategoriesPage() {
const canCreate = useCan(PERM.CATEGORY_CREATE)
const queryClient = useQueryClient()
const tree = useQuery({
queryKey: ['categories', 'tree'],
@@ -57,10 +61,12 @@ function CategoriesPage() {
title="分类树"
description="平台服务分类体系;含未启用分类"
actions={
<Button onClick={() => openCreate(null)}>
<Plus className="size-4" />
</Button>
canCreate && (
<Button onClick={() => openCreate(null)}>
<Plus className="size-4" />
</Button>
)
}
/>
@@ -113,6 +119,9 @@ function TreeNode({
}) {
const [open, setOpen] = useState(depth === 0)
const children = node.children ?? []
const canCreate = useCan(PERM.CATEGORY_CREATE)
const canUpdate = useCan(PERM.CATEGORY_UPDATE)
const canDelete = useCan(PERM.CATEGORY_DELETE)
return (
<div className={cn(depth > 0 && 'border-l')} style={{ marginLeft: depth > 0 ? 20 : 0 }}>
@@ -132,24 +141,30 @@ function TreeNode({
{!node.is_active && <Badge variant="secondary"></Badge>}
{node.approval_status === 'pending' && <Badge variant="outline"></Badge>}
<span className="ml-auto hidden items-center gap-1 group-hover:flex">
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
<Plus className="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
<Pencil className="size-4" />
</Button>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除分类「${node.name ?? node.slug}」?`}
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
confirmLabel="删除"
destructive
onConfirm={() => onDelete(node)}
/>
{canCreate && (
<Button variant="ghost" size="icon-sm" onClick={() => onCreateChild(node)} aria-label="新建子分类">
<Plus className="size-4" />
</Button>
)}
{canUpdate && (
<Button variant="ghost" size="icon-sm" onClick={() => onEdit(node)} aria-label="编辑">
<Pencil className="size-4" />
</Button>
)}
{canDelete && (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除分类「${node.name ?? node.slug}」?`}
description="删除后不可恢复;有子分类或被引用时后端会拒绝。"
confirmLabel="删除"
destructive
onConfirm={() => onDelete(node)}
/>
)}
</span>
</div>
{open &&
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
@@ -34,6 +35,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/categories/pending')({
beforeLoad: () => requirePermission(PERM.CATEGORY_APPROVE),
component: PendingCategoriesPage,
})
+7 -1
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
@@ -22,10 +24,12 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/comment-reports')({
beforeLoad: () => requirePermission(PERM.COMMENT_READ),
component: ReportedCommentsPage,
})
function ReportedCommentsPage() {
const canModerate = useCan(PERM.COMMENT_MODERATE)
const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedComment | null>(null)
const list = useListPage(
@@ -92,7 +96,9 @@ function ReportedCommentsPage() {
header: '操作',
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
{!canModerate ? (
<span className="text-xs text-muted-foreground"></span>
) : row.original.is_blocked ? (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { NotificationDevice } from '@/api/types'
@@ -16,6 +17,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/devices')({
beforeLoad: () => requirePermission(PERM.NOTIFICATION_READ),
component: DevicesPage,
})
+19 -12
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
@@ -21,6 +23,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/dimzou-translations')({
beforeLoad: () => requirePermission(PERM.DIMZOU_READ),
component: DimzouTranslationsPage,
})
@@ -31,6 +34,7 @@ const STATUS_LABELS: Record<DimzouTranslation['status'], string> = {
}
function DimzouTranslationsPage() {
const canRetranslate = useCan(PERM.DIMZOU_RETRANSLATE)
const [status, setStatus] = useState<string>('all')
// language filter applies on Enter/blur to avoid a request per keystroke
const [languageInput, setLanguageInput] = useState('')
@@ -111,18 +115,21 @@ function DimzouTranslationsPage() {
},
{
header: '操作',
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
</Button>
}
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}`}
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
onConfirm={() => requeue(row.original)}
/>
),
cell: ({ row }) =>
canRetranslate ? (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" disabled={!row.original.draft_version_id}>
</Button>
}
title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}`}
description="将最新草稿版本中待译和失败的块重新入队机器翻译。"
onConfirm={() => requeue(row.original)}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
]
+7 -1
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
@@ -23,10 +25,12 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/event-reports')({
beforeLoad: () => requirePermission(PERM.FILEX_READ),
component: ReportedEventsPage,
})
function ReportedEventsPage() {
const canModerate = useCan(PERM.FILEX_MODERATE)
const [status, setStatus] = useState<string>('pending')
const [detail, setDetail] = useState<ReportedEvent | null>(null)
const list = useListPage(
@@ -97,7 +101,9 @@ function ReportedEventsPage() {
header: '操作',
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
{row.original.is_blocked ? (
{!canModerate ? (
<span className="text-xs text-muted-foreground"></span>
) : row.original.is_blocked ? (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline">
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
@@ -25,6 +26,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/demands')({
beforeLoad: () => requirePermission(PERM.EXC_READ),
component: ExcDemandsPage,
})
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ExcDispatch } from '@/api/types'
@@ -17,6 +18,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/dispatches')({
beforeLoad: () => requirePermission(PERM.EXC_READ),
component: ExcDispatchesPage,
})
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
@@ -25,6 +26,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/orders')({
beforeLoad: () => requirePermission(PERM.EXC_READ),
component: ExcOrdersPage,
})
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
@@ -24,6 +25,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/providers')({
beforeLoad: () => requirePermission(PERM.EXC_READ),
component: ExcProvidersPage,
})
+29 -17
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
@@ -23,10 +25,14 @@ import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
export const Route = createFileRoute('/_authed/locales')({
beforeLoad: () => requirePermission(PERM.LOCALE_READ),
component: LocalesPage,
})
function LocalesPage() {
const canCreate = useCan(PERM.LOCALE_CREATE)
const canUpdate = useCan(PERM.LOCALE_UPDATE)
const canDelete = useCan(PERM.LOCALE_DELETE)
const queryClient = useQueryClient()
const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales })
const [dialogOpen, setDialogOpen] = useState(false)
@@ -65,25 +71,29 @@ function LocalesPage() {
cell: ({ row }) => (
<Switch
checked={row.original.is_active}
disabled={!canUpdate}
onCheckedChange={(v) => toggle(row.original, v)}
/>
),
},
{
header: '操作',
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除语言 ${row.original.code}`}
confirmLabel="删除"
destructive
onConfirm={() => remove(row.original)}
/>
),
cell: ({ row }) =>
canDelete ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="删除">
<Trash2 className="size-4 text-destructive" />
</Button>
}
title={`删除语言 ${row.original.code}`}
confirmLabel="删除"
destructive
onConfirm={() => remove(row.original)}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
]
@@ -93,10 +103,12 @@ function LocalesPage() {
title="语言设置"
description="平台支持的界面/内容语言"
actions={
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
</Button>
canCreate && (
<Button onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
</Button>
)
}
/>
<DataTable
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import { issueRobotSessionToken } from '@/api/modules/auth'
import { handleApiError } from '@/lib/errors'
@@ -10,6 +11,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export const Route = createFileRoute('/_authed/robots')({
beforeLoad: () => requirePermission(PERM.ROBOT_TOKEN_ISSUE),
component: RobotsPage,
})
+13 -1
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
@@ -26,6 +28,7 @@ import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
export const Route = createFileRoute('/_authed/sid')({
beforeLoad: () => requirePermission(PERM.SID_MANAGE),
component: SidPage,
})
@@ -190,6 +193,7 @@ function LuckyTab() {
}
function AssignTab() {
const canOverride = useCan(PERM.SID_OVERRIDE)
const [sid, setSid] = useState('')
const [checkResult, setCheckResult] = useState<SidCheckResult | null>(null)
const [userUid, setUserUid] = useState('')
@@ -254,6 +258,11 @@ function AssignTab() {
</Badge>
)}
{checkResult.reserved && !canOverride && (
<p className="mt-1 text-xs text-muted-foreground">
admin:sid:override
</p>
)}
</div>
)}
<div className="grid gap-1.5">
@@ -266,7 +275,10 @@ function AssignTab() {
placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX"
/>
</div>
<Button onClick={assign} disabled={!sid || !userUid || busy}>
<Button
onClick={assign}
disabled={!sid || !userUid || busy || (checkResult?.reserved === true && !canOverride)}
>
SID
</Button>
</CardContent>
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { KeyRound } from 'lucide-react'
@@ -14,6 +15,7 @@ import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/agent-tokens')({
beforeLoad: () => requirePermission(PERM.STUDIO_AGENT_TOKEN_ISSUE),
component: AgentTokensPage,
})
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioArticle } from '@/api/types'
@@ -19,6 +20,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/articles')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: ArticlesPage,
})
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioCategoryBrief } from '@/api/types'
@@ -10,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/category-briefs')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: CategoryBriefsPage,
})
+19 -11
View File
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useCan } from '@/auth/store'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { X } from 'lucide-react'
@@ -28,12 +30,14 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/robot-authors')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: RobotAuthorsPage,
})
const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const
function RobotAuthorsPage() {
const canManage = useCan(PERM.STUDIO_MANAGE)
const queryClient = useQueryClient()
const [status, setStatus] = useState('all')
const [categoryId, setCategoryId] = useState<string | null>(null)
@@ -114,17 +118,21 @@ function RobotAuthorsPage() {
<Button size="sm" variant="ghost" onClick={() => setDetail(row.original)}>
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
</Button>
{row.original.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
</Button>
{canManage && (
<>
<Button size="sm" variant="outline" onClick={() => setEditing(row.original)}>
</Button>
{row.original.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'ready')}>
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => setAuthorStatus(row.original, 'paused')}>
</Button>
)}
</>
)}
</div>
),
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioSeries } from '@/api/types'
@@ -11,6 +12,7 @@ import { StatusPill } from '@/components/status-pill'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/_authed/studio/series')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: SeriesPage,
})
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioStylePreset } from '@/api/types'
@@ -18,6 +19,7 @@ import {
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/studio/style-presets')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: StylePresetsPage,
})
+2
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { PERM, requirePermission } from '@/auth/permissions'
import type { ColumnDef } from '@tanstack/react-table'
import type { StudioSubtopic } from '@/api/types'
import { fetchSubtopics } from '@/api/modules/studio'
@@ -8,6 +9,7 @@ import { PageHeader } from '@/components/page-header'
import { StatusPill } from '@/components/status-pill'
export const Route = createFileRoute('/_authed/studio/subtopics')({
beforeLoad: () => requirePermission(PERM.STUDIO_READ),
component: SubtopicsPage,
})