import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useCreateApp, useUpdateApp, useDeleteApp, useListAppPermissions, getListAppPermissionsQueryKey, useAddAppPermission, useRemoveAppPermission, usePreviewAppPermissionsImpact, useListServices, getListServicesQueryKey, useCreateService, useUpdateService, useDeleteService, useListUsers, getListUsersQueryKey, useCreateUser, useUpdateUser, useDeleteUser, useAddUserRole, useRemoveUserRole, useGetAppSettings, getGetAppSettingsQueryKey, useUpdateAppSettings, useGetAdminStats, getGetAdminStatsQueryKey, useGetAdminAppOpensByApp, getGetAdminAppOpensByAppQueryKey, getAdminAppOpensByApp, getRolePermissionAudit, useGetAdminAppOpensByUser, getGetAdminAppOpensByUserQueryKey, getAdminAppOpensByUser, useGetAdminAppDependentGroups, getGetAdminAppDependentGroupsQueryKey, useGetAdminAppDependentRestrictions, getGetAdminAppDependentRestrictionsQueryKey, useGetAdminAppDependentOpens, getGetAdminAppDependentOpensQueryKey, useGetAdminServiceDependentOrders, getGetAdminServiceDependentOrdersQueryKey, useGetAdminUserDependentNotes, getGetAdminUserDependentNotesQueryKey, useGetAdminUserDependentOrders, getGetAdminUserDependentOrdersQueryKey, getAdminAppDependentGroups, getAdminAppDependentRestrictions, getAdminAppDependentOpens, getAdminServiceDependentOrders, getAdminUserDependentNotes, getAdminUserDependentOrders, useAdminIssueResetLink, useListGroups, getListGroupsQueryKey, useGetGroup, getGetGroupQueryKey, useCreateGroup, useUpdateGroup, useDeleteGroup, useListRoles, getListRolesQueryKey, useCreateRole, useUpdateRole, useDeleteRole, useListPermissions, getListPermissionsQueryKey, useGetRolePermissions, getGetRolePermissionsQueryKey, useGetRoleUsage, getGetRoleUsageQueryKey, useReplaceRolePermissions, usePreviewRolePermissionsImpact, useGetRolePermissionAudit, getGetRolePermissionAuditQueryKey, useGetUserPermissionAudit, getGetUserPermissionAuditQueryKey, getUserPermissionAudit, useGetGroupPermissionAudit, getGetGroupPermissionAuditQueryKey, getGroupPermissionAudit, useGetAppPermissionAudit, getGetAppPermissionAuditQueryKey, getAppPermissionAudit, useListAuditLogs, getListAuditLogsQueryKey, getExportAuditLogsCsvUrl, getExportRolePermissionAuditCsvUrl, ApiError, } from "@workspace/api-client-react"; import type { GroupDeletionConflict, AppDeletionConflict, ServiceDeletionConflict, UserDeletionConflict, App, Service, UserProfile, AppSettings, AdminStats, AdminAppOpensByApp, AdminAppOpensByUser, GetAdminStatsQueryError, GetAdminStatsParams, Group, ListUsersParams, ListAuditLogsParams, AuditLogEntry, RolePermissionsImpact, AppPermissionsImpact, GetRolePermissionAuditParams, RolePermissionAuditEntry, GetUserPermissionAuditParams, GetGroupPermissionAuditParams, GetAppPermissionAuditParams, PermissionAuditEntry, PermissionAuditEntryChangeKind, Permission, AppDependentGroupsPage, AppDependentRestrictionsPage, AppDependentOpensPage, ServiceDependentOrdersPage, UserDependentNotesPage, UserDependentOrdersPage, } from "@workspace/api-client-react"; import { ListUsersStatus } from "@workspace/api-client-react"; import { useQueryClient, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; import { isBuiltinAppSlug } from "@workspace/db/built-in-apps"; import { resolveServiceImageUrl } from "@/lib/image-url"; import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, type LucideIcon } from "lucide-react"; import * as LucideIcons from "lucide-react"; function resolveAppIcon(name: string): LucideIcon | null { if (!name) return null; const Candidate = (LucideIcons as Record)[name]; if (typeof Candidate === "function" || (typeof Candidate === "object" && Candidate !== null)) { return Candidate as LucideIcon; } return null; } import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format"; import { asNumber, asRecord, asString, formatAuditSummary, unitLabel, } from "@/lib/audit-summary"; 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" | "audit-log" | "settings"; function LeaderboardAvatar({ src, initial, alt, }: { src: string | null; initial: string; alt: string; }) { const [errored, setErrored] = useState(false); useEffect(() => { setErrored(false); }, [src]); const showImage = src && !errored; return (
{showImage ? ( {alt} setErrored(true)} className="w-full h-full object-cover" /> ) : ( initial )}
); } type AppOpenMode = "internal" | "external_tab" | "external_iframe"; type AppForm = { nameAr: string; nameEn: string; slug: string; iconName: string; // Optional uploaded image — when set, the launcher renders this instead // of the Lucide icon. Stored as the object-storage path returned by the // upload presigner (e.g. "/objects/"). Empty string = none. imageUrl: string; route: string; externalUrl: string; openMode: AppOpenMode; color: string; sortOrder: number; // Only used when creating a new app — admins can pre-set the required // permissions so the app is never visible to everyone in the brief // window between insert and the follow-up gate. Existing apps keep // editing their gate through the AppPermissionsEditor below. permissionIds: number[]; }; type ServiceForm = { nameAr: string; nameEn: string; descriptionAr: string; descriptionEn: string; imageUrl: string; isAvailable: boolean; }; function DeletionWarningDialog({ title, items, warning, forceHint, emptyBody, confirmLabel, anywayLabel, showWarning, isPending, onCancel, onConfirm, testId, confirmTestId, }: { title: string; items: string[]; warning: string; forceHint: string; emptyBody: string; confirmLabel: string; anywayLabel: string; showWarning: boolean; isPending: boolean; onCancel: () => void; onConfirm: () => void; testId?: string; confirmTestId?: string; }) { const { t } = useTranslation(); return (

{title}

{showWarning ? ( <>

{warning}

{items.length > 0 && (
    {items.map((item, idx) => (
  • {item}
  • ))}
)}

{forceHint}

) : (

{emptyBody}

)}
); } // Local-state permission picker used by the "Add app" dialog. The full // AppPermissionsEditor below talks to /apps/:id/permissions, which can't // run before the app exists. This picker just collects ids into the form // state; handleSaveApp sends them inside the same POST /apps payload so // the new app never appears unrestricted (the goal of the feature). No // impact preview is shown here because a brand-new app starts with zero // users seeing it, so adding permissions can't hide it from anyone. function NewAppPermissionsPicker({ selectedIds, onChange, }: { selectedIds: number[]; onChange: (next: number[]) => void; }) { const { t } = useTranslation(); const { data: allPermissions } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey() }, }); const [pendingId, setPendingId] = useState(""); const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selected = useMemo( () => (allPermissions ?? []).filter((p) => selectedSet.has(p.id)), [allPermissions, selectedSet], ); const available = useMemo( () => (allPermissions ?? []).filter((p) => !selectedSet.has(p.id)), [allPermissions, selectedSet], ); const onAdd = () => { if (pendingId === "" || typeof pendingId !== "number") return; if (!selectedSet.has(pendingId)) onChange([...selectedIds, pendingId]); setPendingId(""); }; const onRemove = (id: number) => { onChange(selectedIds.filter((x) => x !== id)); }; return (

{t("admin.appPermissions.help")}

{selected.length === 0 ? (

{t("admin.appPermissions.none")}

) : (
    {selected.map((p) => (
  • {p.name}
  • ))}
)}
); } function AppPermissionsEditor({ appId }: { appId: number }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); const { data: assigned, isLoading } = useListAppPermissions(appId, { query: { queryKey: getListAppPermissionsQueryKey(appId) }, }); const { data: allPermissions } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey() }, }); const addPermission = useAddAppPermission(); const removePermission = useRemoveAppPermission(); const previewImpact = usePreviewAppPermissionsImpact(); const [pendingId, setPendingId] = useState(""); const [impact, setImpact] = useState(null); const [impactLoading, setImpactLoading] = useState(false); const [impactError, setImpactError] = useState(false); const [confirmAdd, setConfirmAdd] = useState(false); const impactRequestSeq = useRef(0); const assignedIds = useMemo( () => new Set((assigned ?? []).map((p) => p.id)), [assigned], ); const available = useMemo( () => (allPermissions ?? []).filter((p) => !assignedIds.has(p.id)), [allPermissions, assignedIds], ); const refresh = () => { queryClient.invalidateQueries({ queryKey: getListAppPermissionsQueryKey(appId), }); queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); }; // Debounced, cancel-safe impact preview for the candidate "add this // permission" change. Mirrors the RolesPanel UX so admins see the same // kind of warning before they tighten an app's gate. Stale responses // are dropped via impactRequestSeq. useEffect(() => { if (pendingId === "" || typeof pendingId !== "number") { setImpact(null); setImpactLoading(false); setImpactError(false); return; } const candidate = Array.from(assignedIds); if (!candidate.includes(pendingId)) candidate.push(pendingId); setImpactLoading(true); setImpactError(false); const requestId = ++impactRequestSeq.current; const handle = setTimeout(() => { previewImpact.mutate( { id: appId, data: { permissionIds: candidate } }, { onSuccess: (data) => { if (impactRequestSeq.current !== requestId) return; setImpact(data); setImpactError(false); setImpactLoading(false); }, onError: () => { if (impactRequestSeq.current !== requestId) return; setImpact(null); setImpactError(true); setImpactLoading(false); }, }, ); }, 350); return () => clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [appId, pendingId, assignedIds]); const requiresConfirmation = !impactLoading && !impactError && impact != null && impact.affectedUserCount > 0; const performAdd = () => { if (pendingId === "" || typeof pendingId !== "number") return; addPermission.mutate( { id: appId, data: { permissionId: pendingId } }, { onSuccess: () => { setPendingId(""); setImpact(null); setConfirmAdd(false); impactRequestSeq.current += 1; refresh(); }, onError: () => { toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const onAdd = () => { if (pendingId === "" || typeof pendingId !== "number") return; if (requiresConfirmation && !confirmAdd) { setConfirmAdd(true); return; } performAdd(); }; const onRemove = (permissionId: number) => { removePermission.mutate( { id: appId, permissionId }, { onSuccess: refresh, onError: () => { toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; return (

{t("admin.appPermissions.help")}

{isLoading ? (

{t("common.loading")}

) : (assigned?.length ?? 0) === 0 ? (

{t("admin.appPermissions.none")}

) : (
    {assigned!.map((p) => (
  • {p.name}
  • ))}
)}
{pendingId !== "" && (

{t("admin.appPermissions.impactTitle")}

{impactLoading ? (
{t("admin.appPermissions.impactLoading")}
) : impactError || !impact ? (

{t("admin.appPermissions.impactError")}

) : impact.affectedUserCount === 0 ? (

{t("admin.appPermissions.impactNone", { visible: impact.currentlyVisibleUserCount, })}

) : ( <>

{t("admin.appPermissions.impactSummary", { affected: impact.affectedUserCount, visible: impact.currentlyVisibleUserCount, })}

{impact.groups.length > 0 && (

{t("admin.appPermissions.impactViaGroups", { groups: impact.groups.map((g) => g.name).join(", "), })}

)} )}
)} {confirmAdd && impact && impact.affectedUserCount > 0 && (

{t("admin.appPermissions.confirmTitle")}

{t("admin.appPermissions.confirmBody", { affected: impact.affectedUserCount, visible: impact.currentlyVisibleUserCount, })}

{impact.groups.length > 0 && (

{t("admin.appPermissions.impactViaGroups", { groups: impact.groups.map((g) => g.name).join(", "), })}

)}
)}
); } export default function AdminPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); const { user } = useAuth(); const lang = i18n.language; const isRtl = lang === "ar"; const queryClient = useQueryClient(); const { toast } = useToast(); const BackIcon = isRtl ? ArrowRight : ArrowLeft; const isAdmin = user?.roles?.includes("admin") ?? false; // Redirect non-admins using useEffect to avoid violating rules of hooks useEffect(() => { if (user && !isAdmin) { setLocation("/"); } }, [user, isAdmin, setLocation]); 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: rolesList } = useListRoles({ query: { queryKey: getListRolesQueryKey(), enabled: isAdmin } }); const { data: permissionsList } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey(), enabled: isAdmin } }); const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } }); const statsRangeStorageKey = user?.id ? `admin.statsRange.${user.id}` : null; const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d"); const [statsRangeHydrated, setStatsRangeHydrated] = useState(false); useEffect(() => { setStatsRangeHydrated(false); if (typeof window === "undefined" || !statsRangeStorageKey) return; const stored = window.localStorage.getItem(statsRangeStorageKey); if (stored === "7d" || stored === "30d" || stored === "90d") { setStatsRange(stored); } setStatsRangeHydrated(true); }, [statsRangeStorageKey]); useEffect(() => { if (typeof window === "undefined" || !statsRangeStorageKey || !statsRangeHydrated) return; if (statsRange === "7d" || statsRange === "30d" || statsRange === "90d") { window.localStorage.setItem(statsRangeStorageKey, statsRange); } }, [statsRange, statsRangeStorageKey, statsRangeHydrated]); const todayIso = new Date().toISOString().slice(0, 10); const sevenAgoIso = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000) .toISOString() .slice(0, 10); const [customFrom, setCustomFrom] = useState(sevenAgoIso); const [customTo, setCustomTo] = useState(todayIso); const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string }>({ from: sevenAgoIso, to: todayIso, }); const isCustomValid = /^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.from) && /^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.to) && appliedCustom.from <= appliedCustom.to; const statsQueryParams = statsRange === "custom" ? { range: "custom" as const, from: appliedCustom.from, to: appliedCustom.to } : { range: statsRange }; const { data: adminStats, error: adminStatsError } = useGetAdminStats(statsQueryParams, { query: { queryKey: getGetAdminStatsQueryKey(statsQueryParams), enabled: isAdmin && (statsRange !== "custom" || isCustomValid), retry: false, }, }); const adminStatsErrorMessage: string | null = (() => { if (!adminStatsError) return null; const err = adminStatsError as GetAdminStatsQueryError; return err.data?.error ?? err.message ?? null; })(); const createApp = useCreateApp(); const updateApp = useUpdateApp(); const deleteApp = useDeleteApp(); const createService = useCreateService(); const updateService = useUpdateService(); const deleteService = useDeleteService(); const createUser = useCreateUser(); const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); const addUserRole = useAddUserRole(); const removeUserRole = useRemoveUserRole(); const issueResetLink = useAdminIssueResetLink(); const [resetLinkUser, setResetLinkUser] = useState<{ username: string; url: string; expiresAt: string } | null>(null); const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null); const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null); const [dependencyTarget, setDependencyTarget] = useState(null); const [appDeleteTarget, setAppDeleteTarget] = useState(null); const [appDeleteConflict, setAppDeleteConflict] = useState(null); const [serviceDeleteTarget, setServiceDeleteTarget] = useState(null); const [serviceDeleteConflict, setServiceDeleteConflict] = useState(null); const closeAppDeleteDialog = () => { setAppDeleteTarget(null); setAppDeleteConflict(null); }; const closeServiceDeleteDialog = () => { setServiceDeleteTarget(null); setServiceDeleteConflict(null); }; const performAppDelete = (app: App, force: boolean) => { deleteApp.mutate( { id: app.id, params: force ? { force: true } : undefined }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); toast({ title: t("common.success") }); closeAppDeleteDialog(); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409 && err.data) { setAppDeleteConflict(err.data as AppDeletionConflict); return; } toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const performServiceDelete = (service: Service, force: boolean) => { deleteService.mutate( { id: service.id, params: force ? { force: true } : undefined }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }); toast({ title: t("common.success") }); closeServiceDeleteDialog(); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409 && err.data) { setServiceDeleteConflict(err.data as ServiceDeletionConflict); return; } toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null); const [section, setSection] = useState("dashboard"); const [navOpenGroups, setNavOpenGroups] = useState>({}); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [highlightedUserId, setHighlightedUserId] = useState(null); const userRowRefs = useRef>(new Map()); useEffect(() => { if (section !== "users" || highlightedUserId == null) return; const node = userRowRefs.current.get(highlightedUserId); if (node) { node.scrollIntoView({ behavior: "smooth", block: "center" }); } const timer = window.setTimeout(() => setHighlightedUserId(null), 2500); return () => window.clearTimeout(timer); }, [section, highlightedUserId, users]); const handleSelectAppFromDashboard = (appId: number) => { const app = apps?.find((a) => a.id === appId); setSection("apps"); if (app) { setEditingApp({ id: app.id, form: { nameAr: app.nameAr ?? "", nameEn: app.nameEn ?? "", slug: app.slug ?? "", iconName: app.iconName ?? "Grid2X2", imageUrl: app.imageUrl ?? "", route: app.route ?? "/", externalUrl: app.externalUrl ?? "", openMode: ((app.openMode as AppOpenMode) ?? "internal"), color: app.color ?? "#6366f1", sortOrder: app.sortOrder ?? 0, permissionIds: [], }, }); } }; const handleSelectUserFromDashboard = (userId: number) => { setSection("users"); setHighlightedUserId(userId); }; type NavLeaf = { key: AdminSection; label: string; Icon: typeof LayoutDashboard }; type NavGroup = { groupKey: string; label: string; Icon: typeof LayoutDashboard; children: NavLeaf[] }; type NavEntry = NavLeaf | NavGroup; const navItems: NavEntry[] = [ { key: "dashboard", label: t("admin.nav.dashboard"), Icon: LayoutDashboard }, { key: "apps", label: t("admin.nav.apps"), Icon: Grid2X2 }, { key: "services", label: t("admin.nav.services"), Icon: Coffee }, { groupKey: "user-management", label: t("admin.nav.userManagement"), Icon: UsersIcon, children: [ { key: "users", label: t("admin.nav.users"), Icon: UsersIcon }, { key: "groups", label: t("admin.nav.groups"), Icon: Shield }, { key: "roles", label: t("admin.nav.roles"), Icon: KeyRound }, { key: "audit-log", label: t("admin.nav.auditLog"), Icon: ScrollText }, ], }, { key: "settings", label: t("admin.nav.settings"), Icon: Settings }, ]; // Sync section to URL hash so deep links work useEffect(() => { if (typeof window === "undefined") return; const hash = window.location.hash.replace(/^#/, ""); const sectionMatch = hash.match(/section=([a-z-]+)/); if (sectionMatch) { const s = sectionMatch[1] as AdminSection; if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) { setSection(s); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Skip the very first render so we don't clobber a deep-linked hash like // `#section=audit-log&targetType=group&targetId=42` before the mount-time // parser above has had a chance to apply it. const skipNextSectionSync = useRef(true); useEffect(() => { if (typeof window === "undefined") return; if (skipNextSectionSync.current) { skipNextSectionSync.current = false; return; } const raw = window.location.hash.replace(/^#/, ""); const params = new URLSearchParams(raw); const previousSection = params.get("section"); params.set("section", section); // Section-scoped params (the audit log's targetType / targetId / actorId // deep links) only make sense within their owning section. Switching to a // different section drops them so the next visit starts fresh. if (previousSection !== section) { params.delete("targetType"); params.delete("targetId"); params.delete("actorId"); } const newHash = `#${params.toString()}`; if (window.location.hash !== newHash) { window.history.replaceState(null, "", newHash); } }, [section]); const renderNavList = (onSelect?: () => void) => { const renderLeaf = (leaf: NavLeaf, indent = false) => { const active = section === leaf.key; const Icon = leaf.Icon; return ( ); }; return ( ); }; const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", imageUrl: "", route: "/", externalUrl: "", openMode: "internal", color: "#6366f1", sortOrder: 0, permissionIds: [] }; const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", imageUrl: "", isAvailable: true }; // #196: Switch to the audit-log section pre-filtered by the given // targetType/targetId. Writes the hash BEFORE the section change so the // section-sync effect (which would otherwise drop targetType/targetId // when the previous section differs) sees a matching previousSection and // preserves the deep-link params. const openAuditLogForTarget = ( targetType: "app" | "service" | "user" | "group" | "role", targetId: number, ) => { if (typeof window !== "undefined") { const params = new URLSearchParams( window.location.hash.replace(/^#/, ""), ); params.set("section", "audit-log"); params.set("targetType", targetType); params.set("targetId", String(targetId)); window.history.replaceState(null, "", `#${params.toString()}`); } // Close any open editor modals so the audit-log view is unobstructed. setEditingApp(null); setEditingService(null); setSection("audit-log"); }; const handleSaveApp = () => { if (!editingApp) return; // Normalize the optional URL/image fields. The OpenAPI spec types // them as ["string","null"], so an empty string from the form would // be rejected by zod. We send null whenever the admin left them // blank (which is the meaningful value for "no image"/"no URL"). const normalize = (form: AppForm) => ({ ...form, imageUrl: form.imageUrl?.trim() ? form.imageUrl.trim() : null, externalUrl: form.externalUrl?.trim() ? form.externalUrl.trim() : null, }); // Surface server-side errors that the user can act on. The // built-in route lock returns { code: "builtin_route_locked" }. const onError = async (err: unknown) => { let message: string | undefined; if (err && typeof err === "object" && "response" in err) { const r = (err as { response?: Response }).response; if (r) { try { const body = await r.clone().json(); if (body?.code === "builtin_route_locked") { message = t("admin.builtinPathLocked"); } else if (typeof body?.error === "string") { message = body.error; } } catch { /* fall through */ } } } toast({ title: t("admin.uploadFailed"), description: message, variant: "destructive", }); }; if (editingApp.id) { // Edit path: permissionIds aren't part of the update payload — the // existing AppPermissionsEditor manages them with its own impact // preview + audit, so strip the field before sending. const { permissionIds: _ignored, ...rest } = normalize(editingApp.form); void _ignored; updateApp.mutate( { id: editingApp.id, data: rest }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); setEditingApp(null); toast({ title: t("common.success") }); }, onError, }, ); } else { const { permissionIds, ...rest } = normalize(editingApp.form); createApp.mutate( { // Only include permissionIds if the admin actually picked some // so the request body stays minimal and the server's "no // restriction" path is unchanged. data: (editingApp.form.permissionIds.length > 0) ? { ...rest, permissionIds: editingApp.form.permissionIds } : rest, }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); setEditingApp(null); toast({ title: t("common.success") }); }, onError, }, ); void permissionIds; } }; const handleSaveService = () => { if (!editingService) return; if (editingService.id) { updateService.mutate( { id: editingService.id, data: editingService.form }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }); setEditingService(null); toast({ title: t("common.success") }); }, }, ); } else { createService.mutate( { data: editingService.form }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }); setEditingService(null); toast({ title: t("common.success") }); }, }, ); } }; const handleCreateUser = () => { if (!newUserForm) return; createUser.mutate( { data: { username: newUserForm.username, email: newUserForm.email, password: newUserForm.password } }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); setNewUserForm(null); toast({ title: t("common.success") }); }, }, ); }; if (!user || !isAdmin) return null; return (
{/* Header */}

{t("admin.title")}

{t("admin.title")}

{renderNavList(() => setMobileNavOpen(false))}
{/* Edit App Modal */} {editingApp && (() => { const PreviewIcon = resolveAppIcon(editingApp.form.iconName); const previewColor = /^#[0-9a-fA-F]{6}$/.test(editingApp.form.color) ? editingApp.form.color : "#6366f1"; const isBuiltin = editingApp.id !== undefined && isBuiltinAppSlug(editingApp.form.slug); const isExternal = editingApp.form.openMode !== "internal"; return (
{ if (e.target === e.currentTarget) setEditingApp(null); }} >
{/* Header */}
{PreviewIcon ? : }

{editingApp.id ? t("admin.editApp") : t("admin.addApp")}

{editingApp.id ? (lang === "ar" ? "حدّث تفاصيل التطبيق وأذوناته" : "Update app details and permissions") : (lang === "ar" ? "أضف تطبيقاً جديداً للوحة" : "Add a new app to the launcher")}

{/* Body */}
{/* Names row */}
setEditingApp({ ...editingApp, form: { ...editingApp.form, nameAr: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="rtl" placeholder="الملاحظات" />
setEditingApp({ ...editingApp, form: { ...editingApp.form, nameEn: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="Notes" />
{/* Identifier row */}
setEditingApp({ ...editingApp, form: { ...editingApp.form, slug: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="notes" />

{lang === "ar" ? "معرّف فريد للتطبيق (أحرف صغيرة بدون مسافات)" : "Unique app identifier (lowercase, no spaces)"}

{!isExternal && (
setEditingApp({ ...editingApp, form: { ...editingApp.form, route: e.target.value } })} readOnly={isBuiltin} className={cn( "bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400", isBuiltin && "opacity-70 cursor-not-allowed", )} dir="ltr" placeholder="/notes" />

{isBuiltin ? t("admin.builtinPathLocked") : (lang === "ar" ? "المسار داخل التطبيق (مثل /notes)" : "URL path inside the app (e.g. /notes)")}

)}
{/* App image (optional, replaces the Lucide icon on the launcher) */}
setEditingApp({ ...editingApp, form: { ...editingApp.form, imageUrl: url }, }) } />

{lang === "ar" ? "صورة اختيارية تظهر مكان الأيقونة في الشاشة الرئيسية" : "Optional image shown on the launcher tile in place of the icon"}

{/* Open mode + external URL */}
{isExternal && (
setEditingApp({ ...editingApp, form: { ...editingApp.form, externalUrl: e.target.value }, }) } className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="https://example.com" type="url" />
)}
{/* Visuals row — icon + color with live previews */}
{/* Icon picker */}
{PreviewIcon ? : }
setEditingApp({ ...editingApp, form: { ...editingApp.form, iconName: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="StickyNote" />

{lang === "ar" ? "اسم أيقونة من Lucide (مثل: StickyNote, Coffee, Users)" : "Lucide icon name (e.g. StickyNote, Coffee, Users)"}

{/* Color picker */}
setEditingApp({ ...editingApp, form: { ...editingApp.form, color: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm font-mono uppercase focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="#6366F1" />

{lang === "ar" ? "كود اللون السداسي (مثل #6366F1)" : "Hex color code (e.g. #6366F1)"}

{/* Permissions section in a bordered card */}
{lang === "ar" ? "الأذونات" : "Permissions"}
{editingApp.id !== undefined ? ( ) : ( setEditingApp({ ...editingApp, form: { ...editingApp.form, permissionIds: next }, }) } /> )}
{editingApp.id !== undefined && ( { const p = (permissionsList ?? []).find((x) => x.id === id); return p ? p.name : `#${id}`; }} /> )} {editingApp.id !== undefined && ( openAuditLogForTarget("app", editingApp.id!) } /> )}
{/* Footer */}
); })()} {/* Edit Service Modal */} {editingService && (
{ if (e.target === e.currentTarget) setEditingService(null); }} >
{/* Header */}

{editingService.id ? t("admin.editService") : t("admin.addService")}

{editingService.id ? (lang === "ar" ? "حدّث تفاصيل الخدمة وصورتها" : "Update the service details and image") : (lang === "ar" ? "أضف خدمة جديدة إلى القائمة" : "Add a new service to the list")}

{/* Body */}
{/* Name fields — side by side on sm+ */}
setEditingService({ ...editingService, form: { ...editingService.form, nameAr: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="rtl" placeholder="شاي" />
setEditingService({ ...editingService, form: { ...editingService.form, nameEn: e.target.value } })} className="bg-slate-50/70 border-slate-200 rounded-lg h-10 text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400" dir="ltr" placeholder="Tea" />
{/* Image */}
setEditingService({ ...editingService, form: { ...editingService.form, imageUrl: url }, }) } />
{/* Availability — card row */}
{t("admin.serviceAvailable")}
{editingService.form.isAvailable ? (lang === "ar" ? "الخدمة معروضة للمستخدمين الآن" : "Visible to users right now") : (lang === "ar" ? "الخدمة مخفية عن المستخدمين" : "Hidden from users")}
setEditingService({ ...editingService, form: { ...editingService.form, isAvailable: v } })} />
{editingService.id !== undefined && (
openAuditLogForTarget("service", editingService.id!) } />
)}
{/* Footer */}
)} {/* Create User Modal */} {newUserForm && (

{t("admin.addUser")}

setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" />
setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" />
)}
{/* Sidebar (desktop) */} {/* Main content */}
{section === "dashboard" && ( setAppliedCustom({ from: customFrom, to: customTo })} statsQueryParams={statsQueryParams} onSelectApp={handleSelectAppFromDashboard} onSelectUser={handleSelectUserFromDashboard} /> )} {section === "apps" && (
{apps?.map((app) => (
{lang === "ar" ? app.nameAr : app.nameEn}
{!app.isActive && ( {t("admin.appDisabled")} )}
{app.route}
{(() => { const appName = lang === "ar" ? app.nameAr : app.nameEn; const parts: Array<{ label: string; target: DependencyTarget; testid: string; }> = []; if ((app.groupCount ?? 0) > 0) parts.push({ label: t("admin.apps.counts.groups", { count: app.groupCount }), target: { kind: "appGroups", appId: app.id, appName }, testid: `app-counts-groups-${app.id}`, }); if ((app.restrictionCount ?? 0) > 0) parts.push({ label: t("admin.apps.counts.restrictions", { count: app.restrictionCount }), target: { kind: "appRestrictions", appId: app.id, appName }, testid: `app-counts-restrictions-${app.id}`, }); if ((app.openCount ?? 0) > 0) parts.push({ label: t("admin.apps.counts.opens", { count: app.openCount }), target: { kind: "appOpens", appId: app.id, appName }, testid: `app-counts-opens-${app.id}`, }); if (parts.length === 0) return null; return (
{parts.map((p, i) => ( {i > 0 && } ))}
); })()}
{ updateApp.mutate( { id: app.id, data: { isActive: v } }, { onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) }, ); }} />
))}
)} {section === "services" && (
{services?.map((service) => (
{lang === "ar" ? service.nameAr : service.nameEn}
{(service.orderCount ?? 0) > 0 && (
)}
{ updateService.mutate( { id: service.id, data: { isAvailable: v } }, { onSuccess: () => queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }) }, ); }} />
))}
)} {section === "users" && ( issueResetLink.mutate( { id: uid }, { onSuccess: (resp) => setResetLinkUser({ username, url: resp.resetUrl, expiresAt: typeof resp.expiresAt === "string" ? resp.expiresAt : new Date(resp.expiresAt).toISOString(), }), onError: () => toast({ title: t("common.error"), variant: "destructive" }), }, ) } onOpenAuditLogForTarget={openAuditLogForTarget} /> )} {section === "groups" && ( )} {section === "roles" && ( )} {section === "audit-log" && } {section === "settings" && (
)}
{appDeleteTarget && (() => { const target = appDeleteTarget; const items: string[] = []; if (appDeleteConflict) { if (appDeleteConflict.groupCount > 0) items.push(t("admin.deleteApp.groupCount", { count: appDeleteConflict.groupCount })); if (appDeleteConflict.restrictionCount > 0) items.push(t("admin.deleteApp.restrictionCount", { count: appDeleteConflict.restrictionCount })); if (appDeleteConflict.openCount > 0) items.push(t("admin.deleteApp.openCount", { count: appDeleteConflict.openCount })); } return ( performAppDelete(target, appDeleteConflict !== null)} testId="app-delete-dialog" confirmTestId="app-delete-confirm" /> ); })()} {serviceDeleteTarget && (() => { const target = serviceDeleteTarget; const items: string[] = []; if (serviceDeleteConflict && serviceDeleteConflict.orderCount > 0) { items.push(t("admin.deleteService.orderCount", { count: serviceDeleteConflict.orderCount })); } return ( performServiceDelete(target, serviceDeleteConflict !== null)} testId="service-delete-dialog" confirmTestId="service-delete-confirm" /> ); })()} {resetLinkUser && (
setResetLinkUser(null)} >
e.stopPropagation()} >

{t("admin.resetLinkModal.title", { username: resetLinkUser.username })}

{t("admin.resetLinkModal.description")}

{resetLinkUser.url}

{t("admin.resetLinkModal.expires", { time: new Date(resetLinkUser.expiresAt).toLocaleString(), })}

)} {dependencyTarget && ( setDependencyTarget(null)} /> )}
); } function SiteSettingsPanel() { const { t } = useTranslation(); const { toast } = useToast(); const queryClient = useQueryClient(); const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } }); const update = useUpdateAppSettings(); const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "", registrationOpen: true, footerTextAr: "", footerTextEn: "", }); useEffect(() => { if (data) setForm({ siteNameAr: data.siteNameAr, siteNameEn: data.siteNameEn, registrationOpen: data.registrationOpen, footerTextAr: data.footerTextAr, footerTextEn: data.footerTextEn, }); }, [data]); const handleSave = () => { update.mutate( { data: form }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getGetAppSettingsQueryKey() }); toast({ title: t("common.success") }); }, onError: () => toast({ title: t("common.error"), variant: "destructive" }), }, ); }; return (
setForm({ ...form, siteNameAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
setForm({ ...form, siteNameEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />
setForm({ ...form, footerTextAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
setForm({ ...form, footerTextEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />

{t("admin.registrationOpenHint")}

setForm({ ...form, registrationOpen: v })} />
); } function ServiceImageUploader({ value, onChange, }: { value: string; onChange: (url: string) => void; }) { const { t } = useTranslation(); const { toast } = useToast(); const { uploadFile, isUploading, progress } = useUpload({ onSuccess: (resp: UploadResponse) => { onChange(resp.objectPath); }, onError: (err: Error) => { toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" }); }, }); return (
{value && !isUploading && ( )}
{value && (
{ (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
)}
); } function DashboardSection({ apps, services, users, appSettings, adminStats, adminStatsErrorMessage, lang, statsRange, onStatsRangeChange, customFrom, customTo, onCustomFromChange, onCustomToChange, onApplyCustom, statsQueryParams, onSelectApp, onSelectUser, }: { apps: App[] | undefined; services: Service[] | undefined; users: UserProfile[] | undefined; appSettings: AppSettings | undefined; adminStats: AdminStats | undefined; adminStatsErrorMessage: string | null; lang: string; statsRange: "7d" | "30d" | "90d" | "custom"; onStatsRangeChange: (range: "7d" | "30d" | "90d" | "custom") => void; customFrom: string; customTo: string; onCustomFromChange: (v: string) => void; onCustomToChange: (v: string) => void; onApplyCustom: () => void; statsQueryParams: GetAdminStatsParams; onSelectApp: (appId: number) => void; onSelectUser: (userId: number) => void; }) { const { t } = useTranslation(); const [drillIn, setDrillIn] = useState< | { kind: "app"; appId: number } | { kind: "user"; userId: number } | null >(null); const totalApps = apps?.length ?? 0; const totalServices = services?.length ?? 0; const totalUsers = users?.length ?? 0; const registrationOpen = appSettings?.registrationOpen ?? false; const latestUser = users ? [...users].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0] : undefined; const latestApp = apps ? [...apps].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0] : undefined; const formatDate = (iso?: string) => { if (!iso) return ""; try { return formatDateUtil(iso, lang, { year: "numeric", month: "short", day: "numeric" }); } catch { return ""; } }; const stats: { label: string; value: string; Icon: typeof Grid2X2; tint: string }[] = [ { label: t("admin.dashboard.totalApps"), value: String(totalApps), Icon: Grid2X2, tint: "text-indigo-500 bg-indigo-100/70" }, { label: t("admin.dashboard.totalServices"), value: String(totalServices), Icon: Coffee, tint: "text-amber-600 bg-amber-100/70" }, { label: t("admin.dashboard.totalUsers"), value: String(totalUsers), Icon: UsersIcon, tint: "text-emerald-600 bg-emerald-100/70" }, { label: t("admin.dashboard.registration"), value: registrationOpen ? t("admin.dashboard.open") : t("admin.dashboard.closed"), Icon: Shield, tint: registrationOpen ? "text-green-600 bg-green-100/70" : "text-rose-600 bg-rose-100/70", }, ]; const newUsers = adminStats?.newUsersInRange ?? 0; const prevUsers = adminStats?.newUsersPrevRange ?? 0; const userDelta = newUsers - prevUsers; const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null; const activeServices = adminStats?.activeServices ?? 0; const inactiveServices = adminStats?.inactiveServices ?? 0; const signups = adminStats?.signupsByDay ?? []; const maxSignup = Math.max(1, ...signups.map((s) => s.count)); const opens = adminStats?.appOpensByDay ?? []; const maxOpens = Math.max(1, ...opens.map((s) => s.count)); const opensTotal = adminStats?.appOpensInRange ?? 0; const opensPrev = adminStats?.appOpensPrevRange ?? 0; const opensDelta = opensTotal - opensPrev; const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null; const servicesCreated = adminStats?.servicesCreatedByDay ?? []; const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count)); const servicesCreatedTotal = adminStats?.servicesCreatedInRange ?? 0; const topApps = adminStats?.topApps ?? []; const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count)); const mostActiveUsers = adminStats?.mostActiveUsers ?? []; const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count)); const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : statsRange === "custom" ? 7 : 7); const formatRangeDate = (iso: string) => { try { return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" }); } catch { return iso; } }; const rangeLabel = statsRange === "custom" && adminStats?.rangeFrom && adminStats?.rangeTo ? t("admin.dashboard.range.customLabel", { from: formatRangeDate(adminStats.rangeFrom), to: formatRangeDate(adminStats.rangeTo), }) : t(`admin.dashboard.range.${statsRange}`); const prevRangeLabel = statsRange === "custom" ? t("admin.dashboard.prevRange.custom", { count: rangeDays }) : t(`admin.dashboard.prevRange.${statsRange}`); const dayLabel = (iso: string) => { try { return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short"); } catch { return ""; } }; const showCounts = rangeDays <= 14; const labelStride = rangeDays <= 7 ? 1 : rangeDays <= 30 ? 5 : 15; const gapClass = rangeDays <= 7 ? "gap-1.5" : rangeDays <= 30 ? "gap-1" : "gap-px"; const monthDayLabel = (iso: string) => { try { return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" }); } catch { return iso.slice(5); } }; const renderChart = ( title: string, series: { date: string; count: number }[], max: number, accent: string, subtitle?: string, ) => (
{title}
{subtitle ?? rangeLabel}
{series.map((s, idx) => { const heightPct = (s.count / max) * 100; const showLabel = rangeDays <= 7 ? true : idx === series.length - 1 || (series.length - 1 - idx) % labelStride === 0; return (
{showCounts && (
{s.count}
)}
0 ? 6 : 2)}%` }} />
{showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
); })}
); const rangeOptions: { value: "7d" | "30d" | "90d" | "custom"; label: string }[] = [ { value: "7d", label: t("admin.dashboard.range.7d") }, { value: "30d", label: t("admin.dashboard.range.30d") }, { value: "90d", label: t("admin.dashboard.range.90d") }, { value: "custom", label: t("admin.dashboard.range.custom") }, ]; const customInputsValid = /^\d{4}-\d{2}-\d{2}$/.test(customFrom) && /^\d{4}-\d{2}-\d{2}$/.test(customTo) && customFrom <= customTo; return (
{stats.map(({ label, value, Icon, tint }) => (
{label}
{value}
))}
{t("admin.dashboard.trends")}
{rangeOptions.map((opt) => { const active = statsRange === opt.value; return ( ); })}
{statsRange === "custom" && (
onCustomFromChange(e.target.value)} className="bg-white/70 border-slate-200 h-9 text-sm" />
onCustomToChange(e.target.value)} className="bg-white/70 border-slate-200 h-9 text-sm" />
{!customInputsValid && (
{t("admin.dashboard.customRange.invalid")}
)}
)} {adminStatsErrorMessage && (
{t("admin.dashboard.customRange.loadError")}
{adminStatsErrorMessage}
)}
{t("admin.dashboard.newUsersInRange", { range: rangeLabel })}
{newUsers}
{userDelta !== 0 && (
0 ? "text-emerald-600" : "text-rose-600", )} > {userDelta > 0 ? : } {userDeltaPct !== null ? `${userDelta > 0 ? "+" : ""}${userDeltaPct}%` : `${userDelta > 0 ? "+" : ""}${userDelta}`}
)}
{t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
{t("admin.dashboard.activeServices")}
{activeServices}
/ {activeServices + inactiveServices}
{t("admin.dashboard.inactiveServices", { count: inactiveServices })}
{renderChart( t("admin.dashboard.signupsTrend"), signups, maxSignup, "from-primary/40 to-primary", )}
{renderChart( t("admin.dashboard.appOpensTrend"), opens, maxOpens, "from-sky-300/40 to-sky-500", t("admin.dashboard.appOpensSummaryRanged", { count: opensTotal, range: rangeLabel, delta: opensDelta === 0 ? "±0" : opensDeltaPct !== null ? `${opensDelta > 0 ? "+" : ""}${opensDeltaPct}%` : `${opensDelta > 0 ? "+" : ""}${opensDelta}`, }), )} {renderChart( t("admin.dashboard.servicesCreatedTrend"), servicesCreated, maxServicesCreated, "from-amber-300/40 to-amber-500", t("admin.dashboard.servicesCreatedSummaryRanged", { count: servicesCreatedTotal, range: rangeLabel }), )}
{t("admin.dashboard.topApps")}
{t("admin.dashboard.topAppsSubtitle", { range: rangeLabel })}
{topApps.length === 0 ? (
{t("admin.dashboard.leaderboardEmpty")}
) : (
    {topApps.map((app, idx) => { const widthPct = (app.count / maxTopAppCount) * 100; const appName = lang === "ar" ? app.nameAr : app.nameEn; return (
  1. ); })}
)}
{t("admin.dashboard.mostActiveUsers")}
{t("admin.dashboard.mostActiveUsersSubtitle", { range: rangeLabel })}
{mostActiveUsers.length === 0 ? (
{t("admin.dashboard.leaderboardEmpty")}
) : (
    {mostActiveUsers.map((u, idx) => { const widthPct = (u.count / maxActiveUserCount) * 100; const displayName = (lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username; const initial = (displayName || u.username || "?").trim().charAt(0).toUpperCase(); const avatarSrc = resolveServiceImageUrl(u.avatarUrl); return (
  1. ); })}
)}
{t("admin.dashboard.recentActivity")}
{t("admin.dashboard.latestUser")}
{latestUser?.username ?? t("admin.dashboard.none")}
{latestUser?.createdAt && (
{formatDate(latestUser.createdAt)}
)}
{t("admin.dashboard.latestApp")}
{latestApp ? (lang === "ar" ? latestApp.nameAr : latestApp.nameEn) : t("admin.dashboard.none")}
{latestApp?.createdAt && (
{formatDate(latestApp.createdAt)}
)}
{drillIn?.kind === "app" && ( setDrillIn(null)} onJump={(id) => { setDrillIn(null); onSelectApp(id); }} onJumpUser={(id) => { setDrillIn(null); onSelectUser(id); }} /> )} {drillIn?.kind === "user" && ( setDrillIn(null)} onJump={(id) => { setDrillIn(null); onSelectUser(id); }} onJumpApp={(id) => { setDrillIn(null); onSelectApp(id); }} /> )}
); } function DrillInShell({ title, subtitle, onClose, children, footer, }: { title: string; subtitle: string; onClose: () => void; children: ReactNode; footer?: ReactNode; }) { const { t } = useTranslation(); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]); return (
e.stopPropagation()} >
{title}
{subtitle}
{children}
{footer &&
{footer}
}
); } function formatOpenTimestamp(iso: string, lang: string): string { try { return formatDateUtil(iso, lang, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } } function AppOpensDrillIn({ appId, lang, rangeLabel, statsQueryParams, onClose, onJump, onJumpUser, }: { appId: number; lang: string; rangeLabel: string; statsQueryParams: GetAdminStatsParams; onClose: () => void; onJump: (appId: number) => void; onJumpUser: (userId: number) => void; }) { const { t } = useTranslation(); const { data, isLoading, error } = useGetAdminAppOpensByApp(appId, statsQueryParams, { query: { queryKey: getGetAdminAppOpensByAppQueryKey(appId, statsQueryParams), retry: false, }, }); type ExtraOpen = AdminAppOpensByApp["opens"][number]; const [extraOpens, setExtraOpens] = useState([]); const [extraNextOffset, setExtraNextOffset] = useState(null); const [hasExtras, setHasExtras] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(null); const paramsKey = JSON.stringify(statsQueryParams); useEffect(() => { setExtraOpens([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [appId, paramsKey]); const errorMessage = error ? (error as { message?: string }).message ?? null : null; const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : ""; const title = appName ? t("admin.dashboard.drillIn.appTitle", { name: appName }) : t("admin.dashboard.drillIn.loading"); const subtitle = data ? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel }) : rangeLabel; const opens = data ? [...data.opens, ...extraOpens] : []; const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; const onLoadMore = async () => { if (nextOffset === null || isLoadingMore) return; setIsLoadingMore(true); setLoadMoreError(null); try { const next = await getAdminAppOpensByApp(appId, { ...statsQueryParams, offset: nextOffset, }); setExtraOpens((prev) => [...prev, ...next.opens]); setExtraNextOffset(next.nextOffset); setHasExtras(true); } catch (e) { setLoadMoreError((e as { message?: string }).message ?? "Error"); } finally { setIsLoadingMore(false); } }; return (
} > {isLoading ? (
) : errorMessage ? (
{t("admin.dashboard.drillIn.loadError")}
{errorMessage}
) : !data || opens.length === 0 ? (
{t("admin.dashboard.drillIn.empty")}
) : ( <>
    {opens.map((o) => { const display = (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username; const initial = (display || o.username || "?").trim().charAt(0).toUpperCase(); const avatar = resolveServiceImageUrl(o.avatarUrl); return (
  1. ); })}
)} ); } function LoadMoreSection({ shownCount, totalCount, nextOffset, isLoadingMore, loadMoreError, onLoadMore, }: { shownCount: number; totalCount: number; nextOffset: number | null; isLoadingMore: boolean; loadMoreError: string | null; onLoadMore: () => void; }) { const { t } = useTranslation(); return (
{t("admin.dashboard.drillIn.shownOf", { shown: shownCount, total: totalCount })}
{nextOffset !== null && ( )} {loadMoreError && (
{t("admin.dashboard.drillIn.loadMoreError")}
)}
); } function UserOpensDrillIn({ userId, lang, rangeLabel, statsQueryParams, onClose, onJump, onJumpApp, }: { userId: number; lang: string; rangeLabel: string; statsQueryParams: GetAdminStatsParams; onClose: () => void; onJump: (userId: number) => void; onJumpApp: (appId: number) => void; }) { const { t } = useTranslation(); const { data, isLoading, error } = useGetAdminAppOpensByUser(userId, statsQueryParams, { query: { queryKey: getGetAdminAppOpensByUserQueryKey(userId, statsQueryParams), retry: false, }, }); type ExtraOpen = AdminAppOpensByUser["opens"][number]; const [extraOpens, setExtraOpens] = useState([]); const [extraNextOffset, setExtraNextOffset] = useState(null); const [hasExtras, setHasExtras] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(null); const paramsKey = JSON.stringify(statsQueryParams); useEffect(() => { setExtraOpens([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [userId, paramsKey]); const errorMessage = error ? (error as { message?: string }).message ?? null : null; const displayName = data ? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username : ""; const opens = data ? [...data.opens, ...extraOpens] : []; const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; const onLoadMore = async () => { if (nextOffset === null || isLoadingMore) return; setIsLoadingMore(true); setLoadMoreError(null); try { const next = await getAdminAppOpensByUser(userId, { ...statsQueryParams, offset: nextOffset, }); setExtraOpens((prev) => [...prev, ...next.opens]); setExtraNextOffset(next.nextOffset); setHasExtras(true); } catch (e) { setLoadMoreError((e as { message?: string }).message ?? "Error"); } finally { setIsLoadingMore(false); } }; const title = displayName ? t("admin.dashboard.drillIn.userTitle", { name: displayName }) : t("admin.dashboard.drillIn.loading"); const subtitle = data ? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel }) : rangeLabel; return (
} > {isLoading ? (
) : errorMessage ? (
{t("admin.dashboard.drillIn.loadError")}
{errorMessage}
) : !data || opens.length === 0 ? (
{t("admin.dashboard.drillIn.empty")}
) : ( <>
    {opens.map((o) => { const name = lang === "ar" ? o.nameAr : o.nameEn; return (
  1. ); })}
)} ); } // ===== Dependency drill-in ===== // // Generic shell + per-kind list rendering that turns each " X" count // badge on the admin Apps / Services / Users rows into a clickable link // that reveals the actual rows behind the count. Reuses DrillInShell so // Escape-to-close + RTL/LTR layout already work; the counts themselves // are rendered as
{t("admin.users.showing", { count: filtered.length, total: users?.length ?? 0 })}
{/* Table (desktop) / cards (mobile) */}
{(["username", "email", "createdAt"] as UserSortKey[]).map((k) => ( ))}
{t("admin.users.col.groups")}
{t("admin.users.col.actions")}
{filtered.map((u) => (
{ if (node) userRowRefs.current.set(u.id, node); else userRowRefs.current.delete(u.id); }} className={cn( "grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_1fr_auto] gap-2 md:gap-3 px-4 py-3 items-center transition-all", highlightedUserId === u.id && "bg-primary/10 ring-2 ring-primary", )} data-testid={`user-row-${u.id}`} >
{u.username} {!u.isActive && ( {t("admin.users.statusInactive")} )}
{(u.displayNameAr || u.displayNameEn) && (
{u.displayNameAr || u.displayNameEn}
)} {(() => { const userLabel = (lang === "ar" ? u.displayNameAr || u.displayNameEn : u.displayNameEn || u.displayNameAr) || u.username; const parts: Array<{ label: string; target: DependencyTarget; testid: string; }> = []; if ((u.noteCount ?? 0) > 0) parts.push({ label: t("admin.users.counts.notes", { count: u.noteCount }), target: { kind: "userNotes", userId: u.id, userLabel }, testid: `user-counts-notes-${u.id}`, }); if ((u.orderCount ?? 0) > 0) parts.push({ label: t("admin.users.counts.orders", { count: u.orderCount }), target: { kind: "userOrders", userId: u.id, userLabel }, testid: `user-counts-orders-${u.id}`, }); if (parts.length === 0) return null; return (
{parts.map((p, i) => ( {i > 0 && } ))}
); })()}
{u.email}
{u.preferredLanguage && (
{u.preferredLanguage}
)}
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "—"}
{u.groups?.length ? u.groups.map((g) => ( {g.name} )) : }
updateUser.mutate( { id: u.id, data: { isActive: v } }, { onSuccess: invalidateUsers }, ) } /> {u.id !== currentUserId && ( )}
))} {filtered.length === 0 && (
{t("admin.users.empty")}
)}
{newUserOpen && (

{t("admin.addUser")}

setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" />
setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" />
{(groups ?? []).length === 0 && (
{t("admin.groups.empty")}
)} {(groups ?? []).map((g) => { const checked = newUserForm.groupIds.includes(g.id); return ( ); })}
)} {editingUser && ( setEditingUser(null)} onSaved={() => { invalidateUsers(); setEditingUser(null); }} onOpenAuditLogForTarget={onOpenAuditLogForTarget} /> )} {userDeleteTarget && (() => { const target = userDeleteTarget; const items: string[] = []; if (userDeleteConflict) { if (userDeleteConflict.noteCount > 0) items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount })); if (userDeleteConflict.orderCount > 0) items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount })); } return ( performUserDelete(target, userDeleteConflict !== null)} testId="user-delete-dialog" confirmTestId="user-delete-confirm" /> ); })()} {dependencyTarget && ( setDependencyTarget(null)} /> )} ); } type UserEditChangeKey = | "displayNameAr" | "displayNameEn" | "preferredLanguage" | "isActive"; function UserGroupsEditor({ user, groups, onClose, onSaved, onOpenAuditLogForTarget, }: { user: UserProfile; groups: Group[]; onClose: () => void; onSaved: () => void; onOpenAuditLogForTarget: OpenAuditLogForTarget; }) { const { t, i18n } = useTranslation(); const { toast } = useToast(); const updateUser = useUpdateUser(); const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() }, }); const original = useMemo( () => ({ groupIds: new Set(user.groups?.map((g) => g.id) ?? []), displayNameAr: user.displayNameAr ?? "", displayNameEn: user.displayNameEn ?? "", preferredLanguage: user.preferredLanguage || "ar", isActive: user.isActive, }), [user.id], ); const [selected, setSelected] = useState>(new Set(original.groupIds)); const [displayNameAr, setDisplayNameAr] = useState(original.displayNameAr); const [displayNameEn, setDisplayNameEn] = useState(original.displayNameEn); const [preferredLanguage, setPreferredLanguage] = useState(original.preferredLanguage); const [isActive, setIsActive] = useState(original.isActive); const [groupSearch, setGroupSearch] = useState(""); const [reviewOpen, setReviewOpen] = useState(false); // Used to send focus back to the Save button when the inner Review // dialog closes. Radix's automatic focus restoration is unreliable // for nested dialogs, so we steer it explicitly via onCloseAutoFocus. const saveBtnRef = useRef(null); // Capture whatever was focused (typically the pencil edit button) at // the moment this editor first renders, so we can return focus there // when the editor closes. We must read this *during* render — by the // time a useEffect runs, Radix's FocusScope has already moved focus // inside the dialog. useState's lazy initializer gives us a stable, // mount-time snapshot. const [triggerElement] = useState(() => { if (typeof document === "undefined") return null; const active = document.activeElement; return active instanceof HTMLElement && active !== document.body ? active : null; }); const toggle = (id: number) => { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); }; const visibleGroups = groups.filter((g) => !groupSearch.trim() || g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()), ); const groupNameById = useMemo(() => { const map = new Map(); for (const g of groups) map.set(g.id, g.name); return map; }, [groups]); const fieldChanges = useMemo(() => { const out: { key: UserEditChangeKey; before: unknown; after: unknown }[] = []; if (displayNameAr.trim() !== original.displayNameAr.trim()) { out.push({ key: "displayNameAr", before: original.displayNameAr, after: displayNameAr }); } if (displayNameEn.trim() !== original.displayNameEn.trim()) { out.push({ key: "displayNameEn", before: original.displayNameEn, after: displayNameEn }); } if (preferredLanguage !== original.preferredLanguage) { out.push({ key: "preferredLanguage", before: original.preferredLanguage, after: preferredLanguage }); } if (isActive !== original.isActive) { out.push({ key: "isActive", before: original.isActive, after: isActive }); } return out; }, [displayNameAr, displayNameEn, preferredLanguage, isActive, original]); const groupDelta = useMemo(() => { const added: string[] = []; const removed: string[] = []; for (const id of selected) { if (!original.groupIds.has(id)) added.push(groupNameById.get(id) ?? `#${id}`); } for (const id of original.groupIds) { if (!selected.has(id)) removed.push(groupNameById.get(id) ?? `#${id}`); } return { added, removed }; }, [selected, original.groupIds, groupNameById]); const hasChanges = fieldChanges.length > 0 || groupDelta.added.length > 0 || groupDelta.removed.length > 0; const fieldLabel = (key: UserEditChangeKey): string => { switch (key) { case "displayNameAr": return t("admin.users.col.displayNameAr"); case "displayNameEn": return t("admin.users.col.displayNameEn"); case "preferredLanguage": return t("admin.users.col.language"); case "isActive": return t("admin.users.statusActive"); } }; const renderValue = (key: UserEditChangeKey, value: unknown): string => { if (key === "isActive") { return value ? t("admin.users.statusActive") : t("admin.users.statusInactive"); } if (key === "preferredLanguage") { return value === "ar" ? "العربية" : "English"; } const str = typeof value === "string" ? value.trim() : String(value ?? ""); return str === "" ? t("common.empty") : str; }; const handleConfirmSave = () => { updateUser.mutate( { id: user.id, data: { groupIds: Array.from(selected), displayNameAr: displayNameAr.trim() || null, displayNameEn: displayNameEn.trim() || null, preferredLanguage, isActive, }, }, { onSuccess: () => { toast({ title: t("common.success") }); setReviewOpen(false); onSaved(); }, onError: () => toast({ title: t("common.error"), variant: "destructive" }), }, ); }; return ( { if (!o) onClose(); }} > { // Return focus to the trigger that opened the editor (e.g. the // pencil button). Radix's default restoration is unreliable // because the parent re-renders on close. if (triggerElement && document.body.contains(triggerElement)) { e.preventDefault(); triggerElement.focus(); } }} > {t("admin.users.editUserGroups", { username: user.username })}
setDisplayNameAr(e.target.value)} className="bg-white/70 border-slate-200" data-testid="edit-user-display-ar" />
setDisplayNameEn(e.target.value)} className="bg-white/70 border-slate-200" data-testid="edit-user-display-en" />
setGroupSearch(e.target.value)} className="bg-white/70 border-slate-200 mt-1 mb-2" data-testid="edit-user-group-search" />
{visibleGroups.map((g) => ( ))} {visibleGroups.length === 0 &&

{t("admin.groups.empty")}

}
ck === "user.roles" ? t("admin.users.history.kind.roles") : t("admin.users.history.kind.groups") } labelResolver={(ck, id) => { if (ck === "user.groups") { return groupNameById.get(id) ?? `#${id}`; } const r = (roles ?? []).find((x) => x.id === id); return r ? r.name : `#${id}`; }} /> onOpenAuditLogForTarget("user", user.id)} /> { // Nested-dialog focus restoration is unreliable in Radix: // the outer Dialog's FocusScope can swallow the restore. // Steer focus back to the Save button that opened us. e.preventDefault(); saveBtnRef.current?.focus(); }} > {t("admin.users.review.title")} {t("admin.users.review.subtitle", { username: user.username })}
    {fieldChanges.map((c) => (
  • {fieldLabel(c.key)}: {renderValue(c.key, c.before)} {renderValue(c.key, c.after)}
  • ))}
{groupDelta.added.length > 0 && (

{t("admin.users.review.groupsAdded")}

{groupDelta.added.map((name) => ( {name} ))}
)} {groupDelta.removed.length > 0 && (

{t("admin.users.review.groupsRemoved")}

{groupDelta.removed.map((name) => ( {name} ))}
)}
); } // ===== Groups Management Panel ===== function GroupsPanel({ onOpenAuditLogForTarget, }: { onOpenAuditLogForTarget: OpenAuditLogForTarget; }) { const { t } = useTranslation(); const { toast } = useToast(); const queryClient = useQueryClient(); const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } }); const createGroup = useCreateGroup(); const deleteGroup = useDeleteGroup(); const [editingGroupId, setEditingGroupId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); const [groupSearch, setGroupSearch] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [conflictCounts, setConflictCounts] = useState<{ memberCount: number; appCount: number; roleCount: number; } | null>(null); const invalidate = () => queryClient.invalidateQueries({ queryKey: getListGroupsQueryKey() }); const closeDeleteDialog = () => { setDeleteTarget(null); setConflictCounts(null); }; const performDelete = (group: Group, force: boolean) => { deleteGroup.mutate( { id: group.id, params: force ? { force: true } : undefined }, { onSuccess: () => { invalidate(); toast({ title: t("common.success") }); closeDeleteDialog(); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409 && err.data) { const data = err.data as GroupDeletionConflict; setConflictCounts({ memberCount: data.memberCount, appCount: data.appCount, roleCount: data.roleCount, }); return; } toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const visibleGroups = (groups ?? []).filter((g) => !groupSearch.trim() || g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()) || (g.descriptionAr ?? "").toLowerCase().includes(groupSearch.trim().toLowerCase()) || (g.descriptionEn ?? "").toLowerCase().includes(groupSearch.trim().toLowerCase()), ); return (
setGroupSearch(e.target.value)} className="bg-white/70 border-slate-200 flex-1" data-testid="group-search" />
{visibleGroups.map((g) => (
{g.name} {g.isSystem && ( {t("admin.groups.system")} )}
{(g.descriptionAr || g.descriptionEn) && (

{g.descriptionEn || g.descriptionAr}

)}
{!g.isSystem && ( )}
{t("admin.groups.members", { count: g.memberCount })} {t("admin.groups.appsCount", { count: g.appCount })} {t("admin.groups.rolesCount", { count: g.roleCount })}
))} {visibleGroups.length === 0 && (
{t("admin.groups.empty")}
)}
{createOpen && (

{t("admin.groups.addGroup")}

setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" />
setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />
)} {editingGroupId != null && ( setEditingGroupId(null)} onSaved={() => { invalidate(); queryClient.invalidateQueries({ queryKey: getGetGroupQueryKey(editingGroupId) }); setEditingGroupId(null); }} onOpenAuditLogForTarget={onOpenAuditLogForTarget} /> )} {deleteTarget && (() => { const counts = conflictCounts ?? { memberCount: deleteTarget.memberCount, appCount: deleteTarget.appCount, roleCount: deleteTarget.roleCount, }; const isNonEmpty = counts.memberCount > 0 || counts.appCount > 0 || counts.roleCount > 0; const showWarning = conflictCounts !== null || isNonEmpty; return (

{t("admin.groups.deleteTitle", { name: deleteTarget.name })}

{showWarning ? ( <>

{t("admin.groups.deleteWarning")}

  • {t("admin.groups.members", { count: counts.memberCount })}
  • {t("admin.groups.appsCount", { count: counts.appCount })}
  • {t("admin.groups.rolesCount", { count: counts.roleCount })}

{t("admin.groups.deleteForceHint")}

) : (

{t("admin.groups.deleteEmptyBody")}

)}
); })()}
); } function GroupDetailEditor({ groupId, onClose, onSaved, onOpenAuditLogForTarget, }: { groupId: number; onClose: () => void; onSaved: () => void; onOpenAuditLogForTarget: OpenAuditLogForTarget; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const { toast } = useToast(); const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } }); 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(); const [name, setName] = useState(""); const [descriptionAr, setDescriptionAr] = useState(""); const [descriptionEn, setDescriptionEn] = useState(""); const [appIds, setAppIds] = useState>(new Set()); const [userIds, setUserIds] = useState>(new Set()); const [roleIds, setRoleIds] = useState>(new Set()); const [tab, setTab] = useState< "info" | "apps" | "users" | "roles" | "history" >("info"); const [userSearch, setUserSearch] = useState(""); const visibleUsers = (users ?? []).filter((u) => { const q = userSearch.trim().toLowerCase(); if (!q) return true; return ( u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) || (u.displayNameAr ?? "").toLowerCase().includes(q) || (u.displayNameEn ?? "").toLowerCase().includes(q) ); }); useEffect(() => { if (group) { setName(group.name); setDescriptionAr(group.descriptionAr ?? ""); setDescriptionEn(group.descriptionEn ?? ""); setAppIds(new Set(group.appIds)); setUserIds(new Set(group.userIds)); setRoleIds(new Set(group.roleIds)); } }, [group]); const toggle = (set: Set, id: number, setter: (s: Set) => void) => { const next = new Set(set); if (next.has(id)) next.delete(id); else next.add(id); setter(next); }; if (!group) { return (
); } return (

{t("admin.groups.editGroup")}: {group.name}

{group.isSystem && ( {t("admin.groups.system")} )}
{(["info", "apps", "users", "roles", "history"] as const).map((k) => ( ))}
{tab === "info" && (
setName(e.target.value)} disabled={group.isSystem} className="bg-white/70 border-slate-200" />
setDescriptionAr(e.target.value)} className="bg-white/70 border-slate-200" dir="rtl" />
setDescriptionEn(e.target.value)} className="bg-white/70 border-slate-200" dir="ltr" />
)} {tab === "apps" && (

{t("admin.groups.appsHint")}

{apps?.map((a) => ( ))}
)} {tab === "roles" && (

{t("admin.groups.rolesHint")}

{roles?.map((r) => { const primaryDesc = lang === "ar" ? r.descriptionAr : r.descriptionEn; const secondaryDesc = lang === "ar" ? r.descriptionEn : r.descriptionAr; const secondaryDir = lang === "ar" ? "ltr" : "rtl"; const heading = primaryDesc || secondaryDesc || r.name; const showSecondary = !!(primaryDesc && secondaryDesc); return (
{r.descriptionAr &&
{r.descriptionAr}
} {r.descriptionEn &&
{r.descriptionEn}
}
{r.name}
); })} {(!roles || roles.length === 0) && (

{t("admin.groups.rolesEmpty")}

)}
)} {tab === "users" && (

{t("admin.groups.usersHint")}

setUserSearch(e.target.value)} className="bg-white/70 border-slate-200" data-testid="group-user-search" />
{visibleUsers.map((u) => ( ))} {visibleUsers.length === 0 && (

{t("admin.users.empty")}

)}
)} {tab === "history" && ( <> ck === "group.users" ? t("admin.groups.history.kind.users") : ck === "group.roles" ? t("admin.groups.history.kind.roles") : t("admin.groups.history.kind.apps") } labelResolver={(ck, id) => { if (ck === "group.users") { const u = (users ?? []).find((x) => x.id === id); return u ? u.username : `#${id}`; } if (ck === "group.roles") { const r = (roles ?? []).find((x) => x.id === id); return r ? r.name : `#${id}`; } const a = (apps ?? []).find((x) => x.id === id); return a ? (lang === "ar" ? a.nameAr : a.nameEn) : `#${id}`; }} /> { onClose(); onOpenAuditLogForTarget("group", group.id); }} /> )}
); } const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]); function permissionLabel( id: number, permissionsById: Map, ): string { const p = permissionsById.get(id); return p ? p.name : `#${id}`; } const ROLE_HISTORY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const ROLE_HISTORY_PAGE_SIZE = 10; function RolePermissionHistory({ roleId, enabled, permissionsById, language, }: { roleId: number; enabled: boolean; permissionsById: Map; language: string; }) { const { t } = useTranslation(); // Two layers of state: the live values (typed in by the admin) // and the "applied" values that are actually fed into the query. The // dropdown for the actor user is applied immediately because picking a // different person is the natural way to commit; the date inputs only // commit on the explicit Apply button so partial date typing doesn't // refetch on every keystroke. const [actorUserId, setActorUserId] = useState(""); const [fromInput, setFromInput] = useState(""); const [toInput, setToInput] = useState(""); const [appliedFrom, setAppliedFrom] = useState(""); const [appliedTo, setAppliedTo] = useState(""); // True offset pagination: the first page comes from the React Query cache // (so live updates from PUT /roles/:id/permissions invalidations can // refresh it), and subsequent pages are fetched imperatively and appended // to `extraEntries`. We track the next offset to request, plus per-page // load state, so admins can keep paging back through arbitrarily long // histories without a hard ceiling. const [extraEntries, setExtraEntries] = useState([]); const [extraNextOffset, setExtraNextOffset] = useState(null); const [hasExtras, setHasExtras] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(null); // CSV export state for the "Download CSV" button (#205). The download // is independent of the paginated list shown above — it always asks // the server for ALL rows matching the current filters (subject to // a server-side cap), so the spreadsheet doesn't only contain the // pages the admin already scrolled through. const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); const [downloadCsvError, setDownloadCsvError] = useState(null); // Reset filters and pagination whenever the dialog switches to a // different role so an admin doesn't see stale filters carried over. useEffect(() => { setActorUserId(""); setFromInput(""); setToInput(""); setAppliedFrom(""); setAppliedTo(""); setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); setDownloadCsvError(null); setIsDownloadingCsv(false); }, [roleId]); const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom); const toValid = !appliedTo || ROLE_HISTORY_DATE_RE.test(appliedTo); const orderValid = !appliedFrom || !appliedTo || appliedFrom <= appliedTo; const filtersValid = fromValid && toValid && orderValid; const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey(), enabled }, }); const sortedUsers = useMemo(() => { const list = (users ?? []).slice(); list.sort((a, b) => { const aLabel = (language === "ar" ? a.displayNameAr ?? a.displayNameEn : a.displayNameEn ?? a.displayNameAr) ?? a.username; const bLabel = (language === "ar" ? b.displayNameAr ?? b.displayNameEn : b.displayNameEn ?? b.displayNameAr) ?? b.username; return aLabel.localeCompare(bLabel); }); return list; }, [users, language]); // First-page params — only this page lives in React Query so cache // invalidations after a permission save naturally refresh it. The other // pages are fetched imperatively below. const firstPageParams: GetRolePermissionAuditParams = { limit: ROLE_HISTORY_PAGE_SIZE, offset: 0, ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), }; const { data, isLoading, isFetching } = useGetRolePermissionAudit( roleId, firstPageParams, { query: { queryKey: getGetRolePermissionAuditQueryKey(roleId, firstPageParams), enabled: enabled && filtersValid, }, }, ); // When the first page changes (filters applied) wipe the appended extra // pages so we don't end up with overlapping or out-of-order rows. const filtersKey = JSON.stringify(firstPageParams); useEffect(() => { setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [filtersKey]); // Belt-and-braces: also reset the appended pages whenever React Query // refetches the first page under the same filter key (e.g. an external // cache invalidation triggered by a permission save while the dialog // is still open). Without this, the appended pages could drift out of // sync with the refreshed first page. const firstPageSignature = data ? `${data.totalCount}|${data.entries[0]?.id ?? ""}` : null; useEffect(() => { if (firstPageSignature == null) return; setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [firstPageSignature]); const firstPageEntries = data?.entries ?? []; const totalCount = data?.totalCount ?? 0; const entries = hasExtras ? [...firstPageEntries, ...extraEntries] : firstPageEntries; const showingCount = entries.length; const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; const hasMore = nextOffset !== null; const applyFilters = () => { setAppliedFrom(fromInput.trim()); setAppliedTo(toInput.trim()); // Pagination state is reset by the filtersKey effect above. }; const resetFilters = () => { setActorUserId(""); setFromInput(""); setToInput(""); setAppliedFrom(""); setAppliedTo(""); }; const loadMore = async () => { if (nextOffset === null || isLoadingMore) return; setIsLoadingMore(true); setLoadMoreError(null); try { const next = await getRolePermissionAudit(roleId, { ...firstPageParams, offset: nextOffset, }); setExtraEntries((prev) => [...prev, ...next.entries]); setExtraNextOffset(next.nextOffset); setHasExtras(true); } catch (e) { setLoadMoreError( (e as { message?: string }).message ?? t("common.error"), ); } finally { setIsLoadingMore(false); } }; // CSV export handler (#205). Goes via plain `fetch` (with credentials) // rather than the generated client because we want a Blob download // triggered through a synthetic element — the generated // wrapper unwraps the body which makes filename / Content-Type harder // to use. We reuse the generated URL builder so the route + query // serialization stays in lock-step with the OpenAPI spec. `limit` / // `offset` are intentionally NOT forwarded — the backend exports all // matching rows up to its own cap. const downloadCsv = async () => { if (!filtersValid || isDownloadingCsv) return; setIsDownloadingCsv(true); setDownloadCsvError(null); try { const url = getExportRolePermissionAuditCsvUrl(roleId, { ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), }); const res = await fetch(url, { method: "GET", credentials: "include", headers: { Accept: "text/csv" }, }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const blob = await res.blob(); // Honor the server-provided filename when present so admins // get the role name in the file (server sanitizes it). Fall // back to a timestamped default for any unexpected response. const disposition = res.headers.get("Content-Disposition") ?? ""; const match = /filename="?([^";]+)"?/i.exec(disposition); const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const filename = match?.[1] ?? `role-${roleId}-history-${stamp}.csv`; const objectUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objectUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); // Defer revoke so the browser can finish the download. setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); } catch (e) { setDownloadCsvError( (e as { message?: string }).message ?? t("admin.roles.historyDownloadCsvError"), ); } finally { setIsDownloadingCsv(false); } }; const filtersActive = actorUserId !== "" || appliedFrom !== "" || appliedTo !== ""; const filterErrors: string[] = []; if (!fromValid || !toValid) filterErrors.push(t("admin.roles.historyErrors.invalidDate")); else if (!orderValid) filterErrors.push(t("admin.roles.historyErrors.invertedDates")); return (

{t("admin.roles.historyHint")}

setFromInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid="role-history-from-input" />
setToInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid="role-history-to-input" />
{filterErrors.map((msg, i) => (

{msg}

))}
{filtersValid ? t("admin.roles.historyShowing", { shown: showingCount, total: totalCount, }) : t("admin.roles.historyErrors.fixFiltersFirst")}
{!filtersValid ? ( // Hide stale entries while the filter inputs are invalid so the // list can't be confused with the (now wrong) filtered results.

{t("admin.roles.historyErrors.fixFiltersFirst")}

) : isLoading ? (
) : entries.length === 0 ? (

{filtersActive ? t("admin.roles.historyEmptyFiltered") : t("admin.roles.historyEmpty")}

) : ( entries.map((entry) => { const actor = entry.actor; const ar = actor?.displayNameAr ?? null; const en = actor?.displayNameEn ?? null; const preferred = language === "ar" ? ar ?? en : en ?? ar; const actorName = actor && (preferred?.trim() ? preferred : actor.username); return (
{actorName ?? t("admin.roles.historyActorUnknown")} {formatAuditTimestamp(entry.createdAt, language)}
{entry.addedPermissionIds.length > 0 && (
{t("admin.roles.historyAdded", { count: entry.addedPermissionIds.length, })} {" "} {entry.addedPermissionIds .map((id) => permissionLabel(id, permissionsById)) .join(", ")}
)} {entry.removedPermissionIds.length > 0 && (
{t("admin.roles.historyRemoved", { count: entry.removedPermissionIds.length, })} {" "} {entry.removedPermissionIds .map((id) => permissionLabel(id, permissionsById)) .join(", ")}
)}
{t("admin.roles.historyTotal", { count: entry.newPermissionIds.length, })}
); }) )}
{filtersValid && (hasMore || entries.length > 0) && (
{hasMore && ( )} {entries.length > 0 && ( )}
)} {loadMoreError && (

{loadMoreError}

)} {downloadCsvError && (

{t("admin.roles.historyDownloadCsvError")}

)}
); } // --------------------------------------------------------------------------- // PermissionAuditHistory // // Generalized version of RolePermissionHistory that powers the "History" // section embedded into the User, Group and App admin dialogs. The shape of // the audit endpoint is identical across the three entities (see // permission_audit table); the only thing that varies is which generated // hook to call and how to render id labels for each changeKind. We always // call all three React Query hooks (rules-of-hooks) but only the one // matching `targetKind` is enabled. // --------------------------------------------------------------------------- type PermissionAuditScope = "user" | "group" | "app"; type PermAuditLabelResolver = ( changeKind: PermissionAuditEntryChangeKind, id: number, ) => string; function PermissionAuditHistory({ targetKind, targetId, enabled, language, scopeI18nKey, labelResolver, changeKindLabel, }: { targetKind: PermissionAuditScope; targetId: number; enabled: boolean; language: string; // Translation prefix, e.g. "admin.users.history" / "admin.groups.history" // / "admin.apps.history". Mirrors `admin.roles.history*` keys so we get // bilingual support for free without forking copy per call site. scopeI18nKey: "admin.users.history" | "admin.groups.history" | "admin.apps.history"; labelResolver: PermAuditLabelResolver; // Group history mixes user/role/app changes — the caller can render a // small badge per entry indicating which set was changed. Pass `null` // for users/apps where the change kind is fixed. changeKindLabel?: ((changeKind: PermissionAuditEntryChangeKind) => string) | null; }) { const { t } = useTranslation(); const testIdRoot = `${targetKind}-history`; const [actorUserId, setActorUserId] = useState(""); const [fromInput, setFromInput] = useState(""); const [toInput, setToInput] = useState(""); const [appliedFrom, setAppliedFrom] = useState(""); const [appliedTo, setAppliedTo] = useState(""); const [extraEntries, setExtraEntries] = useState([]); const [extraNextOffset, setExtraNextOffset] = useState(null); const [hasExtras, setHasExtras] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadMoreError, setLoadMoreError] = useState(null); useEffect(() => { setActorUserId(""); setFromInput(""); setToInput(""); setAppliedFrom(""); setAppliedTo(""); setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [targetKind, targetId]); const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom); const toValid = !appliedTo || ROLE_HISTORY_DATE_RE.test(appliedTo); const orderValid = !appliedFrom || !appliedTo || appliedFrom <= appliedTo; const filtersValid = fromValid && toValid && orderValid; const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey(), enabled }, }); const sortedUsers = useMemo(() => { const list = (users ?? []).slice(); list.sort((a, b) => { const aLabel = (language === "ar" ? a.displayNameAr ?? a.displayNameEn : a.displayNameEn ?? a.displayNameAr) ?? a.username; const bLabel = (language === "ar" ? b.displayNameAr ?? b.displayNameEn : b.displayNameEn ?? b.displayNameAr) ?? b.username; return aLabel.localeCompare(bLabel); }); return list; }, [users, language]); const firstPageParams: GetUserPermissionAuditParams = { limit: ROLE_HISTORY_PAGE_SIZE, offset: 0, ...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}), ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), }; // We must call all three hooks to satisfy rules-of-hooks; the // `enabled` flag ensures only the one matching `targetKind` actually // fires a request. const userQuery = useGetUserPermissionAudit(targetId, firstPageParams, { query: { queryKey: getGetUserPermissionAuditQueryKey(targetId, firstPageParams), enabled: enabled && filtersValid && targetKind === "user", }, }); const groupQuery = useGetGroupPermissionAudit( targetId, firstPageParams as GetGroupPermissionAuditParams, { query: { queryKey: getGetGroupPermissionAuditQueryKey( targetId, firstPageParams as GetGroupPermissionAuditParams, ), enabled: enabled && filtersValid && targetKind === "group", }, }, ); const appQuery = useGetAppPermissionAudit( targetId, firstPageParams as GetAppPermissionAuditParams, { query: { queryKey: getGetAppPermissionAuditQueryKey( targetId, firstPageParams as GetAppPermissionAuditParams, ), enabled: enabled && filtersValid && targetKind === "app", }, }, ); const activeQuery = targetKind === "user" ? userQuery : targetKind === "group" ? groupQuery : appQuery; const { data, isLoading, isFetching } = activeQuery; const filtersKey = JSON.stringify(firstPageParams); useEffect(() => { setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [filtersKey]); const firstPageSignature = data ? `${data.totalCount}|${data.entries[0]?.id ?? ""}` : null; useEffect(() => { if (firstPageSignature == null) return; setExtraEntries([]); setExtraNextOffset(null); setHasExtras(false); setLoadMoreError(null); setIsLoadingMore(false); }, [firstPageSignature]); const firstPageEntries = data?.entries ?? []; const totalCount = data?.totalCount ?? 0; const entries = hasExtras ? [...firstPageEntries, ...extraEntries] : firstPageEntries; const showingCount = entries.length; const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null; const hasMore = nextOffset !== null; const applyFilters = () => { setAppliedFrom(fromInput.trim()); setAppliedTo(toInput.trim()); }; const resetFilters = () => { setActorUserId(""); setFromInput(""); setToInput(""); setAppliedFrom(""); setAppliedTo(""); }; const loadMore = async () => { if (nextOffset === null || isLoadingMore) return; setIsLoadingMore(true); setLoadMoreError(null); try { const params = { ...firstPageParams, offset: nextOffset }; const next = targetKind === "user" ? await getUserPermissionAudit(targetId, params) : targetKind === "group" ? await getGroupPermissionAudit(targetId, params) : await getAppPermissionAudit(targetId, params); setExtraEntries((prev) => [...prev, ...next.entries]); setExtraNextOffset(next.nextOffset); setHasExtras(true); } catch (e) { setLoadMoreError( (e as { message?: string }).message ?? t("common.error"), ); } finally { setIsLoadingMore(false); } }; const filtersActive = actorUserId !== "" || appliedFrom !== "" || appliedTo !== ""; const filterErrors: string[] = []; if (!fromValid || !toValid) filterErrors.push(t("admin.roles.historyErrors.invalidDate")); else if (!orderValid) filterErrors.push(t("admin.roles.historyErrors.invertedDates")); return (

{t(`${scopeI18nKey}Hint`)}

setFromInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid={`${testIdRoot}-from-input`} />
setToInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid={`${testIdRoot}-to-input`} />
{filterErrors.map((msg, i) => (

{msg}

))}
{filtersValid ? t("admin.roles.historyShowing", { shown: showingCount, total: totalCount, }) : t("admin.roles.historyErrors.fixFiltersFirst")}
{!filtersValid ? (

{t("admin.roles.historyErrors.fixFiltersFirst")}

) : isLoading ? (
) : entries.length === 0 ? (

{filtersActive ? t(`${scopeI18nKey}EmptyFiltered`) : t(`${scopeI18nKey}Empty`)}

) : ( entries.map((entry) => { const actor = entry.actor; const ar = actor?.displayNameAr ?? null; const en = actor?.displayNameEn ?? null; const preferred = language === "ar" ? ar ?? en : en ?? ar; const actorName = actor && (preferred?.trim() ? preferred : actor.username); const ck = entry.changeKind; return (
{actorName ?? t("admin.roles.historyActorUnknown")} {formatAuditTimestamp(entry.createdAt, language)}
{changeKindLabel && (
{changeKindLabel(ck)}
)} {entry.addedIds.length > 0 && (
{t(`${scopeI18nKey}Added`, { count: entry.addedIds.length, })} {" "} {entry.addedIds .map((id) => labelResolver(ck, id)) .join(", ")}
)} {entry.removedIds.length > 0 && (
{t(`${scopeI18nKey}Removed`, { count: entry.removedIds.length, })} {" "} {entry.removedIds .map((id) => labelResolver(ck, id)) .join(", ")}
)}
{t(`${scopeI18nKey}Total`, { count: entry.newIds.length, })}
); }) )}
{filtersValid && hasMore && (
)} {loadMoreError && (

{loadMoreError}

)}
); } function RolesPanel({ onOpenAuditLogForTarget, }: { onOpenAuditLogForTarget: OpenAuditLogForTarget; }) { const { t, i18n } = useTranslation(); const { toast } = useToast(); const queryClient = useQueryClient(); const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } }); const { data: allPermissions } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey() }, }); const createRole = useCreateRole(); const updateRole = useUpdateRole(); const deleteRole = useDeleteRole(); const replaceRolePermissions = useReplaceRolePermissions(); const previewRolePermissionsImpact = usePreviewRolePermissionsImpact(); const [search, setSearch] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); const [editingId, setEditingId] = useState(null); const [editingIsSystem, setEditingIsSystem] = useState(false); const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); const [originalEditName, setOriginalEditName] = useState(""); const [editPermissionIds, setEditPermissionIds] = useState>(new Set()); const [initialPermissionIds, setInitialPermissionIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null); const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null); const [permissionsImpact, setPermissionsImpact] = useState(null); const [impactLoading, setImpactLoading] = useState(false); const [impactError, setImpactError] = useState(false); const [confirmRemoval, setConfirmRemoval] = useState(false); const impactRequestSeq = useRef(0); const editingPermissionsId = editingId ?? 0; const { data: editingRolePermissions, isLoading: editingPermsLoading } = useGetRolePermissions( editingPermissionsId, { query: { queryKey: getGetRolePermissionsQueryKey(editingPermissionsId), enabled: editingId != null, }, }, ); const { data: editingRoleUsage } = useGetRoleUsage(editingPermissionsId, { query: { queryKey: getGetRoleUsageQueryKey(editingPermissionsId), enabled: editingId != null && !editingIsSystem, }, }); const permissionsById = useMemo(() => { const map = new Map(); for (const p of allPermissions ?? []) map.set(p.id, p); return map; }, [allPermissions]); useEffect(() => { if (editingId == null) return; if (!editingRolePermissions) return; const ids = new Set(editingRolePermissions.map((p) => p.id)); setEditPermissionIds(ids); setInitialPermissionIds(ids); }, [editingId, editingRolePermissions]); const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() }); const visibleRoles = (roles ?? []).filter((r) => { const q = search.trim().toLowerCase(); if (!q) return true; return ( r.name.toLowerCase().includes(q) || (r.descriptionAr ?? "").toLowerCase().includes(q) || (r.descriptionEn ?? "").toLowerCase().includes(q) ); }); const handleCreate = () => { const name = createForm.name.trim(); if (!name) return; createRole.mutate( { data: { name, descriptionAr: createForm.descriptionAr.trim() || null, descriptionEn: createForm.descriptionEn.trim() || null, }, }, { onSuccess: () => { invalidate(); setCreateOpen(false); setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" }); toast({ title: t("common.success") }); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409) { toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" }); return; } if (err instanceof ApiError && err.status === 400) { toast({ title: t("admin.roles.errorInvalidName"), variant: "destructive" }); return; } toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const beginEdit = (role: { id: number; name: string; isSystem: boolean; descriptionAr?: string | null; descriptionEn?: string | null; }) => { setEditingId(role.id); setEditingIsSystem(role.isSystem || ROLES_PROTECTED_NAMES.has(role.name)); setEditForm({ name: role.name, descriptionAr: role.descriptionAr ?? "", descriptionEn: role.descriptionEn ?? "", }); setOriginalEditName(role.name); }; const closeEditDialog = () => { setEditingId(null); setEditPermissionIds(new Set()); setInitialPermissionIds(new Set()); setOriginalEditName(""); setPermissionsImpact(null); setImpactLoading(false); setImpactError(false); setConfirmRemoval(false); }; const editNameTrimmed = editForm.name.trim(); const isRenaming = !editingIsSystem && originalEditName.length > 0 && editNameTrimmed.length > 0 && editNameTrimmed !== originalEditName; const permissionsChanged = (() => { if (editPermissionIds.size !== initialPermissionIds.size) return true; for (const id of editPermissionIds) { if (!initialPermissionIds.has(id)) return true; } return false; })(); const removedPermissionIds = useMemo(() => { const removed: number[] = []; for (const id of initialPermissionIds) { if (!editPermissionIds.has(id)) removed.push(id); } return removed; }, [editPermissionIds, initialPermissionIds]); const hasRemovals = removedPermissionIds.length > 0; // Debounced on-demand fetch of impact preview when removals are present. useEffect(() => { if (editingId == null) return; if (editingIsSystem) return; if (!hasRemovals) { setPermissionsImpact(null); setImpactLoading(false); setImpactError(false); return; } setImpactLoading(true); setImpactError(false); const editingRoleId = editingId; const candidate = Array.from(editPermissionIds); const requestId = ++impactRequestSeq.current; const handle = setTimeout(() => { previewRolePermissionsImpact.mutate( { id: editingRoleId, data: { permissionIds: candidate } }, { onSuccess: (data) => { if (impactRequestSeq.current !== requestId) return; setPermissionsImpact(data); setImpactError(false); setImpactLoading(false); }, onError: () => { if (impactRequestSeq.current !== requestId) return; setPermissionsImpact(null); setImpactError(true); setImpactLoading(false); }, }, ); }, 350); return () => clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [editingId, editingIsSystem, hasRemovals, editPermissionIds, initialPermissionIds]); const handleEdit = () => { if (editingId == null) return; if ( !editingIsSystem && hasRemovals && !confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 ) { setConfirmRemoval(true); return; } const editingRoleId = editingId; const name = editForm.name.trim(); if (!name) return; const savePermissionsThenClose = () => { if (editingIsSystem || !permissionsChanged) { invalidate(); closeEditDialog(); toast({ title: t("common.success") }); return; } replaceRolePermissions.mutate( { id: editingRoleId, data: { permissionIds: Array.from(editPermissionIds) }, }, { onSuccess: () => { invalidate(); queryClient.invalidateQueries({ queryKey: getGetRolePermissionsQueryKey(editingRoleId), }); // Invalidate every cached page/filter for this role's audit so // the History panel picks up the new entry no matter which // pagination/filter the admin currently has applied. queryClient.invalidateQueries({ queryKey: [`/api/roles/${editingRoleId}/audit`], }); closeEditDialog(); toast({ title: t("common.success") }); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 400) { const code = (err.data as { code?: string } | null)?.code; const key = code === "system_role_permissions" ? "admin.roles.errorSystemPermissions" : "admin.roles.errorPermissionsSave"; toast({ title: t(key), variant: "destructive" }); return; } toast({ title: t("admin.roles.errorPermissionsSave"), variant: "destructive" }); }, }, ); }; updateRole.mutate( { id: editingRoleId, data: { ...(editingIsSystem ? {} : { name }), descriptionAr: editForm.descriptionAr.trim() || null, descriptionEn: editForm.descriptionEn.trim() || null, }, }, { onSuccess: () => { savePermissionsThenClose(); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409) { toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" }); return; } if (err instanceof ApiError && err.status === 400) { const code = (err.data as { code?: string } | null)?.code; const key = code === "system_role_rename" ? "admin.roles.errorSystemRename" : "admin.roles.errorInvalidName"; toast({ title: t(key), variant: "destructive" }); return; } toast({ title: t("common.error"), variant: "destructive" }); }, }, ); }; const togglePermission = (id: number) => { if (editingIsSystem) return; setEditPermissionIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const handleDelete = () => { if (!deleteTarget) return; deleteRole.mutate( { id: deleteTarget.id }, { onSuccess: () => { invalidate(); setDeleteTarget(null); setDeleteConflict(null); toast({ title: t("common.success") }); }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 409 && err.data) { const data = err.data as { userCount: number; groupCount: number }; setDeleteConflict({ userCount: data.userCount, groupCount: data.groupCount }); return; } toast({ title: t("common.error"), variant: "destructive" }); setDeleteTarget(null); }, }, ); }; return (
setSearch(e.target.value)} className="bg-white/70 border-slate-200 flex-1" data-testid="role-search" />
{visibleRoles.map((r) => (
{r.name} {(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && ( {t("admin.roles.system")} )}
{(r.descriptionAr || r.descriptionEn) && (

{r.descriptionEn || r.descriptionAr}

)} {r.descriptionAr && r.descriptionEn && (

{r.descriptionAr}

)} {(() => { const userCount = r.userCount ?? 0; const groupCount = r.groupCount ?? 0; const parts: string[] = []; if (userCount > 0) parts.push(t("admin.roles.usersCount", { count: userCount })); if (groupCount > 0) parts.push(t("admin.roles.groupsCount", { count: groupCount })); if (parts.length === 0) return null; return (
{parts.map((p, i) => ( {i > 0 && } {p} ))}
); })()}
{!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && ( )}
))} {visibleRoles.length === 0 && (
{t("admin.roles.empty")}
)}
{createOpen && (

{t("admin.roles.addRole")}

setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" placeholder={t("admin.roles.namePlaceholder")} data-testid="role-name-input" />

{t("admin.roles.nameHint")}

setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" data-testid="role-desc-ar-input" />
setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" data-testid="role-desc-en-input" />
)} {editingId != null && (

{t("admin.roles.editRole")}

setEditForm({ ...editForm, name: e.target.value })} className="bg-white/70 border-slate-200" placeholder={t("admin.roles.namePlaceholder")} disabled={editingIsSystem} data-testid="role-edit-name" />

{editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")}

{isRenaming && (

{t("admin.roles.renameWarningTitle", { from: originalEditName, to: editNameTrimmed, })}

{t("admin.roles.renameWarningBody")}

{editingRoleUsage && (

{t("admin.roles.renameUsage", { users: editingRoleUsage.userCount, groups: editingRoleUsage.groupCount, })}

)}
)}
setEditForm({ ...editForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" data-testid="role-edit-desc-ar" />
setEditForm({ ...editForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" data-testid="role-edit-desc-en" />

{editingIsSystem ? t("admin.roles.permissionsSystemHint") : t("admin.roles.permissionsHint")}

{editingPermsLoading || !allPermissions ? (
) : allPermissions.length === 0 ? (

{t("admin.roles.permissionsEmpty")}

) : ( allPermissions.map((p) => { const checked = editPermissionIds.has(p.id); const desc = i18n.language === "ar" ? p.descriptionAr ?? p.descriptionEn : p.descriptionEn ?? p.descriptionAr; return ( ); }) )}
{!editingIsSystem && hasRemovals && (

{t("admin.roles.impactTitle", { count: removedPermissionIds.length })}

{impactLoading ? (
{t("admin.roles.impactLoading")}
) : impactError || !permissionsImpact ? (

{t("admin.roles.impactError")}

) : permissionsImpact.removed.length === 0 ? (

{t("admin.roles.impactNoUsers")}

) : ( <>
    {permissionsImpact.removed.map((item) => (
  • {item.permissionName} {": "} {t("admin.roles.impactPerPermission", { users: item.userCount, groups: item.groupCount, })} {item.groupCount > 0 && ( {" "} {t("admin.roles.impactViaGroups", { groups: item.groups.map((g) => g.name).join(", "), })} )}
  • ))}
{permissionsImpact.totalAffectedUsers > 0 && (

{t("admin.roles.impactTotal", { count: permissionsImpact.totalAffectedUsers, })}

)} )}
)}
{ closeEditDialog(); onOpenAuditLogForTarget("role", editingPermissionsId); }} />
)} {confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 && (

{t("admin.roles.confirmRemovalTitle")}

{t("admin.roles.confirmRemovalBody", { count: permissionsImpact.totalAffectedUsers, })}

    {permissionsImpact.removed.map((item) => (
  • {item.permissionName} {": "} {t("admin.roles.impactPerPermission", { users: item.userCount, groups: item.groupCount, })}
  • ))}
)} {deleteTarget && (

{t("admin.roles.deleteTitle", { name: deleteTarget.name })}

{deleteConflict ? ( <>

{t("admin.roles.deleteConflict")}

  • {t("admin.roles.usersCount", { count: deleteConflict.userCount })}
  • {t("admin.roles.groupsCount", { count: deleteConflict.groupCount })}

{t("admin.roles.deleteConflictHint")}

) : (

{t("admin.roles.deleteEmptyBody")}

)}
{!deleteConflict && ( )}
)}
); } // ===== Audit Log Panel ===== const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function formatAuditTimestamp(value: string | Date, lang: string): string { const d = value instanceof Date ? value : new Date(value); return new Intl.DateTimeFormat(lang === "ar" ? "ar-u-nu-latn" : "en-US", { dateStyle: "medium", timeStyle: "short", numberingSystem: "latn", hour12: false, }).format(d); } function actorLabel(actor: AuditLogEntry["actor"], lang: string): string { if (!actor) return "—"; const ar = actor.displayNameAr ?? null; const en = actor.displayNameEn ?? null; const preferred = lang === "ar" ? ar ?? en : en ?? ar; return preferred && preferred.trim() ? preferred : actor.username; } type AuditTFunction = ReturnType["t"]; // Map of well-known dependency-count metadata keys → unit i18n root used by // `unitLabel`. Keys not in this map are skipped from the inline chip strip. const DEPENDENCY_COUNT_KEYS: Record = { orderCount: "order", noteCount: "note", groupCount: "group", memberCount: "member", appCount: "app", roleCount: "role", openCount: "open", }; // When a dependency chip key maps to a known audit-log target_type, clicking // the chip pivots to "audit log filtered by that target_type". Other chip // keys (orders, messages, notes, conversations, opens) have no audit_logs // rows of their own, so they pivot to the parent entity's audit history // instead (target_type + target_id of the deletion row). const DEPENDENCY_CHIP_TARGET_TYPES: Record = { groupCount: "group", memberCount: "user", appCount: "app", roleCount: "role", }; type AuditTargetPivot = { targetType: string; targetId: number | null; }; function isForceDeleteEntry(entry: AuditLogEntry): boolean { const action = entry.action; if (action.endsWith(".force_delete")) return true; if (action === "user.delete" || action === "app.delete" || action === "group.delete") { const meta = asRecord(entry.metadata); return meta.force === true; } return false; } // Pulls a human-readable display name for a deleted entity from its audit // metadata so the row can show "service #4821 · My Service" or "user #1234 · // Ahmed Al-Saleh" inline without admins having to expand the JSON. Applies // to every force-delete row (where the dep counts are also useful) and to // non-force `user.delete` rows so admins can recognise the deleted person // by name even when the deletion was clean. Returns null when there is no // usable name field in the metadata. function deletedEntityName( entry: AuditLogEntry, lang: string, ): string | null { if (!isForceDeleteEntry(entry) && entry.action !== "user.delete") return null; const meta = asRecord(entry.metadata); const en = asString(meta.nameEn) ?? asString(meta.appNameEn) ?? asString(meta.displayNameEn); const ar = asString(meta.nameAr) ?? asString(meta.appNameAr) ?? asString(meta.displayNameAr); const localized = lang === "ar" ? ar ?? en : en ?? ar; if (localized) return localized; const plain = asString(meta.name) ?? asString(meta.appSlug); if (plain) return plain; const username = asString(meta.username); if (username) return `@${username}`; return null; } function dependencyChips( entry: AuditLogEntry, t: AuditTFunction, ): Array<{ key: string; label: string; pivot: AuditTargetPivot }> { const meta = asRecord(entry.metadata); const chips: Array<{ key: string; label: string; pivot: AuditTargetPivot }> = []; for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) { const value = asNumber(meta[metaKey]); if (value == null || value <= 0) continue; const mappedTargetType = DEPENDENCY_CHIP_TARGET_TYPES[metaKey]; const pivot: AuditTargetPivot = mappedTargetType ? { targetType: mappedTargetType, targetId: null } : { targetType: entry.targetType, targetId: entry.targetId ?? null }; chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value), pivot, }); } return chips; } function AuditLogRow({ entry, lang, onPivotToTarget, onPivotToActor, }: { entry: AuditLogEntry; lang: string; onPivotToTarget: (pivot: AuditTargetPivot) => void; // #197: clicking the actor name/avatar pivots the panel to "everything that // person did" — symmetrical with target-chip pivots above. onPivotToActor: (actorUserId: number) => void; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const hasMetadata = entry.metadata != null && Object.keys(entry.metadata as object).length > 0; const actor = entry.actor; const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase(); const summary = formatAuditSummary(entry, t, lang); const isForced = isForceDeleteEntry(entry); const chips = isForced ? dependencyChips(entry, t) : []; const deletedName = deletedEntityName(entry, lang); const showDeletedTarget = deletedName != null && entry.targetId != null; // #197: rows whose actor is null (system / deleted user) cannot pivot — the // server filter requires a concrete actorUserId, so we render the same // avatar + name without the click affordance. const canPivotActor = actor != null; const actorPivotAria = actor ? t("admin.audit.actorPivotAria", { name: actorLabel(actor, lang) }) : undefined; return (
{canPivotActor ? ( ) : ( )}
{canPivotActor ? ( ) : ( {actorLabel(actor, lang)} )} {actor && ( @{actor.username} )}
{summary ? (
{summary}
) : (
{showDeletedTarget ? t("admin.audit.targetWithName", { type: entry.targetType, id: entry.targetId, name: deletedName, }) : t("admin.audit.target", { type: entry.targetType, id: entry.targetId ?? "—", })}
)} {summary && showDeletedTarget && (
{t("admin.audit.targetWithName", { type: entry.targetType, id: entry.targetId, name: deletedName, })}
)}
{entry.action} {formatAuditTimestamp(entry.createdAt, lang)}
{chips.length > 0 && (
{chips.map((chip) => { const ariaLabel = chip.pivot.targetId != null ? t("admin.audit.dependencies.chipAriaParent", { label: chip.label, type: entry.targetType, id: chip.pivot.targetId, }) : t("admin.audit.dependencies.chipAriaTargetType", { label: chip.label, type: chip.pivot.targetType, }); return ( ); })}
)}
{hasMetadata && ( )}
{expanded && hasMetadata && (
          {JSON.stringify(entry.metadata, null, 2)}
        
)}
); } // #196: Inline "Recent activity" strip rendered inside each entity's detail // editor (services / apps / groups / users / roles). Reuses the same // formatAuditSummary wording as the full audit log so the two views read // consistently. The "View full history" button hands back control to the // parent so it can switch sections AND set the targetType/targetId hash — // see openAuditLogForTarget() in AdminPage for that orchestration. function RecentActivityForTarget({ targetType, targetId, enabled, lang, onViewFullHistory, }: { targetType: "app" | "service" | "user" | "group" | "role"; targetId: number; enabled: boolean; lang: string; onViewFullHistory: () => void; }) { const { t } = useTranslation(); const params: ListAuditLogsParams = { targetType, targetId, limit: 10, offset: 0, }; const queryKey = getListAuditLogsQueryKey(params); const { data, isLoading } = useListAuditLogs(params, { query: { queryKey, enabled }, }); const entries = data?.entries ?? []; const totalCount = data?.totalCount ?? 0; const testIdRoot = `recent-activity-${targetType}-${targetId}`; return (

{t("admin.audit.recent.title")}

{totalCount > entries.length && ( {t("admin.audit.showing", { shown: entries.length, total: totalCount, })} )}
{isLoading ? (
{t("admin.audit.recent.loading")}
) : entries.length === 0 ? (

{t("admin.audit.recent.empty")}

) : (
    {entries.map((entry) => { const summary = formatAuditSummary(entry, t, lang) ?? t("admin.audit.target", { type: entry.targetType, id: entry.targetId ?? "—", }); return (
  • {summary}
    {actorLabel(entry.actor, lang)} {formatAuditTimestamp(entry.createdAt, lang)}
  • ); })}
)}
); } // Parses targetType / targetId values out of the admin page URL hash so the // audit log panel can deep-link to a pre-filtered view (e.g. when an admin // clicks a dependency chip on a forced-delete row). function parseAuditHashTarget(): AuditTargetPivot { if (typeof window === "undefined") return { targetType: "", targetId: null }; const raw = window.location.hash.replace(/^#/, ""); const params = new URLSearchParams(raw); const targetType = params.get("targetType")?.trim() ?? ""; const targetIdRaw = params.get("targetId")?.trim(); let targetId: number | null = null; if (targetIdRaw) { const n = Number(targetIdRaw); if (Number.isFinite(n) && Number.isInteger(n) && n >= 1) { targetId = n; } } return { targetType, targetId }; } // Updates the admin URL hash (which carries `section=audit-log`) so the // active targetType/targetId filters survive reload and back/forward nav. // Uses replaceState to avoid polluting browser history with every tweak. function syncAuditHashTarget(pivot: AuditTargetPivot) { if (typeof window === "undefined") return; const raw = window.location.hash.replace(/^#/, ""); const params = new URLSearchParams(raw); if (pivot.targetType) { params.set("targetType", pivot.targetType); } else { params.delete("targetType"); } if (pivot.targetId != null) { params.set("targetId", String(pivot.targetId)); } else { params.delete("targetId"); } if (!params.has("section")) { params.set("section", "audit-log"); } const newHash = `#${params.toString()}`; if (window.location.hash !== newHash) { window.history.replaceState(null, "", newHash); } } // #197: same idea as parseAuditHashTarget but for the actor filter. Uses the // shorter `actorId` URL key (the deep-link / hash param called out in the // task spec) even though the API param + state are named `actorUserId`. function parseAuditHashActor(): number | null { if (typeof window === "undefined") return null; const raw = window.location.hash.replace(/^#/, ""); const params = new URLSearchParams(raw); const actorIdRaw = params.get("actorId")?.trim(); if (!actorIdRaw) return null; const n = Number(actorIdRaw); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return null; return n; } function syncAuditHashActor(actorUserId: number | null) { if (typeof window === "undefined") return; const raw = window.location.hash.replace(/^#/, ""); const params = new URLSearchParams(raw); if (actorUserId != null) { params.set("actorId", String(actorUserId)); } else { params.delete("actorId"); } if (!params.has("section")) { params.set("section", "audit-log"); } const newHash = `#${params.toString()}`; if (window.location.hash !== newHash) { window.history.replaceState(null, "", newHash); } } // #197: Combobox-style actor picker with autocomplete by username and // display name (English + Arabic). Replaces the previous flat setActionFilter(e.target.value)} disabled={forcedOnly} className={cn( "bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full", forcedOnly && "opacity-60 cursor-not-allowed", )} data-testid="audit-action-filter" > {knownActions.map((a) => ( ))}
{ setActorUserId(next); setPageLimit(50); syncAuditHashActor(next === "" ? null : next); }} placeholder={t("admin.audit.filters.allActors")} searchPlaceholder={t("admin.audit.filters.actorSearch")} emptyMessage={t("admin.audit.filters.actorEmpty")} clearLabel={t("admin.audit.filters.clearActorFilter")} />
setFromInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid="audit-from-input" />
setToInput(e.target.value)} className="bg-white/70 border-slate-200" data-testid="audit-to-input" />
{inputErrors.map((msg, i) => (

{msg}

))} {exportError && (

{t("admin.audit.export.failed")}

)}
{filtersValid ? t("admin.audit.showing", { shown: showingCount, total: totalCount, }) : t("admin.audit.errors.fixFiltersFirst")}
{isLoading ? (
) : entries.length === 0 ? (
{t("admin.audit.empty")}
) : (
{entries.map((entry) => ( ))}
)} {hasMore && (
)} ); }