- Add RBAC module with data-access, schema, types, and components for role and permission management
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
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),
|
|
}
|
|
}
|