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:
riyadhafraa
2026-05-19 06:41:10 +00:00
parent f63a48fe1f
commit e00e015b8b
8 changed files with 145 additions and 27 deletions
@@ -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",
+42 -13
View File
@@ -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));
},
);
+1
View File
@@ -521,6 +521,7 @@
"active": "نشط",
"issueResetLink": "إصدار رابط إعادة تعيين كلمة المرور",
"orderReceiverRole": "مستلم الطلبات",
"roleInheritedFromGroup": "موروث من المجموعة",
"resetLinkModal": {
"title": "رابط إعادة تعيين كلمة المرور للمستخدم {{username}}",
"description": "شارك هذا الرابط لمرة واحدة مع المستخدم. يمكن استخدامه مرة واحدة فقط.",
+1
View File
@@ -497,6 +497,7 @@
"active": "Active",
"issueResetLink": "Issue password reset link",
"orderReceiverRole": "Order Receiver",
"roleInheritedFromGroup": "Inherited from group",
"resetLinkModal": {
"title": "Password reset link for {{username}}",
"description": "Share this one-time link with the user. It can only be used once.",
+40 -14
View File
@@ -4355,20 +4355,46 @@ function UsersPanel({
>
<KeyRound size={14} />
</button>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground select-none">
<span className="hidden lg:inline">{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: invalidateUsers,
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
};
if (v) addUserRole.mutate({ id: u.id, data: { roleName: "order_receiver" } }, opts);
else removeUserRole.mutate({ id: u.id, roleName: "order_receiver" }, opts);
}}
/>
</label>
{(() => {
// The Switch toggles the DIRECT user_roles row only. If
// the role is inherited via a group (present in `roles`
// but not in `directRoles`), removing the direct row is a
// no-op and the Switch would flip back on after refetch.
// Disable it and show which group grants the role so the
// admin edits group membership instead.
const hasEffective = u.roles?.includes("order_receiver") ?? false;
const hasDirect = u.directRoles?.includes("order_receiver") ?? false;
const isInherited = hasEffective && !hasDirect;
return (
<label
className="flex items-center gap-1 text-[10px] text-muted-foreground select-none"
title={isInherited ? t("admin.roleInheritedFromGroup") : undefined}
>
<span className="hidden lg:inline">{t("admin.orderReceiverRole")}</span>
<Switch
checked={hasEffective}
disabled={isInherited}
onCheckedChange={(v) => {
const opts = {
onSuccess: invalidateUsers,
onError: () =>
toast({ title: t("common.error"), variant: "destructive" }),
};
if (v)
addUserRole.mutate(
{ id: u.id, data: { roleName: "order_receiver" } },
opts,
);
else
removeUserRole.mutate(
{ id: u.id, roleName: "order_receiver" },
opts,
);
}}
/>
</label>
);
})()}
{u.id !== currentUserId && (
<button
onClick={() => {
@@ -218,6 +218,13 @@ export interface UserProfile {
avatarUrl?: string | null;
isActive: boolean;
roles: string[];
/** Roles assigned directly to the user (user_roles table only). The
`roles` field above is the effective set (direct + inherited via
group membership). The admin Users page uses the difference to
mark a role as "inherited from group X" and disables the toggle
so the admin doesn't try to remove a role that won't disappear.
*/
directRoles?: string[];
groups: GroupSummary[];
createdAt: string;
/** Dependent record counts. Populated only by the admin list endpoint
+10
View File
@@ -3291,6 +3291,16 @@ components:
type: array
items:
type: string
directRoles:
type: array
items:
type: string
description: |
Roles assigned directly to the user (user_roles table only). The
`roles` field above is the effective set (direct + inherited via
group membership). The admin Users page uses the difference to
mark a role as "inherited from group X" and disables the toggle
so the admin doesn't try to remove a role that won't disappear.
groups:
type: array
items:
+30
View File
@@ -1577,6 +1577,12 @@ export const ListUsersResponseItem = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
directRoles: zod
.array(zod.string())
.optional()
.describe(
"Roles assigned directly to the user (user_roles table only). The\n`roles` field above is the effective set (direct + inherited via\ngroup membership). The admin Users page uses the difference to\nmark a role as \"inherited from group X\" and disables the toggle\nso the admin doesn't try to remove a role that won't disappear.\n",
),
groups: zod.array(
zod.object({
id: zod.number(),
@@ -1649,6 +1655,12 @@ export const GetUserResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
directRoles: zod
.array(zod.string())
.optional()
.describe(
"Roles assigned directly to the user (user_roles table only). The\n`roles` field above is the effective set (direct + inherited via\ngroup membership). The admin Users page uses the difference to\nmark a role as \"inherited from group X\" and disables the toggle\nso the admin doesn't try to remove a role that won't disappear.\n",
),
groups: zod.array(
zod.object({
id: zod.number(),
@@ -1709,6 +1721,12 @@ export const UpdateUserResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
directRoles: zod
.array(zod.string())
.optional()
.describe(
"Roles assigned directly to the user (user_roles table only). The\n`roles` field above is the effective set (direct + inherited via\ngroup membership). The admin Users page uses the difference to\nmark a role as \"inherited from group X\" and disables the toggle\nso the admin doesn't try to remove a role that won't disappear.\n",
),
groups: zod.array(
zod.object({
id: zod.number(),
@@ -1822,6 +1840,12 @@ export const AddUserRoleResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
directRoles: zod
.array(zod.string())
.optional()
.describe(
"Roles assigned directly to the user (user_roles table only). The\n`roles` field above is the effective set (direct + inherited via\ngroup membership). The admin Users page uses the difference to\nmark a role as \"inherited from group X\" and disables the toggle\nso the admin doesn't try to remove a role that won't disappear.\n",
),
groups: zod.array(
zod.object({
id: zod.number(),
@@ -1872,6 +1896,12 @@ export const RemoveUserRoleResponse = zod.object({
avatarUrl: zod.string().nullish(),
isActive: zod.boolean(),
roles: zod.array(zod.string()),
directRoles: zod
.array(zod.string())
.optional()
.describe(
"Roles assigned directly to the user (user_roles table only). The\n`roles` field above is the effective set (direct + inherited via\ngroup membership). The admin Users page uses the difference to\nmark a role as \"inherited from group X\" and disables the toggle\nso the admin doesn't try to remove a role that won't disappear.\n",
),
groups: zod.array(
zod.object({
id: zod.number(),