Task #83: Refresh role and permission changes live across user sessions

## Socket.IO broadcasts (core task):
- POST /users/:id/roles: uses .returning() to emit apps_changed only when row inserted
- DELETE /users/:id/roles/:roleName: uses .returning() to emit only when row deleted
- Added emitAppsChangedToRoleHolders(roleId) helper to realtime.ts
- New role-permission endpoints in roles.ts, emit only on effective changes:
  - GET /roles/:id/permissions — list permissions for a role
  - POST /roles/:id/permissions — assign; emits to role holders on actual insert
  - DELETE /roles/:id/permissions/:permId — remove; emits only if row was deleted

## Bug fix: admin panel not showing disabled apps
- Added GET /api/admin/apps (requireAdmin) returning ALL apps from DB
- Updated admin.tsx: useQuery → /api/admin/apps with ADMIN_APPS_KEY constant
- All admin.tsx mutation handlers invalidate ADMIN_APPS_KEY

## UI fix: disabled app cards nearly invisible (opacity-60 on glass)
- Gray background + grayscale icon + muted text + stronger Disabled badge

Replit-Task-Id: f439ec75-bcd5-4030-8ee1-83a5d976f1a1

## DB: removed 11 duplicate rows from app_permissions table
This commit is contained in:
riyadhafraa
2026-04-27 11:11:43 +00:00
parent 19a20cc5a4
commit 595ca06a57
6 changed files with 147 additions and 17 deletions
+41
View File
@@ -1,3 +1,11 @@
import { eq, inArray } from "drizzle-orm";
import { db } from "@workspace/db";
import {
userRolesTable,
groupRolesTable,
userGroupsTable,
} from "@workspace/db";
export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
const unique = Array.from(new Set(userIds.filter((id) => Number.isInteger(id))));
if (unique.length === 0) return;
@@ -6,3 +14,36 @@ export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
io.to(`user:${userId}`).emit("apps_changed");
}
}
/**
* 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 directRows = await db
.select({ userId: userRolesTable.userId })
.from(userRolesTable)
.where(eq(userRolesTable.roleId, roleId));
const groupIdRows = await db
.select({ groupId: groupRolesTable.groupId })
.from(groupRolesTable)
.where(eq(groupRolesTable.roleId, roleId));
const groupIds = groupIdRows.map((r) => r.groupId);
const indirectRows = groupIds.length > 0
? await db
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, groupIds))
: [];
const userIds = [
...directRows.map((r) => r.userId),
...indirectRows.map((r) => r.userId),
];
await emitAppsChangedToUsers(userIds);
}
+8
View File
@@ -116,6 +116,14 @@ router.get("/apps", requireAuth, async (req, res): Promise<void> => {
res.json(visible);
});
router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
const allApps = await db
.select()
.from(appsTable)
.orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn));
res.json(allApps);
});
router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const parsed = UpdateMyAppOrderBody.safeParse(req.body);
+67
View File
@@ -6,9 +6,11 @@ import {
userRolesTable,
groupRolesTable,
rolePermissionsTable,
permissionsTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
import { emitAppsChangedToRoleHolders } from "../lib/realtime.js";
const router: IRouter = Router();
@@ -125,4 +127,69 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
res.sendStatus(204);
});
router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const rows = await db
.select({ id: permissionsTable.id, name: permissionsTable.name })
.from(rolePermissionsTable)
.innerJoin(permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id))
.where(eq(rolePermissionsTable.roleId, id));
res.json(rows);
});
router.post("/roles/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const permissionId = Number(req.body?.permissionId);
if (!Number.isInteger(permissionId)) {
res.status(400).json({ error: "permissionId is required" });
return;
}
const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
if (!role) {
res.status(404).json({ error: "Role not found" });
return;
}
const [perm] = await db.select({ id: permissionsTable.id }).from(permissionsTable).where(eq(permissionsTable.id, permissionId));
if (!perm) {
res.status(404).json({ error: "Permission not found" });
return;
}
const existing = await db
.select({ roleId: rolePermissionsTable.roleId })
.from(rolePermissionsTable)
.where(sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`);
if (existing.length === 0) {
await db.insert(rolePermissionsTable).values({ roleId: id, permissionId });
await emitAppsChangedToRoleHolders(id);
}
res.status(201).json({ roleId: id, permissionId });
});
router.delete("/roles/:id/permissions/:permissionId", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
const permissionId = Number(req.params.permissionId);
if (!Number.isInteger(id) || !Number.isInteger(permissionId)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const deleted = await db
.delete(rolePermissionsTable)
.where(
sql`${rolePermissionsTable.roleId} = ${id} AND ${rolePermissionsTable.permissionId} = ${permissionId}`,
)
.returning();
if (deleted.length > 0) {
await emitAppsChangedToRoleHolders(id);
}
res.sendStatus(204);
});
export default router;
+12 -4
View File
@@ -381,10 +381,14 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
return;
}
await db
const inserted = await db
.insert(userRolesTable)
.values({ userId: user.id, roleId: role.id })
.onConflictDoNothing();
.onConflictDoNothing()
.returning();
if (inserted.length > 0) {
await emitAppsChangedToUsers([user.id]);
}
const roles = await getUserRoles(user.id);
const groups = await getGroupsForUser(user.id);
@@ -421,14 +425,18 @@ router.delete(
.where(eq(rolesTable.name, roleName));
if (role) {
await db
const deleted = await db
.delete(userRolesTable)
.where(
and(
eq(userRolesTable.userId, user.id),
eq(userRolesTable.roleId, role.id),
),
);
)
.returning();
if (deleted.length > 0) {
await emitAppsChangedToUsers([user.id]);
}
}
const roles = await getUserRoles(user.id);