diff --git a/artifacts/api-server/src/lib/realtime.ts b/artifacts/api-server/src/lib/realtime.ts index b65662ec..d1705902 100644 --- a/artifacts/api-server/src/lib/realtime.ts +++ b/artifacts/api-server/src/lib/realtime.ts @@ -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 { 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 { 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 { + 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); +} diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 9890bf52..d95d6154 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -116,6 +116,14 @@ router.get("/apps", requireAuth, async (req, res): Promise => { res.json(visible); }); +router.get("/admin/apps", requireAdmin, async (_req, res): Promise => { + 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 => { const userId = req.session.userId!; const parsed = UpdateMyAppOrderBody.safeParse(req.body); diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index 6a6a1e25..6fab3d68 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -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 => { res.sendStatus(204); }); +router.get("/roles/:id/permissions", requireAdmin, async (req, res): Promise => { + 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 => { + 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 => { + 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; diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index bb93870e..1296f594 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -381,10 +381,14 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise => 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); diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 65c578bb..61c786f7 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -2,8 +2,6 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { - useListApps, - getListAppsQueryKey, useCreateApp, useUpdateApp, useDeleteApp, @@ -60,7 +58,7 @@ import type { ListUsersParams, } 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 { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { resolveServiceImageUrl } from "@/lib/image-url"; @@ -75,6 +73,14 @@ import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format"; +const ADMIN_APPS_KEY = ["/api/admin/apps"] as const; + +async function fetchAdminApps(): Promise { + 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"; function LeaderboardAvatar({ @@ -145,7 +151,7 @@ export default function AdminPage() { } }, [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: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } }); const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } }); @@ -375,7 +381,7 @@ export default function AdminPage() { { id: editingApp.id, data: editingApp.form }, { onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); + queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); setEditingApp(null); toast({ title: t("common.success") }); }, @@ -386,7 +392,7 @@ export default function AdminPage() { { data: editingApp.form }, { onSuccess: () => { - queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); + queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); setEditingApp(null); toast({ title: t("common.success") }); }, @@ -642,19 +648,19 @@ export default function AdminPage() { {apps?.map((app) => ( -
+
-
+
{lang === "ar" ? app.nameAr : app.nameEn}
{!app.isActive && ( - + {t("admin.appDisabled")} )} @@ -668,7 +674,7 @@ export default function AdminPage() { onCheckedChange={(v) => { updateApp.mutate( { 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"))) { deleteApp.mutate( { 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 { toast } = useToast(); 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: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } }); const updateGroup = useUpdateGroup();