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:
398
src/modules/rbac/actions.ts
Normal file
398
src/modules/rbac/actions.ts
Normal 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
|
||||
}
|
||||
27
src/modules/rbac/components/error-boundary.tsx
Normal file
27
src/modules/rbac/components/error-boundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/modules/rbac/components/permission-catalog-view.tsx
Normal file
83
src/modules/rbac/components/permission-catalog-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/modules/rbac/components/role-detail-edit-button.tsx
Normal file
32
src/modules/rbac/components/role-detail-edit-button.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
138
src/modules/rbac/components/role-form-dialog.tsx
Normal file
138
src/modules/rbac/components/role-form-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
src/modules/rbac/components/role-list.tsx
Normal file
239
src/modules/rbac/components/role-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
src/modules/rbac/components/role-management-view.tsx
Normal file
30
src/modules/rbac/components/role-management-view.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
302
src/modules/rbac/components/role-permission-matrix.tsx
Normal file
302
src/modules/rbac/components/role-permission-matrix.tsx
Normal 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
|
||||
}
|
||||
186
src/modules/rbac/components/user-role-assign-dialog.tsx
Normal file
186
src/modules/rbac/components/user-role-assign-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/modules/rbac/data-access-assignments.ts
Normal file
172
src/modules/rbac/data-access-assignments.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
36
src/modules/rbac/data-access-permissions.ts
Normal file
36
src/modules/rbac/data-access-permissions.ts
Normal 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
|
||||
})
|
||||
227
src/modules/rbac/data-access.ts
Normal file
227
src/modules/rbac/data-access.ts
Normal 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 }))
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
26
src/modules/rbac/lib/data-scope-config.ts
Normal file
26
src/modules/rbac/lib/data-scope-config.ts
Normal 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>
|
||||
}
|
||||
110
src/modules/rbac/lib/data-scope-resolver.ts
Normal file
110
src/modules/rbac/lib/data-scope-resolver.ts
Normal 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 }
|
||||
}
|
||||
271
src/modules/rbac/lib/permission-catalog.ts
Normal file
271
src/modules/rbac/lib/permission-catalog.ts
Normal 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)
|
||||
}
|
||||
40
src/modules/rbac/lib/track.ts
Normal file
40
src/modules/rbac/lib/track.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
42
src/modules/rbac/schema.ts
Normal file
42
src/modules/rbac/schema.ts
Normal 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
57
src/modules/rbac/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user