Push role permission changes to signed-in users in real time
Original task (#101): When an admin saves the role editor, role holders need their permission-driven UI (admin nav items, permission-gated action buttons, etc.) to refresh without waiting for a page reload. The server already emitted `apps_changed`, but that event is semantically about visible apps, not the broader cached permission state, and the client only invalidated the apps list and `/api/auth/me`. Changes: - artifacts/api-server/src/lib/realtime.ts - Extracted role-holder resolution (direct user_roles + indirect via group_roles -> user_groups) into a private helper. - Added `emitRolePermissionsChangedToHolders(roleId)` that emits a new `role_permissions_changed` socket event with `{ roleId }` to every holder of the role. - artifacts/api-server/src/routes/roles.ts - After `PUT /api/roles/:id/permissions` succeeds, call the new emitter alongside the existing `emitAppsChangedToRoleHolders`. - artifacts/tx-os/src/hooks/use-notifications-socket.ts - Subscribed to `role_permissions_changed`. Invalidates `getMe`, `listRoles`, `listPermissions`, and—when the payload includes the role id—`getRolePermissions`, the role permission audit, and role usage queries so admin-only UI re-evaluates without a refresh. Scope notes / deviations: - The task spec called out `PUT /api/roles/:id/permissions` only, so the matching POST/DELETE single-permission endpoints still emit only `apps_changed`. Filed as follow-up #150 to keep them consistent. - No new automated tests added (filed as follow-up #151). Pre-existing typecheck errors in artifacts/api-server/src/routes/executive-meetings.ts are unrelated and untouched. - `replit.md` not updated; this is a behavioral refinement of an existing socket flow, not an architectural change. Replit-Task-Id: bd31a9a9-dbf1-497a-8505-96672f92bc79
This commit is contained in:
@@ -15,14 +15,7 @@ export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every user that currently carries `roleId`, either directly via
|
||||
* `user_roles` or indirectly via `group_roles` -> `user_groups`, and emit
|
||||
* `apps_changed` to each of them.
|
||||
*/
|
||||
export async function emitAppsChangedToRoleHolders(roleId: number): Promise<void> {
|
||||
if (!Number.isInteger(roleId)) return;
|
||||
|
||||
async function getRoleHolderUserIds(roleId: number): Promise<number[]> {
|
||||
const directRows = await db
|
||||
.select({ userId: userRolesTable.userId })
|
||||
.from(userRolesTable)
|
||||
@@ -41,9 +34,38 @@ export async function emitAppsChangedToRoleHolders(roleId: number): Promise<void
|
||||
.where(inArray(userGroupsTable.groupId, groupIds))
|
||||
: [];
|
||||
|
||||
const userIds = [
|
||||
return [
|
||||
...directRows.map((r) => r.userId),
|
||||
...indirectRows.map((r) => r.userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every user that currently carries `roleId`, either directly via
|
||||
* `user_roles` or indirectly via `group_roles` -> `user_groups`, and emit
|
||||
* `apps_changed` to each of them.
|
||||
*/
|
||||
export async function emitAppsChangedToRoleHolders(roleId: number): Promise<void> {
|
||||
if (!Number.isInteger(roleId)) return;
|
||||
const userIds = await getRoleHolderUserIds(roleId);
|
||||
await emitAppsChangedToUsers(userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify every holder of `roleId` that the role's permission set has been
|
||||
* updated, so the client can invalidate cached `/api/auth/me` and
|
||||
* role/permission queries and re-evaluate permission-driven UI without a
|
||||
* page refresh.
|
||||
*/
|
||||
export async function emitRolePermissionsChangedToHolders(
|
||||
roleId: number,
|
||||
): Promise<void> {
|
||||
if (!Number.isInteger(roleId)) return;
|
||||
const userIds = await getRoleHolderUserIds(roleId);
|
||||
const unique = Array.from(new Set(userIds.filter((id) => Number.isInteger(id))));
|
||||
if (unique.length === 0) return;
|
||||
const { io } = await import("../index.js");
|
||||
for (const userId of unique) {
|
||||
io.to(`user:${userId}`).emit("role_permissions_changed", { roleId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
UpdateRoleBody,
|
||||
ReplaceRolePermissionsBody,
|
||||
} from "@workspace/api-zod";
|
||||
import { emitAppsChangedToRoleHolders } from "../lib/realtime.js";
|
||||
import {
|
||||
emitAppsChangedToRoleHolders,
|
||||
emitRolePermissionsChangedToHolders,
|
||||
} from "../lib/realtime.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -566,6 +569,7 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise<voi
|
||||
}
|
||||
});
|
||||
await emitAppsChangedToRoleHolders(id);
|
||||
await emitRolePermissionsChangedToHolders(id);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: permissionsTable.id,
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
getListIncomingServiceOrdersQueryKey,
|
||||
getListAppsQueryKey,
|
||||
getGetMeQueryKey,
|
||||
getListRolesQueryKey,
|
||||
getListPermissionsQueryKey,
|
||||
getGetRolePermissionsQueryKey,
|
||||
getGetRolePermissionAuditQueryKey,
|
||||
getGetRoleUsageQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
@@ -66,6 +71,27 @@ export function useNotificationsSocket() {
|
||||
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"role_permissions_changed",
|
||||
(payload?: { roleId?: number }) => {
|
||||
queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListPermissionsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
|
||||
const roleId = payload?.roleId;
|
||||
if (typeof roleId === "number") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetRolePermissionsQueryKey(roleId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetRolePermissionAuditQueryKey(roleId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetRoleUsageQueryKey(roleId),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user