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:
@@ -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> {
|
export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
|
||||||
const unique = Array.from(new Set(userIds.filter((id) => Number.isInteger(id))));
|
const unique = Array.from(new Set(userIds.filter((id) => Number.isInteger(id))));
|
||||||
if (unique.length === 0) return;
|
if (unique.length === 0) return;
|
||||||
@@ -6,3 +14,36 @@ export async function emitAppsChangedToUsers(userIds: number[]): Promise<void> {
|
|||||||
io.to(`user:${userId}`).emit("apps_changed");
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ router.get("/apps", requireAuth, async (req, res): Promise<void> => {
|
|||||||
res.json(visible);
|
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> => {
|
router.put("/me/app-order", requireAuth, async (req, res): Promise<void> => {
|
||||||
const userId = req.session.userId!;
|
const userId = req.session.userId!;
|
||||||
const parsed = UpdateMyAppOrderBody.safeParse(req.body);
|
const parsed = UpdateMyAppOrderBody.safeParse(req.body);
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
userRolesTable,
|
userRolesTable,
|
||||||
groupRolesTable,
|
groupRolesTable,
|
||||||
rolePermissionsTable,
|
rolePermissionsTable,
|
||||||
|
permissionsTable,
|
||||||
} from "@workspace/db";
|
} from "@workspace/db";
|
||||||
import { requireAdmin } from "../middlewares/auth";
|
import { requireAdmin } from "../middlewares/auth";
|
||||||
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
|
import { CreateRoleBody, UpdateRoleBody } from "@workspace/api-zod";
|
||||||
|
import { emitAppsChangedToRoleHolders } from "../lib/realtime.js";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -125,4 +127,69 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
res.sendStatus(204);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -381,10 +381,14 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
const inserted = await db
|
||||||
.insert(userRolesTable)
|
.insert(userRolesTable)
|
||||||
.values({ userId: user.id, roleId: role.id })
|
.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 roles = await getUserRoles(user.id);
|
||||||
const groups = await getGroupsForUser(user.id);
|
const groups = await getGroupsForUser(user.id);
|
||||||
@@ -421,14 +425,18 @@ router.delete(
|
|||||||
.where(eq(rolesTable.name, roleName));
|
.where(eq(rolesTable.name, roleName));
|
||||||
|
|
||||||
if (role) {
|
if (role) {
|
||||||
await db
|
const deleted = await db
|
||||||
.delete(userRolesTable)
|
.delete(userRolesTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userRolesTable.userId, user.id),
|
eq(userRolesTable.userId, user.id),
|
||||||
eq(userRolesTable.roleId, role.id),
|
eq(userRolesTable.roleId, role.id),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.returning();
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
await emitAppsChangedToUsers([user.id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await getUserRoles(user.id);
|
const roles = await getUserRoles(user.id);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
useListApps,
|
|
||||||
getListAppsQueryKey,
|
|
||||||
useCreateApp,
|
useCreateApp,
|
||||||
useUpdateApp,
|
useUpdateApp,
|
||||||
useDeleteApp,
|
useDeleteApp,
|
||||||
@@ -60,7 +58,7 @@ import type {
|
|||||||
ListUsersParams,
|
ListUsersParams,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||||
@@ -75,6 +73,14 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
|
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
|
||||||
|
|
||||||
|
const ADMIN_APPS_KEY = ["/api/admin/apps"] as const;
|
||||||
|
|
||||||
|
async function fetchAdminApps(): Promise<App[]> {
|
||||||
|
const res = await fetch("/api/admin/apps", { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch admin apps");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings";
|
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "settings";
|
||||||
|
|
||||||
function LeaderboardAvatar({
|
function LeaderboardAvatar({
|
||||||
@@ -145,7 +151,7 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
}, [user, isAdmin, setLocation]);
|
}, [user, isAdmin, setLocation]);
|
||||||
|
|
||||||
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
|
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps, enabled: isAdmin });
|
||||||
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
||||||
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
|
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
|
||||||
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||||
@@ -375,7 +381,7 @@ export default function AdminPage() {
|
|||||||
{ id: editingApp.id, data: editingApp.form },
|
{ id: editingApp.id, data: editingApp.form },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
|
||||||
setEditingApp(null);
|
setEditingApp(null);
|
||||||
toast({ title: t("common.success") });
|
toast({ title: t("common.success") });
|
||||||
},
|
},
|
||||||
@@ -386,7 +392,7 @@ export default function AdminPage() {
|
|||||||
{ data: editingApp.form },
|
{ data: editingApp.form },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
|
||||||
setEditingApp(null);
|
setEditingApp(null);
|
||||||
toast({ title: t("common.success") });
|
toast({ title: t("common.success") });
|
||||||
},
|
},
|
||||||
@@ -642,19 +648,19 @@ export default function AdminPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{apps?.map((app) => (
|
{apps?.map((app) => (
|
||||||
<div key={app.id} className={cn("glass-panel rounded-2xl p-4 flex items-center justify-between gap-3", !app.isActive && "opacity-60")}>
|
<div key={app.id} className={cn("rounded-2xl p-4 flex items-center justify-between gap-3 border", app.isActive ? "glass-panel border-transparent" : "bg-slate-100/80 border-slate-200")}>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-lg shrink-0"
|
className={cn("w-8 h-8 rounded-lg shrink-0", !app.isActive && "grayscale")}
|
||||||
style={{ backgroundColor: `${app.color}40` }}
|
style={{ backgroundColor: `${app.color}40` }}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-sm text-foreground truncate">
|
<div className={cn("font-medium text-sm truncate", app.isActive ? "text-foreground" : "text-slate-400")}>
|
||||||
{lang === "ar" ? app.nameAr : app.nameEn}
|
{lang === "ar" ? app.nameAr : app.nameEn}
|
||||||
</div>
|
</div>
|
||||||
{!app.isActive && (
|
{!app.isActive && (
|
||||||
<span className="text-[10px] font-medium bg-slate-200 text-slate-500 rounded px-1.5 py-0.5 shrink-0">
|
<span className="text-[10px] font-medium bg-slate-300 text-slate-600 rounded px-1.5 py-0.5 shrink-0">
|
||||||
{t("admin.appDisabled")}
|
{t("admin.appDisabled")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -668,7 +674,7 @@ export default function AdminPage() {
|
|||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
updateApp.mutate(
|
updateApp.mutate(
|
||||||
{ id: app.id, data: { isActive: v } },
|
{ id: app.id, data: { isActive: v } },
|
||||||
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }) },
|
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -694,7 +700,7 @@ export default function AdminPage() {
|
|||||||
if (confirm(t("admin.deleteConfirm"))) {
|
if (confirm(t("admin.deleteConfirm"))) {
|
||||||
deleteApp.mutate(
|
deleteApp.mutate(
|
||||||
{ id: app.id },
|
{ id: app.id },
|
||||||
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }) },
|
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2671,7 +2677,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
|||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } });
|
const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } });
|
||||||
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
|
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps });
|
||||||
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
|
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
|
||||||
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
|
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
|
||||||
const updateGroup = useUpdateGroup();
|
const updateGroup = useUpdateGroup();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
Reference in New Issue
Block a user