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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 06:42:31 +08:00
parent 6ed8de4de6
commit 46056ae4b0
26 changed files with 1480 additions and 133 deletions

File diff suppressed because it is too large Load Diff

53
src/auth/permissions.ts Normal file
View File

@ -0,0 +1,53 @@
import { redirect } from '@tanstack/react-router'
import { can, useAuthStore } from '@/auth/store'
/**
* Admin permission keys — mirrors the backend catalog
* (Modules/{Module}/app/Permissions/{Module}Permissions.php).
*/
export const PERM = {
CATEGORY_READ: 'admin:category:read',
CATEGORY_CREATE: 'admin:category:create',
CATEGORY_UPDATE: 'admin:category:update',
CATEGORY_DELETE: 'admin:category:delete',
CATEGORY_APPROVE: 'admin:category:approve',
COMMENT_READ: 'admin:comment:read',
COMMENT_MODERATE: 'admin:comment:moderate',
FILEX_READ: 'admin:filex:read',
FILEX_MODERATE: 'admin:filex:moderate',
DIMZOU_READ: 'admin:dimzou:read',
DIMZOU_RETRANSLATE: 'admin:dimzou:retranslate',
EXC_READ: 'admin:exc:read',
STUDIO_READ: 'admin:studio:read',
STUDIO_MANAGE: 'admin:studio:manage',
STUDIO_AGENT_TOKEN_ISSUE: 'admin:studio:agent-token:issue',
LOCALE_READ: 'admin:locale:read',
LOCALE_CREATE: 'admin:locale:create',
LOCALE_UPDATE: 'admin:locale:update',
LOCALE_DELETE: 'admin:locale:delete',
SID_MANAGE: 'admin:sid:manage',
SID_OVERRIDE: 'admin:sid:override',
NOTIFICATION_READ: 'admin:notification:read',
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
// role management — only super-admin holds these
ROLE_READ: 'auth:role:read',
ROLE_CREATE: 'auth:role:create',
ROLE_UPDATE: 'auth:role:update',
ROLE_DELETE: 'auth:role:delete',
ROLE_ASSIGN: 'auth:role:assign',
PERMISSION_READ: 'auth:permission:read',
} as const
export type Permission = (typeof PERM)[keyof typeof PERM]
/**
* Route `beforeLoad` guard. Redirects home when the session is loaded and
* lacks the permission; lets the request through while the session is still
* empty (the API's 403 is the backstop, and /auth/me refreshes the store).
*/
export function requirePermission(permission: Permission): void {
const { user } = useAuthStore.getState()
if (user && !can(permission)) {
throw redirect({ to: '/' })
}
}

37
src/auth/store.test.ts Normal file
View File

@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { can, hasRole, useAuthStore } from '@/auth/store'
const user = {
kind: 'user' as const,
uid: '01TEST',
username: 'tester',
email: 't@example.com',
phone: null,
display_name: '测试',
avatar: null,
status: true,
}
describe('can()', () => {
beforeEach(() => {
useAuthStore.getState().clear()
})
it('denies when the permission is missing', () => {
useAuthStore.getState().setSession(user, ['admin'], ['admin:category:read'])
expect(can('admin:category:read')).toBe(true)
expect(can('admin:sid:manage')).toBe(false)
})
it('super-admin passes every permission check', () => {
useAuthStore.getState().setSession(user, ['super-admin'], [])
expect(can('admin:sid:manage')).toBe(true)
expect(can('anything:at:all')).toBe(true)
})
it('hasRole matches exact role names', () => {
useAuthStore.getState().setSession(user, ['admin'], [])
expect(hasRole('admin')).toBe(true)
expect(hasRole('super-admin')).toBe(false)
})
})

View File

@ -1,7 +1,9 @@
import type { LucideIcon } from 'lucide-react'
import {
BookOpenText,
Bot,
ClipboardList,
FileText,
Flag,
FolderTree,
Hash,
@ -16,9 +18,11 @@ import {
Receipt,
ScrollText,
Send,
Shield,
Smartphone,
Sparkles,
UserCheck,
Users,
} from 'lucide-react'
export interface NavItem {
@ -38,19 +42,19 @@ export const NAV_GROUPS: NavGroup[] = [
{
label: '分类管理',
items: [
{ label: '分类树', to: '/categories', icon: FolderTree },
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks },
{ label: '分类树', to: '/categories', icon: FolderTree, permission: 'admin:category:read' },
{ label: '待审核分类', to: '/categories/pending', icon: ListChecks, permission: 'admin:category:approve' },
],
},
{
label: '内容工作室',
items: [
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot },
{ label: '文章系列', to: '/studio/series', icon: LayoutList },
{ label: '文章', to: '/studio/articles', icon: Newspaper },
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles },
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText },
{ label: '风格预设', to: '/studio/style-presets', icon: Palette },
{ label: '机器人作者', to: '/studio/robot-authors', icon: Bot, permission: 'admin:studio:read' },
{ label: '文章系列', to: '/studio/series', icon: LayoutList, permission: 'admin:studio:read' },
{ label: '文章', to: '/studio/articles', icon: Newspaper, permission: 'admin:studio:read' },
{ label: '子话题', to: '/studio/subtopics', icon: Sparkles, permission: 'admin:studio:read' },
{ label: '分类简报', to: '/studio/category-briefs', icon: ScrollText, permission: 'admin:studio:read' },
{ label: '风格预设', to: '/studio/style-presets', icon: Palette, permission: 'admin:studio:read' },
{
label: 'Agent 接入',
to: '/studio/agent-tokens',
@ -62,27 +66,36 @@ export const NAV_GROUPS: NavGroup[] = [
{
label: '专长交易',
items: [
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList },
{ label: '派单监控', to: '/exc/dispatches', icon: Send },
{ label: '订单监控', to: '/exc/orders', icon: Receipt },
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck },
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList, permission: 'admin:exc:read' },
{ label: '派单监控', to: '/exc/dispatches', icon: Send, permission: 'admin:exc:read' },
{ label: '订单监控', to: '/exc/orders', icon: Receipt, permission: 'admin:exc:read' },
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck, permission: 'admin:exc:read' },
],
},
{
label: 'Dimzou 内容',
items: [
{ label: '文档监控', to: '/dimzou/documents', icon: FileText, permission: 'admin:dimzou:read' },
{ label: '发布监控', to: '/dimzou/publications', icon: BookOpenText, permission: 'admin:dimzou:read' },
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages, permission: 'admin:dimzou:read' },
],
},
{
label: '内容审核',
items: [
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning },
{ label: '被举报事件', to: '/event-reports', icon: Flag },
{ label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages },
{ label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning, permission: 'admin:comment:read' },
{ label: '被举报事件', to: '/event-reports', icon: Flag, permission: 'admin:filex:read' },
],
},
{
label: '用户与系统',
items: [
{ label: 'SID 管理', to: '/sid', icon: Hash },
{ label: '语言设置', to: '/locales', icon: Languages },
{ label: '推送设备', to: '/devices', icon: Smartphone },
{ label: '机器人会话', to: '/robots', icon: Inbox },
{ label: '角色管理', to: '/roles', icon: Shield, permission: 'auth:role:read' },
{ label: '用户角色', to: '/admin-users', icon: Users, permission: 'auth:role:assign' },
{ label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' },
{ label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale:read' },
{ label: '推送设备', to: '/devices', icon: Smartphone, permission: 'admin:notification:read' },
{ label: '机器人会话', to: '/robots', icon: Inbox, permission: 'auth:robot-token:issue' },
],
},
]

View File

@ -17,6 +17,10 @@ export function handleApiError<T extends FieldValues>(
}
return
}
if (error.status === 403) {
toast.error('无权限执行此操作')
return
}
toast.error(error.message || `请求失败(${error.code}`)
return
}

View File

@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { LogOut, Moon, Sun } from 'lucide-react'
import { fetchMe } from '@/api/modules/auth'
import { isAuthenticated, useAuthStore } from '@/auth/store'
import { can, isAuthenticated, useAuthStore } from '@/auth/store'
import { NAV_GROUPS } from '@/components/layout/nav'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
@ -29,7 +29,8 @@ function AuthedLayout() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const roles = useAuthStore((s) => s.roles)
const permissions = useAuthStore((s) => s.permissions)
// subscribe so the sidebar re-filters when /auth/me refreshes permissions
useAuthStore((s) => s.permissions)
// Re-validate the session on mount / focus; keeps roles+permissions fresh.
const me = useQuery({
@ -52,9 +53,8 @@ function AuthedLayout() {
}
}, [me.isError, navigate])
const isSuper = roles.includes('super-admin')
const canSee = (permission?: string) =>
!permission || isSuper || permissions.includes(permission)
// roles/permissions subscriptions above keep this reactive.
const canSee = (permission?: string) => !permission || can(permission)
const logout = () => {
useAuthStore.getState().clear()

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 &&

View File

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

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">

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,
})

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>
),
},
]

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">

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,
})

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,
})

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,
})

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,
})

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

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,
})

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>

View File

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

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,
})

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 { 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,
})

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>
),

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,
})

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 { 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,
})

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,
})