feat(rbac): add role-based access control module

- Add RBAC module with data-access, schema, types, and components for role and permission management
This commit is contained in:
SpecialX
2026-07-03 10:23:56 +08:00
parent cee7bbfd7a
commit ac1de9e433
18 changed files with 2416 additions and 0 deletions

398
src/modules/rbac/actions.ts Normal file
View File

@@ -0,0 +1,398 @@
"use server"
import { revalidatePath } from "next/cache"
import { after } from "next/server"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions, type Permission } from "@/shared/types/permissions"
import { logAudit } from "@/shared/lib/audit-logger"
import { logDataChange } from "@/shared/lib/change-logger"
import {
AssignUserRolesSchema,
CreateRoleSchema,
SetRolePermissionsSchema,
UpdateRoleSchema,
} from "./schema"
import {
ADMIN_ROLE_NAME,
createRole,
deleteRole,
getRoleById,
getRolePermissions,
setRoleEnabled,
setRolePermissions,
updateRole,
} from "./data-access"
import { assignRolesToUser, getUserRoleNames } from "./data-access-assignments"
import { trackPermissionChange } from "./lib/track"
import type { RoleDetail, RoleRecord } from "./types"
/**
* Create a new custom role.
*/
export async function createRoleAction(
prevState: ActionState<RoleRecord> | undefined,
formData: FormData
): Promise<ActionState<RoleRecord>> {
try {
await requirePermission(Permissions.ROLE_CREATE)
const parsed = CreateRoleSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") || null,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const role = await createRole(parsed.data)
after(() =>
logAudit({
action: "role.create",
module: "rbac",
targetId: role.id,
targetType: "role",
detail: { name: role.name, description: role.description },
})
)
after(() =>
logDataChange({
tableName: "roles",
recordId: role.id,
action: "create",
newValue: { name: role.name, description: role.description },
})
)
revalidatePath("/admin/roles")
return { success: true, data: role, message: "Role created" }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to create role" }
}
}
/**
* Update a role's name and/or description.
*/
export async function updateRoleAction(
roleId: string,
prevState: ActionState<RoleRecord> | undefined,
formData: FormData
): Promise<ActionState<RoleRecord>> {
try {
await requirePermission(Permissions.ROLE_UPDATE)
const parsed = UpdateRoleSchema.safeParse({
name: formData.get("name") || undefined,
description: formData.get("description") || null,
})
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
}
}
const before = await getRoleById(roleId)
if (!before) return { success: false, message: "Role not found" }
const role = await updateRole(roleId, parsed.data)
after(() =>
logAudit({
action: "role.update",
module: "rbac",
targetId: role.id,
targetType: "role",
detail: { before: { name: before.name, description: before.description }, after: { name: role.name, description: role.description } },
})
)
after(() =>
logDataChange({
tableName: "roles",
recordId: role.id,
action: "update",
oldValue: { name: before.name, description: before.description },
newValue: { name: role.name, description: role.description },
})
)
revalidatePath("/admin/roles")
revalidatePath(`/admin/roles/${roleId}`)
return { success: true, data: role, message: "Role updated" }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to update role" }
}
}
/**
* Delete a custom role. System roles cannot be deleted.
*/
export async function deleteRoleAction(
roleId: string
): Promise<ActionState<void>> {
try {
await requirePermission(Permissions.ROLE_DELETE)
const before = await getRoleById(roleId)
if (!before) return { success: false, message: "Role not found" }
await deleteRole(roleId)
after(() =>
logAudit({
action: "role.delete",
module: "rbac",
targetId: roleId,
targetType: "role",
detail: { name: before.name },
})
)
after(() =>
logDataChange({
tableName: "roles",
recordId: roleId,
action: "delete",
oldValue: { name: before.name, description: before.description },
})
)
revalidatePath("/admin/roles")
return { success: true, message: "Role deleted" }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to delete role" }
}
}
/**
* Enable or disable a role. The admin role cannot be disabled.
*/
export async function toggleRoleEnabledAction(
roleId: string,
enabled: boolean
): Promise<ActionState<RoleRecord>> {
try {
await requirePermission(Permissions.ROLE_UPDATE)
const role = await setRoleEnabled(roleId, enabled)
after(() =>
logAudit({
action: enabled ? "role.enable" : "role.disable",
module: "rbac",
targetId: roleId,
targetType: "role",
detail: { name: role.name },
})
)
revalidatePath("/admin/roles")
revalidatePath(`/admin/roles/${roleId}`)
return { success: true, data: role, message: enabled ? "Role enabled" : "Role disabled" }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to toggle role" }
}
}
/**
* Replace the full permission list for a role.
* The admin role's permissions cannot be changed.
*/
export async function setRolePermissionsAction(
prevState: ActionState<Permission[]> | undefined,
formData: FormData
): Promise<ActionState<Permission[]>> {
try {
await requirePermission(Permissions.ROLE_UPDATE)
const parsed = SetRolePermissionsSchema.safeParse({
roleId: formData.get("roleId"),
permissions: JSON.parse(String(formData.get("permissions") ?? "[]")),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid input",
errors: parsed.error.flatten().fieldErrors,
}
}
const before = await getRolePermissions(parsed.data.roleId)
// Schema validates all strings are valid permission values, so the cast is safe.
const permissions = parsed.data.permissions as Permission[]
await setRolePermissions(parsed.data.roleId, permissions)
trackPermissionChange({
action: "set_role_permissions",
roleId: parsed.data.roleId,
before,
after: permissions,
})
after(() =>
logAudit({
action: "role.set_permissions",
module: "rbac",
targetId: parsed.data.roleId,
targetType: "role",
detail: {
before,
after: permissions,
},
})
)
after(() =>
logDataChange({
tableName: "role_permissions",
recordId: parsed.data.roleId,
action: "update",
oldValue: { permissions: before },
newValue: { permissions },
})
)
revalidatePath("/admin/roles")
revalidatePath(`/admin/roles/${parsed.data.roleId}`)
return { success: true, data: permissions, message: "Permissions updated" }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to update permissions" }
}
}
/**
* Assign roles to a user (full replacement).
*/
export async function assignUserRolesAction(
prevState: ActionState<string[]> | undefined,
formData: FormData
): Promise<ActionState<string[]>> {
try {
await requirePermission(Permissions.ROLE_ASSIGN)
const parsed = AssignUserRolesSchema.safeParse({
userId: formData.get("userId"),
roleNames: JSON.parse(String(formData.get("roleNames") ?? "[]")),
})
if (!parsed.success) {
return {
success: false,
message: "Invalid input",
errors: parsed.error.flatten().fieldErrors,
}
}
const before = await getUserRoleNames(parsed.data.userId)
await assignRolesToUser(parsed.data.userId, parsed.data.roleNames)
trackPermissionChange({
action: "assign_user_roles",
userId: parsed.data.userId,
before,
after: parsed.data.roleNames,
})
after(() =>
logAudit({
action: "user.assign_roles",
module: "rbac",
targetId: parsed.data.userId,
targetType: "user",
detail: { before, after: parsed.data.roleNames },
})
)
after(() =>
logDataChange({
tableName: "users_to_roles",
recordId: parsed.data.userId,
action: "update",
oldValue: { roles: before },
newValue: { roles: parsed.data.roleNames },
})
)
revalidatePath("/admin/users")
return {
success: true,
data: parsed.data.roleNames,
message: "Roles assigned. The user may need to sign in again for changes to take effect.",
}
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to assign roles" }
}
}
/**
* Get a role detail (for the edit page). Throws PermissionDeniedError if the
* caller lacks ROLE_READ.
*/
export async function getRoleDetailAction(
roleId: string
): Promise<ActionState<RoleDetail>> {
try {
await requirePermission(Permissions.ROLE_READ)
const role = await getRoleById(roleId)
if (!role) return { success: false, message: "Role not found" }
return { success: true, data: role }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
if (error instanceof Error) {
return { success: false, message: error.message }
}
return { success: false, message: "Failed to load role" }
}
}
/**
* Check whether a role is the locked admin role.
* Exported as a server action so client components can call it without
* importing the data-access layer directly.
*/
export async function isAdminRole(roleName: string): Promise<boolean> {
return roleName === ADMIN_ROLE_NAME
}

View File

@@ -0,0 +1,27 @@
"use client"
/**
* RBAC module error boundary.
*
* 薄包装:委托给共享 SectionErrorBoundary使用 common 命名空间。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
export function ErrorBoundary({ children, fallback }: ErrorBoundaryProps): ReactNode {
return (
<SectionErrorBoundary
namespace="common"
fallback={fallback ? () => fallback : undefined}
>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -0,0 +1,83 @@
"use client"
import { KeyRound } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { useTranslations } from "next-intl"
import { PERMISSION_CATALOG } from "../lib/permission-catalog"
interface PermissionCatalogViewProps {
/** Map of permission value → number of roles that have it. */
roleCountsByPermission?: Record<string, number>
}
/**
* Read-only catalog of all permission points, grouped by module.
* Shows each permission's key, value, and (optionally) how many roles grant it.
*/
export function PermissionCatalogView({
roleCountsByPermission,
}: PermissionCatalogViewProps) {
const t = useTranslations("rbac")
const totalPermissions = PERMISSION_CATALOG.reduce(
(sum, g) => sum + g.permissions.length,
0
)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="h-5 w-5" />
{t("permissions.title")}
<Badge variant="outline" className="ml-2">
{t("permissions.countLabel", { count: totalPermissions })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6" aria-label={t("permissions.catalogAriaLabel")}>
{PERMISSION_CATALOG.map((group) => (
<section key={group.group} className="space-y-2" aria-label={t.has(group.labelKey.replace("rbac:", "")) ? t(group.labelKey.replace("rbac:", "")) : group.group}>
<h3 className="text-sm font-semibold border-b pb-2">
{t.has(group.labelKey.replace("rbac:", ""))
? t(group.labelKey.replace("rbac:", ""))
: group.group}
</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{group.permissions.map((perm) => {
const count = roleCountsByPermission?.[perm.value] ?? 0
return (
<div
key={perm.key}
className="rounded-md border p-3 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium leading-tight">
{t.has(perm.labelKey.replace("rbac:", ""))
? t(perm.labelKey.replace("rbac:", ""))
: perm.key}
</p>
{roleCountsByPermission && (
<Badge variant="secondary" className="shrink-0">
{t("permissions.roleCount", { count })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono break-all">
{perm.value}
</p>
</div>
)
})}
</div>
</section>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { Pencil } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { RoleFormDialog } from "./role-form-dialog"
import type { RoleRecord } from "../types"
interface RoleDetailEditButtonProps {
role: RoleRecord
}
/**
* Button that opens the role edit dialog. Client component because it manages
* dialog open state.
*/
export function RoleDetailEditButton({ role }: RoleDetailEditButtonProps) {
const t = useTranslations("rbac")
const [open, setOpen] = React.useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
{t("roles.editButton")}
</Button>
<RoleFormDialog open={open} onOpenChange={setOpen} editRole={role} />
</>
)
}

View File

@@ -0,0 +1,138 @@
"use client"
import { useRouter } from "next/navigation"
import { Shield } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { useTranslations } from "next-intl"
import { createRoleAction, updateRoleAction } from "../actions"
import type { RoleRecord } from "../types"
interface RoleFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** When provided, the dialog is in edit mode. */
editRole?: RoleRecord | null
}
/**
* Create or edit a role. In edit mode for the `admin` role, the name field is
* disabled (admin role name is locked).
*/
export function RoleFormDialog({ open, onOpenChange, editRole }: RoleFormDialogProps) {
const router = useRouter()
const t = useTranslations("rbac")
const isEdit = Boolean(editRole)
const isAdmin = editRole?.name === "admin"
const createMutation = useActionMutation({
successMessage: t("messages.created"),
onSuccess: () => {
onOpenChange(false)
router.refresh()
},
})
const updateMutation = useActionMutation({
successMessage: t("messages.updated"),
onSuccess: () => {
onOpenChange(false)
router.refresh()
},
})
const isWorking = createMutation.isWorking || updateMutation.isWorking
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
if (isEdit && editRole) {
void updateMutation.mutate(() => updateRoleAction(editRole.id, undefined, formData))
} else {
void createMutation.mutate(() => createRoleAction(undefined, formData))
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{isEdit
? t("roles.editRoleTitle", { name: editRole?.name ?? "" })
: t("roles.createTitle")}
</DialogTitle>
<DialogDescription>
{isEdit ? t("roles.editDescription") : t("roles.createDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="role-name">{t("roles.name")}</Label>
<Input
id="role-name"
name="name"
defaultValue={editRole?.name ?? ""}
placeholder={t("roles.namePlaceholder")}
disabled={isAdmin || isWorking}
required
minLength={2}
maxLength={50}
pattern="^[a-z0-9_]+$"
title={t("roles.namePatternTitle")}
/>
{isAdmin && (
<p className="text-xs text-muted-foreground">
{t("roles.adminNameLocked")}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="role-description">{t("roles.descriptionLabel")}</Label>
<Textarea
id="role-description"
name="description"
defaultValue={editRole?.description ?? ""}
placeholder={t("roles.descriptionPlaceholder")}
disabled={isWorking}
maxLength={255}
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isWorking}
>
{t("actions.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{isWorking
? t("actions.saving")
: isEdit
? t("actions.save")
: t("roles.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,239 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Plus, Shield, ShieldCheck, ShieldAlert, MoreHorizontal, Pencil, Trash2, Power } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableCaption,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { formatDate } from "@/shared/lib/utils"
import { useTranslations } from "next-intl"
import { deleteRoleAction, toggleRoleEnabledAction } from "../actions"
import type { RoleWithStats } from "../types"
interface RoleListProps {
roles: RoleWithStats[]
}
/**
* Admin role management list view.
*
* Renders a table of roles with their type (system/custom), status, permission
* and user counts, and a row menu with edit / toggle / delete actions.
*/
export function RoleList({ roles }: RoleListProps) {
const router = useRouter()
const t = useTranslations("rbac")
const [deleteTarget, setDeleteTarget] = React.useState<RoleWithStats | null>(null)
const toggleMutation = useActionMutation({
successMessage: t("messages.roleStatusUpdated"),
onSuccess: () => router.refresh(),
})
const deleteMutation = useActionMutation({
successMessage: t("messages.deleted"),
onSuccess: () => {
setDeleteTarget(null)
router.refresh()
},
})
const handleToggle = (role: RoleWithStats): void => {
void toggleMutation.mutate(() => toggleRoleEnabledAction(role.id, !role.isEnabled))
}
const handleDelete = (): void => {
if (!deleteTarget) return
void deleteMutation.mutate(() => deleteRoleAction(deleteTarget.id))
}
if (roles.length === 0) {
return (
<EmptyState
icon={Shield}
title={t("roles.emptyTitle")}
description={t("roles.emptyDescription")}
action={{
label: t("roles.create"),
href: "/admin/roles?new=1",
variant: "default",
}}
/>
)
}
return (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("roles.listTitle", { count: roles.length })}</CardTitle>
<Button asChild size="sm">
<Link href="/admin/roles?new=1">
<Plus className="mr-2 h-4 w-4" />
{t("roles.create")}
</Link>
</Button>
</CardHeader>
<CardContent>
<Table aria-label={t("roles.tableAriaLabel")}>
<TableCaption className="sr-only">
{t("roles.tableCaption", { count: roles.length })}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead>{t("roles.name")}</TableHead>
<TableHead>{t("roles.description")}</TableHead>
<TableHead>{t("roles.type")}</TableHead>
<TableHead>{t("roles.status")}</TableHead>
<TableHead className="text-center">{t("roles.permissions")}</TableHead>
<TableHead className="text-center">{t("roles.users")}</TableHead>
<TableHead>{t("roles.updated")}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => {
const isAdmin = role.name === "admin"
return (
<TableRow key={role.id}>
<TableCell className="font-medium">
<Link
href={`/admin/roles/${role.id}`}
className="text-primary hover:underline"
>
{role.name}
</Link>
</TableCell>
<TableCell className="max-w-48 truncate text-muted-foreground">
{role.description ?? "—"}
</TableCell>
<TableCell>
{role.isSystem ? (
<Badge variant="secondary">
<ShieldCheck className="mr-1 h-3 w-3" />
{t("roles.system")}
</Badge>
) : (
<Badge variant="outline">
<Shield className="mr-1 h-3 w-3" />
{t("roles.custom")}
</Badge>
)}
</TableCell>
<TableCell>
{role.isEnabled ? (
<Badge variant="default">{t("roles.enabled")}</Badge>
) : (
<Badge variant="destructive">{t("roles.disabled")}</Badge>
)}
</TableCell>
<TableCell className="text-center">{role.permissionCount}</TableCell>
<TableCell className="text-center">{role.userCount}</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(role.updatedAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t("roles.openMenu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/roles/${role.id}`}>
<Pencil className="mr-2 h-4 w-4" />
{t("actions.edit")}
</Link>
</DropdownMenuItem>
{!isAdmin && (
<DropdownMenuItem onClick={() => handleToggle(role)}>
<Power className="mr-2 h-4 w-4" />
{role.isEnabled ? t("actions.disable") : t("actions.enable")}
</DropdownMenuItem>
)}
{!role.isSystem && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(role)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("actions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-destructive" />
{t("roles.deleteConfirmTitle", { name: deleteTarget?.name ?? "" })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("roles.deleteConfirmDescription", { count: deleteTarget?.userCount ?? 0 })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isWorking}>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteMutation.isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isWorking ? t("actions.deleting") : t("actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { useSearchParams } from "next/navigation"
import { RoleList } from "@/modules/rbac/components/role-list"
import { RoleFormDialog } from "@/modules/rbac/components/role-form-dialog"
import type { RoleWithStats } from "@/modules/rbac/types"
interface RoleManagementViewProps {
roles: RoleWithStats[]
}
/**
* Client wrapper for the roles page. Handles the `?new=1` query param to
* auto-open the create dialog.
*/
export function RoleManagementView({ roles }: RoleManagementViewProps) {
const searchParams = useSearchParams()
const [createOpen, setCreateOpen] = React.useState(
searchParams.get("new") === "1"
)
return (
<>
<RoleList roles={roles} />
<RoleFormDialog open={createOpen} onOpenChange={setCreateOpen} />
</>
)
}

View File

@@ -0,0 +1,302 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Save, Lock, Search, ChevronDown, Users } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Input } from "@/shared/components/ui/input"
import { Alert, AlertDescription } from "@/shared/components/ui/alert"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import type { Permission } from "@/shared/types/permissions"
import { setRolePermissionsAction } from "../actions"
import { PERMISSION_CATALOG, type PermissionMeta } from "../lib/permission-catalog"
interface RolePermissionMatrixProps {
roleId: string
roleName: string
/** Permissions currently assigned to the role. */
currentPermissions: Permission[]
/** Whether this role's permissions are locked (admin role). */
isLocked: boolean
/** Number of users assigned to this role (for the impact notice). */
userCount: number
}
/**
* Permission assignment matrix.
*
* Renders all permissions grouped by module with checkboxes. The user can
* toggle individual permissions or select/deselect all in a group. On save,
* the full permission list is sent to the server (replacement semantics).
*
* Features:
* - Searchable by permission name/label/value.
* - Collapsible groups (default expanded; auto-expand while searching).
* - Impact notice when the role has assigned users.
*
* The `admin` role is read-only (`isLocked = true`).
*/
export function RolePermissionMatrix({
roleId,
roleName,
currentPermissions,
isLocked,
userCount,
}: RolePermissionMatrixProps) {
const t = useTranslations("rbac")
const router = useRouter()
const [selected, setSelected] = React.useState<Set<string>>(
new Set(currentPermissions)
)
const [search, setSearch] = React.useState("")
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(
new Set()
)
// Reset local state when the prop changes (e.g. after server refresh)
React.useEffect(() => {
setSelected(new Set(currentPermissions))
}, [currentPermissions])
const saveMutation = useActionMutation({
successMessage: t("messages.permissionsUpdated"),
onSuccess: () => router.refresh(),
})
const normalizedSearch = search.trim().toLowerCase()
const matchesSearch = (perm: PermissionMeta): boolean => {
if (!normalizedSearch) return true
if (perm.value.toLowerCase().includes(normalizedSearch)) return true
if (perm.key.toLowerCase().includes(normalizedSearch)) return true
const labelKey = perm.labelKey.replace("rbac:", "")
if (t.has(labelKey)) {
const label = String(t(labelKey)).toLowerCase()
if (label.includes(normalizedSearch)) return true
}
return false
}
const filteredCatalog = PERMISSION_CATALOG.map((group) => ({
...group,
permissions: group.permissions.filter(matchesSearch),
})).filter((group) => group.permissions.length > 0)
// Auto-expand all groups while a search query is active.
React.useEffect(() => {
if (normalizedSearch) {
setCollapsedGroups(new Set())
}
}, [normalizedSearch])
const handleToggle = (permission: string, checked: boolean): void => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(permission)
else next.delete(permission)
return next
})
}
const handleToggleGroup = (groupPermissions: string[], checked: boolean): void => {
setSelected((prev) => {
const next = new Set(prev)
for (const p of groupPermissions) {
if (checked) next.add(p)
else next.delete(p)
}
return next
})
}
const handleToggleCollapse = (group: string): void => {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(group)) next.delete(group)
else next.add(group)
return next
})
}
const handleSave = (): void => {
const formData = new FormData()
formData.set("roleId", roleId)
formData.set("permissions", JSON.stringify(Array.from(selected)))
void saveMutation.mutate(() => setRolePermissionsAction(undefined, formData))
}
const hasChanges = !setsEqual(selected, new Set(currentPermissions))
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{t("permissions.matrixTitle", { roleName })}</CardTitle>
{isLocked && (
<Badge variant="secondary">
<Lock className="mr-1 h-3 w-3" />
{t("roles.locked")}
</Badge>
)}
<Badge variant="outline">{t("permissions.selectedCount", { count: selected.size })}</Badge>
</div>
{!isLocked && (
<Button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isWorking}
size="sm"
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isWorking ? t("actions.saving") : t("actions.save")}
</Button>
)}
</CardHeader>
<CardContent className={isLocked ? "pointer-events-none opacity-60" : ""}>
{userCount > 0 && (
<Alert className="mb-4 border-amber-500/50 bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400">
<Users className="h-4 w-4 translate-y-0.5" />
<AlertDescription>
{t("permissions.impactNotice", { count: userCount })}
</AlertDescription>
</Alert>
)}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("permissions.searchPlaceholder")}
className="pl-8"
aria-label={t("permissions.searchAriaLabel")}
/>
</div>
</div>
{filteredCatalog.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("permissions.searchNoResults")}
</p>
) : (
<div className="space-y-4">
{filteredCatalog.map((group) => {
const groupPermValues = group.permissions.map((p) => p.value)
const selectedInGroup = groupPermValues.filter((p) => selected.has(p))
const allChecked = selectedInGroup.length === groupPermValues.length
const someChecked = selectedInGroup.length > 0 && !allChecked
const isCollapsed = !normalizedSearch && collapsedGroups.has(group.group)
const groupLabel = t.has(group.labelKey.replace("rbac:", ""))
? t(group.labelKey.replace("rbac:", ""))
: group.group
return (
<Collapsible
key={group.group}
open={!isCollapsed}
onOpenChange={() => handleToggleCollapse(group.group)}
>
<fieldset className="space-y-2">
<legend className="flex w-full items-center gap-2 border-b pb-2">
<Checkbox
id={`group-${group.group}`}
checked={allChecked ? true : someChecked ? "indeterminate" : false}
onCheckedChange={(checked) =>
handleToggleGroup(groupPermValues, checked === true)
}
disabled={isLocked}
onClick={(e) => e.stopPropagation()}
/>
<label
htmlFor={`group-${group.group}`}
className="flex-1 cursor-pointer text-sm font-semibold"
>
{groupLabel}
</label>
<Badge variant="outline">
{selectedInGroup.length} / {groupPermValues.length}
</Badge>
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent"
aria-label={
isCollapsed
? t("permissions.expandGroup", { name: groupLabel })
: t("permissions.collapseGroup", { name: groupLabel })
}
aria-expanded={!isCollapsed}
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isCollapsed ? "" : "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
</legend>
<CollapsibleContent>
<div className="grid grid-cols-1 gap-2 pt-2 sm:grid-cols-2 lg:grid-cols-3">
{group.permissions.map((perm) => {
const isChecked = selected.has(perm.value)
const permLabel = t.has(perm.labelKey.replace("rbac:", ""))
? t(perm.labelKey.replace("rbac:", ""))
: perm.key
return (
<label
key={perm.key}
htmlFor={perm.key}
className="flex items-start gap-2 rounded-md border p-2 hover:bg-accent cursor-pointer"
>
<Checkbox
id={perm.key}
checked={isChecked}
onCheckedChange={(checked) =>
handleToggle(perm.value, checked === true)
}
disabled={isLocked}
className="mt-0.5"
/>
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium leading-tight">
{permLabel}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{perm.value}
</p>
</div>
</label>
)
})}
</div>
</CollapsibleContent>
</fieldset>
</Collapsible>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false
for (const v of a) if (!b.has(v)) return false
return true
}

View File

@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { UserCog } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Badge } from "@/shared/components/ui/badge"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { useTranslations } from "next-intl"
import { assignUserRolesAction } from "../actions"
/** Minimal role shape needed by the assign dialog. Re-exported so the users
* module can pass role data without importing the full RoleRecord type. */
export interface AssignableRole {
id: string
name: string
description: string | null
isSystem: boolean
isEnabled: boolean
}
interface UserRoleAssignDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
userId: string
userName: string
userEmail: string
/** All available roles (enabled ones are assignable). */
allRoles: AssignableRole[]
/** Role names currently assigned to the user. */
currentRoleNames: string[]
}
/**
* Dialog for assigning roles to a user. Renders a checkbox list of all
* enabled roles. On save, the full role list is sent (replacement semantics).
*/
export function UserRoleAssignDialog({
open,
onOpenChange,
userId,
userName,
userEmail,
allRoles,
currentRoleNames,
}: UserRoleAssignDialogProps) {
const router = useRouter()
const t = useTranslations("rbac")
const [selected, setSelected] = React.useState<Set<string>>(
new Set(currentRoleNames)
)
React.useEffect(() => {
setSelected(new Set(currentRoleNames))
}, [currentRoleNames])
const assignMutation = useActionMutation({
successMessage: t("messages.rolesAssigned"),
onSuccess: () => {
onOpenChange(false)
router.refresh()
},
})
const handleToggle = (roleName: string, checked: boolean): void => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(roleName)
else next.delete(roleName)
return next
})
}
const handleSave = (): void => {
const formData = new FormData()
formData.set("userId", userId)
formData.set("roleNames", JSON.stringify(Array.from(selected)))
void assignMutation.mutate(() => assignUserRolesAction(undefined, formData))
}
// Only enabled roles can be assigned; disabled roles are shown but read-only
const enabledRoles = allRoles.filter((r) => r.isEnabled)
const disabledAssignedRoles = allRoles.filter(
(r) => !r.isEnabled && selected.has(r.name)
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="h-5 w-5" />
{t("roles.assignTitle")}
</DialogTitle>
<DialogDescription>
{t.rich("roles.assignDescription", {
name: userName || userEmail,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</DialogDescription>
</DialogHeader>
<div
className="space-y-3 max-h-96 overflow-y-auto"
role="group"
aria-label={t("roles.assignListAriaLabel", { name: userName || userEmail })}
>
{enabledRoles.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("roles.noEnabledRoles")}
</p>
)}
{enabledRoles.map((role) => {
const isChecked = selected.has(role.name)
const isAdmin = role.name === "admin"
return (
<label
key={role.id}
htmlFor={`role-${role.id}`}
className="flex items-start gap-3 rounded-md border p-3 hover:bg-accent cursor-pointer"
>
<Checkbox
id={`role-${role.id}`}
checked={isChecked}
onCheckedChange={(checked) =>
handleToggle(role.name, checked === true)
}
disabled={assignMutation.isWorking}
className="mt-0.5"
/>
<div className="space-y-0.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{role.name}</span>
{role.isSystem && <Badge variant="secondary">{t("roles.system")}</Badge>}
{isAdmin && <Badge variant="destructive">{t("roles.locked")}</Badge>}
</div>
{role.description && (
<p className="text-xs text-muted-foreground">
{role.description}
</p>
)}
</div>
</label>
)
})}
{disabledAssignedRoles.length > 0 && (
<div className="space-y-1 pt-2 border-t">
<p className="text-xs text-muted-foreground">
{t("roles.disabledAssignedLabel")}
</p>
{disabledAssignedRoles.map((role) => (
<div key={role.id} className="flex items-center gap-2 text-xs">
<Badge variant="outline">{role.name}</Badge>
<span className="text-muted-foreground">{t("roles.disabledLabel")}</span>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={assignMutation.isWorking}
>
{t("actions.cancel")}
</Button>
<Button onClick={handleSave} disabled={assignMutation.isWorking}>
{assignMutation.isWorking ? t("actions.saving") : t("roles.saveRoles")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,172 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { roles, users, usersToRoles } from "@/shared/db/schema"
import type { PaginatedResult, UserRoleAssignment } from "./types"
const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100
function clampPageSize(size?: number): number {
if (!size || size <= 0) return DEFAULT_PAGE_SIZE
return Math.min(size, MAX_PAGE_SIZE)
}
function clampPage(page?: number): number {
if (!page || page <= 0) return 1
return page
}
/**
* Get the list of role names assigned to a user.
*/
export const getUserRoleNames = cache(async (userId: string): Promise<string[]> => {
const rows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId))
return rows.map((r) => r.name)
})
/**
* Replace the full set of roles assigned to a user.
* Resolves role names to IDs and inserts them in a transaction.
*
* Only enabled roles (or system roles, which are always considered enabled)
* can be assigned. Disabled roles are silently filtered out to keep
* `users_to_roles` consistent with `resolvePermissions` (which also filters
* disabled roles at login time).
*
* Pass an empty array to remove all roles from the user.
*/
export async function assignRolesToUser(
userId: string,
roleNames: string[]
): Promise<void> {
// Verify the user exists
const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
if (!user) throw new Error("User not found")
// Resolve role names to role records. Only enabled roles are assignable;
// disabled roles are filtered out to avoid data inconsistency.
const roleRows =
roleNames.length > 0
? await db
.select({ id: roles.id, name: roles.name, isEnabled: roles.isEnabled })
.from(roles)
.where(inArray(roles.name, roleNames))
: []
// Validate all requested role names exist
const foundNames = new Set(roleRows.map((r) => r.name))
const missing = roleNames.filter((n) => !foundNames.has(n))
if (missing.length > 0) {
throw new Error(`Unknown roles: ${missing.join(", ")}`)
}
// P1-9 security fix: filter out disabled non-system roles
const assignableRoles = roleRows.filter((r) => r.isEnabled || r.name === "admin")
const roleIds = assignableRoles.map((r) => r.id)
await db.transaction(async (tx) => {
await tx.delete(usersToRoles).where(eq(usersToRoles.userId, userId))
if (roleIds.length > 0) {
await tx.insert(usersToRoles).values(
roleIds.map((roleId) => ({ userId, roleId }))
)
}
})
}
/**
* List user-role assignments with optional filtering and pagination.
* Each row includes the user's id, name, email, and the list of role names assigned.
*/
export async function getUserRoleAssignments(params?: {
page?: number
pageSize?: number
search?: string
role?: string
}): Promise<PaginatedResult<UserRoleAssignment>> {
const page = clampPage(params?.page)
const pageSize = clampPageSize(params?.pageSize)
const offset = (page - 1) * pageSize
const conditions = []
if (params?.search) {
const term = `%${params.search}%`
conditions.push(or(ilike(users.name, term), ilike(users.email, term)))
}
// If filtering by role, join through users_to_roles
const where = conditions.length ? and(...conditions) : undefined
const [userRows, totalRows] = await Promise.all([
db
.select({
id: users.id,
name: users.name,
email: users.email,
createdAt: users.createdAt,
})
.from(users)
.where(where)
.orderBy(desc(users.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ value: count() }).from(users).where(where),
])
const userIds = userRows.map((u) => u.id)
if (userIds.length === 0) {
return {
items: [],
page,
pageSize,
total: 0,
totalPages: 0,
}
}
// Fetch role assignments for these users in a single query
const assignmentRows = await db
.select({ userId: usersToRoles.userId, roleName: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, userIds))
const roleMap = new Map<string, string[]>()
for (const row of assignmentRows) {
const list = roleMap.get(row.userId) ?? []
list.push(row.roleName)
roleMap.set(row.userId, list)
}
const items: UserRoleAssignment[] = userRows.map((u) => ({
userId: u.id,
userName: u.name,
userEmail: u.email,
roleNames: roleMap.get(u.id) ?? [],
createdAt: u.createdAt,
}))
// Apply role filter post-fetch if specified (simpler than a subquery here)
const filtered = params?.role
? items.filter((i) => i.roleNames.includes(params.role ?? ""))
: items
const total = Number(totalRows[0]?.value ?? 0)
return {
items: filtered,
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
}
}

View File

@@ -0,0 +1,36 @@
import "server-only"
import { cache } from "react"
import { and, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { rolePermissions, roles } from "@/shared/db/schema"
import { isPermission } from "@/shared/lib/type-guards"
import { getAllPermissionMetas } from "./lib/permission-catalog"
/**
* Count how many enabled roles have each permission.
*
* Returns a map of permission value → role count. Only permissions that
* appear in the permission catalog are included; only enabled roles are
* counted (disabled roles do not contribute permissions at login time).
*/
export const getPermissionRoleCounts = cache(async (): Promise<Record<string, number>> => {
const allPermissionValues = getAllPermissionMetas().map((m) => m.value)
if (allPermissionValues.length === 0) return {}
const rows = await db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.innerJoin(roles, eq(rolePermissions.roleId, roles.id))
.where(and(eq(roles.isEnabled, true), inArray(rolePermissions.permission, allPermissionValues)))
const counts: Record<string, number> = {}
for (const row of rows) {
const perm = isPermission(row.permission) ? row.permission : null
if (perm) {
counts[perm] = (counts[perm] ?? 0) + 1
}
}
return counts
})

View File

@@ -0,0 +1,227 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { count, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { rolePermissions, roles, usersToRoles } from "@/shared/db/schema"
import { isPermission } from "@/shared/lib/type-guards"
import type { Permission } from "@/shared/types/permissions"
import type {
CreateRoleInput,
RoleDetail,
RoleRecord,
RoleWithStats,
UpdateRoleInput,
} from "./types"
/** The `admin` role name. This role is fully locked: no edits, no disable, no delete. */
export const ADMIN_ROLE_NAME = "admin"
function toRoleRecord(row: typeof roles.$inferSelect): RoleRecord {
return {
id: row.id,
name: row.name,
description: row.description,
isSystem: row.isSystem,
isEnabled: row.isEnabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
/**
* List all roles with permission and user counts.
* Results are ordered: system roles first (alphabetical), then custom roles (newest first).
*/
export const getRoles = cache(async (): Promise<RoleWithStats[]> => {
const roleRows = await db.select().from(roles).orderBy(roles.name)
if (roleRows.length === 0) return []
const roleIds = roleRows.map((r) => r.id)
const [permCounts, userCounts] = await Promise.all([
db
.select({ roleId: rolePermissions.roleId, count: count() })
.from(rolePermissions)
.where(inArray(rolePermissions.roleId, roleIds))
.groupBy(rolePermissions.roleId),
db
.select({ roleId: usersToRoles.roleId, count: count() })
.from(usersToRoles)
.where(inArray(usersToRoles.roleId, roleIds))
.groupBy(usersToRoles.roleId),
])
const permMap = new Map(permCounts.map((r) => [r.roleId, Number(r.count)]))
const userMap = new Map(userCounts.map((r) => [r.roleId, Number(r.count)]))
return roleRows.map((row) => ({
...toRoleRecord(row),
permissionCount: permMap.get(row.id) ?? 0,
userCount: userMap.get(row.id) ?? 0,
}))
})
/**
* Get a single role by id, including its full permission list and user count.
*/
export const getRoleById = cache(async (id: string): Promise<RoleDetail | null> => {
const roleRow = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!roleRow) return null
const [permRows, userCountRows] = await Promise.all([
db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, id)),
db
.select({ count: count() })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, id)),
])
return {
...toRoleRecord(roleRow),
permissions: permRows
.map((p) => (isPermission(p.permission) ? p.permission : null))
.filter((p): p is Permission => p !== null),
userCount: userCountRows[0] ? Number(userCountRows[0].count) : 0,
}
})
/**
* Get a single role by name.
*/
export const getRoleByName = cache(async (name: string): Promise<RoleRecord | null> => {
const row = await db.query.roles.findFirst({ where: eq(roles.name, name) })
return row ? toRoleRecord(row) : null
})
/**
* Create a new custom role.
* Throws if a role with the same name already exists.
*/
export async function createRole(input: CreateRoleInput): Promise<RoleRecord> {
const existing = await db.query.roles.findFirst({ where: eq(roles.name, input.name) })
if (existing) {
throw new Error(`Role "${input.name}" already exists`)
}
const id = createId()
await db.insert(roles).values({
id,
name: input.name,
description: input.description ?? null,
isSystem: false,
isEnabled: true,
})
const row = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!row) throw new Error("Failed to create role")
return toRoleRecord(row)
}
/**
* Update a role's name and/or description.
* The `admin` role's name cannot be changed.
*/
export async function updateRole(id: string, input: UpdateRoleInput): Promise<RoleRecord> {
const existing = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!existing) throw new Error("Role not found")
// Lock the admin role name
if (existing.name === ADMIN_ROLE_NAME && input.name && input.name !== ADMIN_ROLE_NAME) {
throw new Error("The admin role name cannot be changed")
}
const updateData: Partial<typeof roles.$inferInsert> = {}
if (input.name !== undefined) updateData.name = input.name
if (input.description !== undefined) updateData.description = input.description
if (Object.keys(updateData).length === 0) {
return toRoleRecord(existing)
}
await db.update(roles).set(updateData).where(eq(roles.id, id))
const row = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!row) throw new Error("Role disappeared after update")
return toRoleRecord(row)
}
/**
* Delete a role.
* System roles (including admin) cannot be deleted.
*/
export async function deleteRole(id: string): Promise<void> {
const existing = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!existing) throw new Error("Role not found")
if (existing.isSystem) {
throw new Error("System roles cannot be deleted")
}
// role_permissions and users_to_roles cascade on delete via FK
await db.delete(roles).where(eq(roles.id, id))
}
/**
* Enable or disable a role.
* The admin role cannot be disabled.
*/
export async function setRoleEnabled(id: string, enabled: boolean): Promise<RoleRecord> {
const existing = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!existing) throw new Error("Role not found")
if (existing.name === ADMIN_ROLE_NAME && !enabled) {
throw new Error("The admin role cannot be disabled")
}
await db.update(roles).set({ isEnabled: enabled }).where(eq(roles.id, id))
const row = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!row) throw new Error("Role disappeared after update")
return toRoleRecord(row)
}
/**
* Get the list of permission strings assigned to a role.
*/
export const getRolePermissions = cache(async (roleId: string): Promise<Permission[]> => {
const rows = await db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.where(eq(rolePermissions.roleId, roleId))
return rows
.map((r) => (isPermission(r.permission) ? r.permission : null))
.filter((p): p is Permission => p !== null)
})
/**
* Replace the full permission list for a role.
* Uses a transaction: delete all existing entries, then insert the new set.
*
* The `admin` role's permissions cannot be changed (it is fully locked).
*/
export async function setRolePermissions(
roleId: string,
permissions: Permission[]
): Promise<void> {
const existing = await db.query.roles.findFirst({ where: eq(roles.id, roleId) })
if (!existing) throw new Error("Role not found")
if (existing.name === ADMIN_ROLE_NAME) {
throw new Error("The admin role permissions cannot be modified")
}
const uniquePermissions = [...new Set(permissions)]
await db.transaction(async (tx) => {
await tx.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
if (uniquePermissions.length > 0) {
await tx.insert(rolePermissions).values(
uniquePermissions.map((p) => ({ roleId, permission: p }))
)
}
})
}

View File

@@ -0,0 +1,26 @@
import "server-only"
import type { DataScope, Role } from "@/shared/types/permissions"
/**
* A rule that maps one or more roles to a DataScope resolution strategy.
*
* The resolver iterates rules in array order (priority). For each rule,
* if the user has any of the listed roles, `resolve` is called. A non-null
* return value becomes the user's DataScope and iteration stops. A null
* return value means "this role didn't yield a scope, try the next rule".
*
* This configuration-driven approach (P1-6 audit fix) replaces the
* hardcoded `roleNames.includes("xxx")` chain in the old resolveDataScope.
* Adding a new role or changing scope behaviour only requires editing this
* config, not the resolver logic.
*/
export interface DataScopeRule {
/** Roles that trigger this rule. */
roles: Role[]
/**
* Resolve the DataScope for a user with one of `roles`.
* Return null to fall through to the next rule.
*/
resolve: (userId: string) => Promise<DataScope | null>
}

View File

@@ -0,0 +1,110 @@
import "server-only"
import type { DataScope, Role } from "@/shared/types/permissions"
import { getGradesForStaff } from "@/modules/school/data-access"
import {
getTeacherScopeData,
getStudentScopeData,
getGradeIdsForStudentIds,
} from "@/modules/classes/data-access"
import { getChildren } from "@/modules/parent/data-access"
import type { DataScopeRule } from "./data-scope-config"
/**
* Configuration-driven DataScope rules (P1-5/P1-6 audit fix).
*
* Order matters: the first rule whose `resolve` returns a non-null
* DataScope wins. Rules that query module data-access functions return
* null when the user has no managed resources, allowing fall-through to
* the next rule (e.g. a grade_head with no grades falls back to "owned").
*
* To add a new role or change scope behaviour, edit this array — no need
* to touch the resolver logic in shared/lib/auth-guard.ts.
*/
export const DATA_SCOPE_RULES: DataScopeRule[] = [
// Admin sees everything
{
roles: ["admin"],
resolve: async () => ({ type: "all" }),
},
// Grade head / teaching head: can manage their grades
{
roles: ["grade_head", "teaching_head"],
resolve: async (userId) => {
const managedGrades = await getGradesForStaff(userId)
if (managedGrades.length === 0) return null
return {
type: "grade_managed",
gradeIds: managedGrades.map((g) => g.id),
}
},
},
// Teacher: can see their own classes
{
roles: ["teacher"],
resolve: async (userId) => {
const { classIds, subjectIds } = await getTeacherScopeData(userId)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
},
},
// Student: can see data from their enrolled classes
{
roles: ["student"],
resolve: async (userId) => {
const { classIds, gradeIds } = await getStudentScopeData(userId)
return {
type: "class_members",
classIds,
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
}
},
},
// Parent: can see their children's data
{
roles: ["parent"],
resolve: async (userId) => {
const children = await getChildren(userId)
const childrenIds = children.map((c) => c.studentId)
const gradeIds = await getGradeIdsForStudentIds(childrenIds)
return {
type: "children",
childrenIds,
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
}
},
},
]
/**
* Resolve the DataScope for a user based on their roles.
*
* Iterates DATA_SCOPE_RULES in priority order. The first rule whose
* `resolve` returns a non-null DataScope wins. Falls back to "owned"
* if no rule matches or all matching rules return null.
*
* This function is called by shared/lib/auth-guard.ts via dynamic import,
* so the shared layer never statically depends on modules/*.
*/
export async function resolveDataScopeFromConfig(
userId: string,
roleNames: Role[],
): Promise<DataScope> {
const roleSet = new Set(roleNames)
for (const rule of DATA_SCOPE_RULES) {
const hasRole = rule.roles.some((r) => roleSet.has(r))
if (!hasRole) continue
const scope = await rule.resolve(userId)
if (scope !== null) return scope
}
// Fallback: only own data
return { type: "owned", userId }
}

View File

@@ -0,0 +1,271 @@
import { Permissions, type Permission } from "@/shared/types/permissions"
/**
* Metadata for a single permission point, used by the permission catalog view
* and the role-permission matrix to render grouped, labeled permissions.
*/
export interface PermissionMeta {
/** The Permissions constant key, e.g. "EXAM_CREATE". */
key: keyof typeof Permissions
/** The permission string value, e.g. "exam:create". */
value: Permission
/** i18n key for the human-readable label, e.g. "rbac:permissions.exam.create". */
labelKey: string
/** i18n key for the description, e.g. "rbac:permissions.exam.create.desc". */
descriptionKey: string
}
/**
* A group of related permissions, used to render the catalog/matrix by module.
*/
export interface PermissionGroup {
/** Group key, e.g. "exam". */
group: string
/** i18n key for the group label, e.g. "rbac:permissions.group.exam". */
labelKey: string
permissions: PermissionMeta[]
}
/**
* The full permission catalog, grouped by module.
*
* This is the single source of truth for which permissions exist and how they
* are grouped/labeled in the UI. When adding a new permission to
* `shared/types/permissions.ts`, add a matching entry here so it shows up in
* the admin permission management UI.
*/
export const PERMISSION_CATALOG: PermissionGroup[] = [
{
group: "exam",
labelKey: "rbac:permissions.group.exam",
permissions: [
{ key: "EXAM_CREATE", value: Permissions.EXAM_CREATE, labelKey: "rbac:permissions.exam.create", descriptionKey: "rbac:permissions.exam.create.desc" },
{ key: "EXAM_READ", value: Permissions.EXAM_READ, labelKey: "rbac:permissions.exam.read", descriptionKey: "rbac:permissions.exam.read.desc" },
{ key: "EXAM_UPDATE", value: Permissions.EXAM_UPDATE, labelKey: "rbac:permissions.exam.update", descriptionKey: "rbac:permissions.exam.update.desc" },
{ key: "EXAM_DELETE", value: Permissions.EXAM_DELETE, labelKey: "rbac:permissions.exam.delete", descriptionKey: "rbac:permissions.exam.delete.desc" },
{ key: "EXAM_DUPLICATE", value: Permissions.EXAM_DUPLICATE, labelKey: "rbac:permissions.exam.duplicate", descriptionKey: "rbac:permissions.exam.duplicate.desc" },
{ key: "EXAM_PUBLISH", value: Permissions.EXAM_PUBLISH, labelKey: "rbac:permissions.exam.publish", descriptionKey: "rbac:permissions.exam.publish.desc" },
{ key: "EXAM_AI_GENERATE", value: Permissions.EXAM_AI_GENERATE, labelKey: "rbac:permissions.exam.ai_generate", descriptionKey: "rbac:permissions.exam.ai_generate.desc" },
{ key: "EXAM_SUBMIT", value: Permissions.EXAM_SUBMIT, labelKey: "rbac:permissions.exam.submit", descriptionKey: "rbac:permissions.exam.submit.desc" },
{ key: "EXAM_PROCTOR", value: Permissions.EXAM_PROCTOR, labelKey: "rbac:permissions.exam.proctor", descriptionKey: "rbac:permissions.exam.proctor.desc" },
{ key: "EXAM_PROCTOR_READ", value: Permissions.EXAM_PROCTOR_READ, labelKey: "rbac:permissions.exam.proctor_read", descriptionKey: "rbac:permissions.exam.proctor_read.desc" },
],
},
{
group: "homework",
labelKey: "rbac:permissions.group.homework",
permissions: [
{ key: "HOMEWORK_CREATE", value: Permissions.HOMEWORK_CREATE, labelKey: "rbac:permissions.homework.create", descriptionKey: "rbac:permissions.homework.create.desc" },
{ key: "HOMEWORK_GRADE", value: Permissions.HOMEWORK_GRADE, labelKey: "rbac:permissions.homework.grade", descriptionKey: "rbac:permissions.homework.grade.desc" },
{ key: "HOMEWORK_SUBMIT", value: Permissions.HOMEWORK_SUBMIT, labelKey: "rbac:permissions.homework.submit", descriptionKey: "rbac:permissions.homework.submit.desc" },
],
},
{
group: "question",
labelKey: "rbac:permissions.group.question",
permissions: [
{ key: "QUESTION_CREATE", value: Permissions.QUESTION_CREATE, labelKey: "rbac:permissions.question.create", descriptionKey: "rbac:permissions.question.create.desc" },
{ key: "QUESTION_READ", value: Permissions.QUESTION_READ, labelKey: "rbac:permissions.question.read", descriptionKey: "rbac:permissions.question.read.desc" },
{ key: "QUESTION_UPDATE", value: Permissions.QUESTION_UPDATE, labelKey: "rbac:permissions.question.update", descriptionKey: "rbac:permissions.question.update.desc" },
{ key: "QUESTION_DELETE", value: Permissions.QUESTION_DELETE, labelKey: "rbac:permissions.question.delete", descriptionKey: "rbac:permissions.question.delete.desc" },
],
},
{
group: "textbook",
labelKey: "rbac:permissions.group.textbook",
permissions: [
{ key: "TEXTBOOK_CREATE", value: Permissions.TEXTBOOK_CREATE, labelKey: "rbac:permissions.textbook.create", descriptionKey: "rbac:permissions.textbook.create.desc" },
{ key: "TEXTBOOK_READ", value: Permissions.TEXTBOOK_READ, labelKey: "rbac:permissions.textbook.read", descriptionKey: "rbac:permissions.textbook.read.desc" },
{ key: "TEXTBOOK_UPDATE", value: Permissions.TEXTBOOK_UPDATE, labelKey: "rbac:permissions.textbook.update", descriptionKey: "rbac:permissions.textbook.update.desc" },
{ key: "TEXTBOOK_DELETE", value: Permissions.TEXTBOOK_DELETE, labelKey: "rbac:permissions.textbook.delete", descriptionKey: "rbac:permissions.textbook.delete.desc" },
],
},
{
group: "class",
labelKey: "rbac:permissions.group.class",
permissions: [
{ key: "CLASS_CREATE", value: Permissions.CLASS_CREATE, labelKey: "rbac:permissions.class.create", descriptionKey: "rbac:permissions.class.create.desc" },
{ key: "CLASS_READ", value: Permissions.CLASS_READ, labelKey: "rbac:permissions.class.read", descriptionKey: "rbac:permissions.class.read.desc" },
{ key: "CLASS_UPDATE", value: Permissions.CLASS_UPDATE, labelKey: "rbac:permissions.class.update", descriptionKey: "rbac:permissions.class.update.desc" },
{ key: "CLASS_DELETE", value: Permissions.CLASS_DELETE, labelKey: "rbac:permissions.class.delete", descriptionKey: "rbac:permissions.class.delete.desc" },
{ key: "CLASS_ENROLL", value: Permissions.CLASS_ENROLL, labelKey: "rbac:permissions.class.enroll", descriptionKey: "rbac:permissions.class.enroll.desc" },
{ key: "CLASS_SCHEDULE", value: Permissions.CLASS_SCHEDULE, labelKey: "rbac:permissions.class.schedule", descriptionKey: "rbac:permissions.class.schedule.desc" },
],
},
{
group: "school",
labelKey: "rbac:permissions.group.school",
permissions: [
{ key: "SCHOOL_MANAGE", value: Permissions.SCHOOL_MANAGE, labelKey: "rbac:permissions.school.manage", descriptionKey: "rbac:permissions.school.manage.desc" },
{ key: "GRADE_MANAGE", value: Permissions.GRADE_MANAGE, labelKey: "rbac:permissions.school.grade_manage", descriptionKey: "rbac:permissions.school.grade_manage.desc" },
{ key: "USER_MANAGE", value: Permissions.USER_MANAGE, labelKey: "rbac:permissions.school.user_manage", descriptionKey: "rbac:permissions.school.user_manage.desc" },
],
},
{
group: "user",
labelKey: "rbac:permissions.group.user",
permissions: [
{ key: "USER_PROFILE_UPDATE", value: Permissions.USER_PROFILE_UPDATE, labelKey: "rbac:permissions.user.profile_update", descriptionKey: "rbac:permissions.user.profile_update.desc" },
],
},
{
group: "ai",
labelKey: "rbac:permissions.group.ai",
permissions: [
{ key: "AI_CHAT", value: Permissions.AI_CHAT, labelKey: "rbac:permissions.ai.chat", descriptionKey: "rbac:permissions.ai.chat.desc" },
{ key: "AI_CONFIGURE", value: Permissions.AI_CONFIGURE, labelKey: "rbac:permissions.ai.configure", descriptionKey: "rbac:permissions.ai.configure.desc" },
],
},
{
group: "settings",
labelKey: "rbac:permissions.group.settings",
permissions: [
{ key: "SETTINGS_ADMIN", value: Permissions.SETTINGS_ADMIN, labelKey: "rbac:permissions.settings.admin", descriptionKey: "rbac:permissions.settings.admin.desc" },
],
},
{
group: "audit",
labelKey: "rbac:permissions.group.audit",
permissions: [
{ key: "AUDIT_LOG_READ", value: Permissions.AUDIT_LOG_READ, labelKey: "rbac:permissions.audit.read", descriptionKey: "rbac:permissions.audit.read.desc" },
],
},
{
group: "announcement",
labelKey: "rbac:permissions.group.announcement",
permissions: [
{ key: "ANNOUNCEMENT_MANAGE", value: Permissions.ANNOUNCEMENT_MANAGE, labelKey: "rbac:permissions.announcement.manage", descriptionKey: "rbac:permissions.announcement.manage.desc" },
{ key: "ANNOUNCEMENT_READ", value: Permissions.ANNOUNCEMENT_READ, labelKey: "rbac:permissions.announcement.read", descriptionKey: "rbac:permissions.announcement.read.desc" },
],
},
{
group: "grade_record",
labelKey: "rbac:permissions.group.grade_record",
permissions: [
{ key: "GRADE_RECORD_MANAGE", value: Permissions.GRADE_RECORD_MANAGE, labelKey: "rbac:permissions.grade_record.manage", descriptionKey: "rbac:permissions.grade_record.manage.desc" },
{ key: "GRADE_RECORD_READ", value: Permissions.GRADE_RECORD_READ, labelKey: "rbac:permissions.grade_record.read", descriptionKey: "rbac:permissions.grade_record.read.desc" },
],
},
{
group: "file",
labelKey: "rbac:permissions.group.file",
permissions: [
{ key: "FILE_UPLOAD", value: Permissions.FILE_UPLOAD, labelKey: "rbac:permissions.file.upload", descriptionKey: "rbac:permissions.file.upload.desc" },
{ key: "FILE_READ", value: Permissions.FILE_READ, labelKey: "rbac:permissions.file.read", descriptionKey: "rbac:permissions.file.read.desc" },
{ key: "FILE_DELETE", value: Permissions.FILE_DELETE, labelKey: "rbac:permissions.file.delete", descriptionKey: "rbac:permissions.file.delete.desc" },
],
},
{
group: "course_plan",
labelKey: "rbac:permissions.group.course_plan",
permissions: [
{ key: "COURSE_PLAN_MANAGE", value: Permissions.COURSE_PLAN_MANAGE, labelKey: "rbac:permissions.course_plan.manage", descriptionKey: "rbac:permissions.course_plan.manage.desc" },
{ key: "COURSE_PLAN_READ", value: Permissions.COURSE_PLAN_READ, labelKey: "rbac:permissions.course_plan.read", descriptionKey: "rbac:permissions.course_plan.read.desc" },
],
},
{
group: "attendance",
labelKey: "rbac:permissions.group.attendance",
permissions: [
{ key: "ATTENDANCE_MANAGE", value: Permissions.ATTENDANCE_MANAGE, labelKey: "rbac:permissions.attendance.manage", descriptionKey: "rbac:permissions.attendance.manage.desc" },
{ key: "ATTENDANCE_READ", value: Permissions.ATTENDANCE_READ, labelKey: "rbac:permissions.attendance.read", descriptionKey: "rbac:permissions.attendance.read.desc" },
{ key: "LEAVE_REQUEST_CREATE", value: Permissions.LEAVE_REQUEST_CREATE, labelKey: "rbac:permissions.leave_request.create", descriptionKey: "rbac:permissions.leave_request.create.desc" },
{ key: "LEAVE_REQUEST_READ", value: Permissions.LEAVE_REQUEST_READ, labelKey: "rbac:permissions.leave_request.read", descriptionKey: "rbac:permissions.leave_request.read.desc" },
{ key: "LEAVE_REQUEST_REVIEW", value: Permissions.LEAVE_REQUEST_REVIEW, labelKey: "rbac:permissions.leave_request.review", descriptionKey: "rbac:permissions.leave_request.review.desc" },
],
},
{
group: "message",
labelKey: "rbac:permissions.group.message",
permissions: [
{ key: "MESSAGE_SEND", value: Permissions.MESSAGE_SEND, labelKey: "rbac:permissions.message.send", descriptionKey: "rbac:permissions.message.send.desc" },
{ key: "MESSAGE_READ", value: Permissions.MESSAGE_READ, labelKey: "rbac:permissions.message.read", descriptionKey: "rbac:permissions.message.read.desc" },
{ key: "MESSAGE_DELETE", value: Permissions.MESSAGE_DELETE, labelKey: "rbac:permissions.message.delete", descriptionKey: "rbac:permissions.message.delete.desc" },
],
},
{
group: "scheduling",
labelKey: "rbac:permissions.group.scheduling",
permissions: [
{ key: "SCHEDULE_AUTO", value: Permissions.SCHEDULE_AUTO, labelKey: "rbac:permissions.scheduling.auto", descriptionKey: "rbac:permissions.scheduling.auto.desc" },
{ key: "SCHEDULE_ADJUST", value: Permissions.SCHEDULE_ADJUST, labelKey: "rbac:permissions.scheduling.adjust", descriptionKey: "rbac:permissions.scheduling.adjust.desc" },
],
},
{
group: "elective",
labelKey: "rbac:permissions.group.elective",
permissions: [
{ key: "ELECTIVE_MANAGE", value: Permissions.ELECTIVE_MANAGE, labelKey: "rbac:permissions.elective.manage", descriptionKey: "rbac:permissions.elective.manage.desc" },
{ key: "ELECTIVE_READ", value: Permissions.ELECTIVE_READ, labelKey: "rbac:permissions.elective.read", descriptionKey: "rbac:permissions.elective.read.desc" },
{ key: "ELECTIVE_SELECT", value: Permissions.ELECTIVE_SELECT, labelKey: "rbac:permissions.elective.select", descriptionKey: "rbac:permissions.elective.select.desc" },
],
},
{
group: "diagnostic",
labelKey: "rbac:permissions.group.diagnostic",
permissions: [
{ key: "DIAGNOSTIC_MANAGE", value: Permissions.DIAGNOSTIC_MANAGE, labelKey: "rbac:permissions.diagnostic.manage", descriptionKey: "rbac:permissions.diagnostic.manage.desc" },
{ key: "DIAGNOSTIC_READ", value: Permissions.DIAGNOSTIC_READ, labelKey: "rbac:permissions.diagnostic.read", descriptionKey: "rbac:permissions.diagnostic.read.desc" },
],
},
{
group: "lesson_plan",
labelKey: "rbac:permissions.group.lesson_plan",
permissions: [
{ key: "LESSON_PLAN_CREATE", value: Permissions.LESSON_PLAN_CREATE, labelKey: "rbac:permissions.lesson_plan.create", descriptionKey: "rbac:permissions.lesson_plan.create.desc" },
{ key: "LESSON_PLAN_READ", value: Permissions.LESSON_PLAN_READ, labelKey: "rbac:permissions.lesson_plan.read", descriptionKey: "rbac:permissions.lesson_plan.read.desc" },
{ key: "LESSON_PLAN_UPDATE", value: Permissions.LESSON_PLAN_UPDATE, labelKey: "rbac:permissions.lesson_plan.update", descriptionKey: "rbac:permissions.lesson_plan.update.desc" },
{ key: "LESSON_PLAN_DELETE", value: Permissions.LESSON_PLAN_DELETE, labelKey: "rbac:permissions.lesson_plan.delete", descriptionKey: "rbac:permissions.lesson_plan.delete.desc" },
{ key: "LESSON_PLAN_PUBLISH", value: Permissions.LESSON_PLAN_PUBLISH, labelKey: "rbac:permissions.lesson_plan.publish", descriptionKey: "rbac:permissions.lesson_plan.publish.desc" },
],
},
{
group: "dashboard",
labelKey: "rbac:permissions.group.dashboard",
permissions: [
{ key: "DASHBOARD_ADMIN_READ", value: Permissions.DASHBOARD_ADMIN_READ, labelKey: "rbac:permissions.dashboard.admin_read", descriptionKey: "rbac:permissions.dashboard.admin_read.desc" },
{ key: "DASHBOARD_TEACHER_READ", value: Permissions.DASHBOARD_TEACHER_READ, labelKey: "rbac:permissions.dashboard.teacher_read", descriptionKey: "rbac:permissions.dashboard.teacher_read.desc" },
{ key: "DASHBOARD_STUDENT_READ", value: Permissions.DASHBOARD_STUDENT_READ, labelKey: "rbac:permissions.dashboard.student_read", descriptionKey: "rbac:permissions.dashboard.student_read.desc" },
{ key: "DASHBOARD_PARENT_READ", value: Permissions.DASHBOARD_PARENT_READ, labelKey: "rbac:permissions.dashboard.parent_read", descriptionKey: "rbac:permissions.dashboard.parent_read.desc" },
],
},
{
group: "error_book",
labelKey: "rbac:permissions.group.error_book",
permissions: [
{ key: "ERROR_BOOK_READ", value: Permissions.ERROR_BOOK_READ, labelKey: "rbac:permissions.error_book.read", descriptionKey: "rbac:permissions.error_book.read.desc" },
{ key: "ERROR_BOOK_MANAGE", value: Permissions.ERROR_BOOK_MANAGE, labelKey: "rbac:permissions.error_book.manage", descriptionKey: "rbac:permissions.error_book.manage.desc" },
{ key: "ERROR_BOOK_ANALYTICS_READ", value: Permissions.ERROR_BOOK_ANALYTICS_READ, labelKey: "rbac:permissions.error_book.analytics_read", descriptionKey: "rbac:permissions.error_book.analytics_read.desc" },
],
},
{
group: "adaptive_practice",
labelKey: "rbac:permissions.group.adaptive_practice",
permissions: [
{ key: "ADAPTIVE_PRACTICE_READ", value: Permissions.ADAPTIVE_PRACTICE_READ, labelKey: "rbac:permissions.adaptive_practice.read", descriptionKey: "rbac:permissions.adaptive_practice.read.desc" },
{ key: "ADAPTIVE_PRACTICE_MANAGE", value: Permissions.ADAPTIVE_PRACTICE_MANAGE, labelKey: "rbac:permissions.adaptive_practice.manage", descriptionKey: "rbac:permissions.adaptive_practice.manage.desc" },
],
},
{
group: "rbac",
labelKey: "rbac:permissions.group.rbac",
permissions: [
{ key: "ROLE_CREATE", value: Permissions.ROLE_CREATE, labelKey: "rbac:permissions.rbac.role_create", descriptionKey: "rbac:permissions.rbac.role_create.desc" },
{ key: "ROLE_READ", value: Permissions.ROLE_READ, labelKey: "rbac:permissions.rbac.role_read", descriptionKey: "rbac:permissions.rbac.role_read.desc" },
{ key: "ROLE_UPDATE", value: Permissions.ROLE_UPDATE, labelKey: "rbac:permissions.rbac.role_update", descriptionKey: "rbac:permissions.rbac.role_update.desc" },
{ key: "ROLE_DELETE", value: Permissions.ROLE_DELETE, labelKey: "rbac:permissions.rbac.role_delete", descriptionKey: "rbac:permissions.rbac.role_delete.desc" },
{ key: "ROLE_ASSIGN", value: Permissions.ROLE_ASSIGN, labelKey: "rbac:permissions.rbac.role_assign", descriptionKey: "rbac:permissions.rbac.role_assign.desc" },
{ key: "PERMISSION_READ", value: Permissions.PERMISSION_READ, labelKey: "rbac:permissions.rbac.permission_read", descriptionKey: "rbac:permissions.rbac.permission_read.desc" },
],
},
]
/** Flatten the catalog into a single list of all permission metadata. */
export function getAllPermissionMetas(): PermissionMeta[] {
return PERMISSION_CATALOG.flatMap((g) => g.permissions)
}
/** Look up the metadata for a single permission value. */
export function getPermissionMeta(value: Permission): PermissionMeta | undefined {
return getAllPermissionMetas().find((m) => m.value === value)
}

View File

@@ -0,0 +1,40 @@
/**
* Tracking interface for critical RBAC operations.
*
* This is a no-op stub that defines the contract for future analytics/audit
* instrumentation. Replace the implementation with a real tracker (e.g.
* PostHog, Mixpanel, or an internal event pipeline) when ready.
*/
/** The RBAC action being tracked. */
export type TrackPermissionAction =
| "set_role_permissions"
| "assign_user_roles"
/** Parameters for {@link trackPermissionChange}. */
export interface TrackPermissionChangeParams {
/** The kind of RBAC operation. */
action: TrackPermissionAction
/** Role id involved in the change (set for `set_role_permissions`). */
roleId?: string
/** User id involved in the change (set for `assign_user_roles`). */
userId?: string
/** State before the change (permission values or role names). */
before: string[]
/** State after the change (permission values or role names). */
after: string[]
}
/**
* Track a permission/role change.
*
* Currently a no-op (console.debug only). The interface is stable so call
* sites can be wired up without touching the actions layer again.
*/
export function trackPermissionChange(params: TrackPermissionChangeParams): void {
// Reserved for future analytics implementation.
// Intentionally minimal: avoid importing heavy SDKs until needed.
if (process.env.NODE_ENV !== "production") {
console.debug("[rbac:track]", params)
}
}

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { Permissions } from "@/shared/types/permissions"
/** All valid permission strings, used to validate the permission list on assignment. */
const ALL_PERMISSION_VALUES = Object.values(Permissions) as readonly string[]
export const CreateRoleSchema = z.object({
name: z
.string()
.min(2, "Role name must be at least 2 characters")
.max(50, "Role name must be at most 50 characters")
.regex(/^[a-z0-9_]+$/, "Role name must be lowercase letters, digits, and underscores only"),
description: z.string().max(255).optional().nullable(),
})
export const UpdateRoleSchema = z.object({
name: z
.string()
.min(2)
.max(50)
.regex(/^[a-z0-9_]+$/)
.optional(),
description: z.string().max(255).optional().nullable(),
})
export const SetRolePermissionsSchema = z.object({
roleId: z.string().min(1),
permissions: z
.array(z.string())
.refine(
(perms) => perms.every((p) => ALL_PERMISSION_VALUES.includes(p)),
{ message: "One or more permissions are invalid" }
),
})
export const AssignUserRolesSchema = z.object({
userId: z.string().min(1),
roleNames: z
.array(z.string().min(1))
.min(0, "Role list cannot be undefined (use empty array to clear)"),
})

57
src/modules/rbac/types.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Permission } from "@/shared/types/permissions"
/** A role row as stored in the `roles` table. */
export interface RoleRecord {
id: string
name: string
description: string | null
isSystem: boolean
isEnabled: boolean
createdAt: Date
updatedAt: Date
}
/** A role with its permission list and aggregate counts, for list/detail views. */
export interface RoleWithStats extends RoleRecord {
/** Number of permissions assigned to this role. */
permissionCount: number
/** Number of users assigned to this role. */
userCount: number
}
/** A role with its full permission list, for the detail/edit view. */
export interface RoleDetail extends RoleRecord {
permissions: Permission[]
/** Number of users assigned to this role (for impact notices). */
userCount: number
}
/** Input for creating a new role. */
export interface CreateRoleInput {
name: string
description?: string | null
}
/** Input for updating an existing role. `name` is ignored for the `admin` role. */
export interface UpdateRoleInput {
name?: string
description?: string | null
}
/** A user-role assignment row, for the assignment management view. */
export interface UserRoleAssignment {
userId: string
userName: string | null
userEmail: string
roleNames: string[]
createdAt: Date
}
/** Paginated result wrapper (mirrors the audit module's pattern). */
export interface PaginatedResult<T> {
items: T[]
page: number
pageSize: number
total: number
totalPages: number
}