Improve how user roles are managed and displayed on the admin page
Introduce separation of direct and inherited roles in user profiles and API responses. Modify the admin UI to disable the "order receiver" toggle when a role is inherited, providing a clearer user experience. Update API endpoints and schemas to reflect these changes, alongside locale updates for translated strings. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 33aee19f-8f0f-4399-98e0-39fe09a87e1b Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/m92e9kU Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -190,6 +190,20 @@ export async function getUserRoles(userId: number): Promise<string[]> {
|
||||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||||
}
|
||||
|
||||
// Roles assigned directly to the user (user_roles table only), excluding any
|
||||
// roles inherited via group membership. The admin Users page contrasts this
|
||||
// with the effective set so a toggle for an inherited-only role can be
|
||||
// disabled — otherwise removing the direct row (which doesn't exist) is a
|
||||
// no-op and the Switch silently flips back on after refetch.
|
||||
export async function getDirectUserRoles(userId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(eq(userRolesTable.userId, userId));
|
||||
return Array.from(new Set(rows.map((r) => r.roleName)));
|
||||
}
|
||||
|
||||
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
userRolesTable,
|
||||
rolesTable,
|
||||
groupsTable,
|
||||
groupRolesTable,
|
||||
userGroupsTable,
|
||||
notesTable,
|
||||
serviceOrdersTable,
|
||||
servicesTable,
|
||||
auditLogsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import { requireAuth, requireAdmin, getUserRoles, getDirectUserRoles } from "../middlewares/auth";
|
||||
import { emitAppsChangedToUsers } from "../lib/realtime";
|
||||
import {
|
||||
listPermissionAudit,
|
||||
@@ -77,6 +78,7 @@ type DependencyCounts = {
|
||||
function buildUserProfile(
|
||||
user: typeof usersTable.$inferSelect,
|
||||
roles: string[],
|
||||
directRoles: string[],
|
||||
groups: GroupSummary[],
|
||||
counts?: DependencyCounts,
|
||||
) {
|
||||
@@ -92,6 +94,7 @@ function buildUserProfile(
|
||||
avatarUrl: user.avatarUrl,
|
||||
isActive: user.isActive,
|
||||
roles,
|
||||
directRoles,
|
||||
groups,
|
||||
createdAt: user.createdAt,
|
||||
...(counts ?? {}),
|
||||
@@ -174,7 +177,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
});
|
||||
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.status(201).json(buildUserProfile(user, [], groups));
|
||||
res.status(201).json(buildUserProfile(user, [], [], groups));
|
||||
});
|
||||
|
||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
@@ -232,17 +235,38 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
|
||||
const userIds = users.map((u) => u.id);
|
||||
|
||||
// Batch load roles
|
||||
const roleRows = await db
|
||||
// Batch load direct roles (user_roles table only).
|
||||
const directRoleRows = await db
|
||||
.select({ userId: userRolesTable.userId, roleName: rolesTable.name })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id))
|
||||
.where(inArray(userRolesTable.userId, userIds));
|
||||
const rolesByUser = new Map<number, string[]>();
|
||||
for (const r of roleRows) {
|
||||
const list = rolesByUser.get(r.userId) ?? [];
|
||||
const directRolesByUser = new Map<number, string[]>();
|
||||
for (const r of directRoleRows) {
|
||||
const list = directRolesByUser.get(r.userId) ?? [];
|
||||
list.push(r.roleName);
|
||||
rolesByUser.set(r.userId, list);
|
||||
directRolesByUser.set(r.userId, list);
|
||||
}
|
||||
|
||||
// Batch load roles inherited via group membership so the effective `roles`
|
||||
// field matches GET /users/:id and the auth middleware. Without this the
|
||||
// admin Users list shows direct-only roles while the actual permission
|
||||
// check uses the effective set — confusing when a user receives orders
|
||||
// via a group but the row in the list shows no order_receiver role.
|
||||
const inheritedRoleRows = await db
|
||||
.select({ userId: userGroupsTable.userId, roleName: rolesTable.name })
|
||||
.from(userGroupsTable)
|
||||
.innerJoin(groupRolesTable, eq(groupRolesTable.groupId, userGroupsTable.groupId))
|
||||
.innerJoin(rolesTable, eq(groupRolesTable.roleId, rolesTable.id))
|
||||
.where(inArray(userGroupsTable.userId, userIds));
|
||||
const rolesByUser = new Map<number, Set<string>>();
|
||||
for (const [uid, names] of directRolesByUser) {
|
||||
rolesByUser.set(uid, new Set(names));
|
||||
}
|
||||
for (const r of inheritedRoleRows) {
|
||||
const set = rolesByUser.get(r.userId) ?? new Set<string>();
|
||||
set.add(r.roleName);
|
||||
rolesByUser.set(r.userId, set);
|
||||
}
|
||||
|
||||
// Batch load groups
|
||||
@@ -297,7 +321,8 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
users.map((u) =>
|
||||
buildUserProfile(
|
||||
u,
|
||||
rolesByUser.get(u.id) ?? [],
|
||||
Array.from(rolesByUser.get(u.id) ?? []),
|
||||
directRolesByUser.get(u.id) ?? [],
|
||||
groupsByUser.get(u.id) ?? [],
|
||||
{
|
||||
noteCount: noteMap.get(u.id) ?? 0,
|
||||
@@ -475,8 +500,9 @@ router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
const directRoles = await getDirectUserRoles(user.id);
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.json(buildUserProfile(user, roles, groups));
|
||||
res.json(buildUserProfile(user, roles, directRoles, groups));
|
||||
});
|
||||
|
||||
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
@@ -584,8 +610,9 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
const directRoles = await getDirectUserRoles(user.id);
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.json(buildUserProfile(user, roles, groups));
|
||||
res.json(buildUserProfile(user, roles, directRoles, groups));
|
||||
});
|
||||
|
||||
router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> => {
|
||||
@@ -647,8 +674,9 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
const directRoles = await getDirectUserRoles(user.id);
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.json(buildUserProfile(user, roles, groups));
|
||||
res.json(buildUserProfile(user, roles, directRoles, groups));
|
||||
});
|
||||
|
||||
router.delete(
|
||||
@@ -715,8 +743,9 @@ router.delete(
|
||||
}
|
||||
|
||||
const roles = await getUserRoles(user.id);
|
||||
const directRoles = await getDirectUserRoles(user.id);
|
||||
const groups = await getGroupsForUser(user.id);
|
||||
res.json(buildUserProfile(user, roles, groups));
|
||||
res.json(buildUserProfile(user, roles, directRoles, groups));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user