Files
TX/artifacts/tx-os/src/pages/admin.tsx
T
Riyadh 68a338f4cf Improve admin modal design and accessibility with shared component
Refactors AdminFormDialog to create a reusable component for admin modals, including aria-labelledby/describedby attributes and Escape key closing functionality.
2026-05-13 19:11:10 +00:00

8025 lines
306 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useMemo, useId, 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, AlertTriangle, 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<string, unknown>)[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<App[]> {
const res = await fetch("/api/admin/apps", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch admin apps");
return res.json();
}
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "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 (
<div className="w-8 h-8 rounded-full shrink-0 bg-emerald-100/70 text-emerald-700 flex items-center justify-center text-xs font-semibold overflow-hidden">
{showImage ? (
<img
src={src}
alt={alt}
onError={() => setErrored(true)}
className="w-full h-full object-cover"
/>
) : (
initial
)}
</div>
);
}
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/<id>"). 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 (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl w-full max-w-md overflow-hidden shadow-xl"
role="alertdialog"
aria-modal="true"
data-testid={testId}
>
<div className="flex items-start gap-3 p-5 sm:p-6">
<div
className="shrink-0 w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center"
aria-hidden="true"
>
<AlertTriangle size={20} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h2 className="text-base sm:text-lg font-semibold text-foreground leading-snug">
{title}
</h2>
</div>
</div>
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-4">
{showWarning ? (
<>
<p className="text-sm text-foreground/90 leading-relaxed">
{warning}
</p>
{items.length > 0 && (
<div className="rounded-xl border border-foreground/10 bg-foreground/5 p-3">
<ul className="text-sm text-foreground space-y-1.5">
{items.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<span
className="mt-1.5 inline-block w-1.5 h-1.5 rounded-full bg-foreground/40 shrink-0"
aria-hidden="true"
/>
<span className="leading-relaxed">{item}</span>
</li>
))}
</ul>
</div>
)}
<div className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5">
<p className="text-xs sm:text-[13px] text-destructive leading-relaxed">
{forceHint}
</p>
</div>
</>
) : (
<p className="text-sm text-foreground/85 leading-relaxed">
{emptyBody}
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 px-5 sm:px-6 py-4 border-t border-foreground/10 bg-foreground/[0.02]">
<Button
variant="ghost"
onClick={onCancel}
disabled={isPending}
className="text-muted-foreground hover:text-foreground"
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
disabled={isPending}
onClick={onConfirm}
data-testid={confirmTestId}
className="min-w-[110px] gap-2"
>
{isPending && (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
)}
{showWarning ? anywayLabel : confirmLabel}
</Button>
</div>
</div>
</div>
);
}
function AdminFormDialog({
title,
subtitle,
icon,
onCancel,
onSubmit,
submitLabel,
cancelLabel,
submitDisabled,
isPending,
children,
testId,
submitTestId,
maxWidth = "sm",
}: {
title: string;
subtitle?: string;
icon: ReactNode;
onCancel: () => void;
onSubmit: () => void;
submitLabel?: string;
cancelLabel?: string;
submitDisabled?: boolean;
isPending?: boolean;
children: ReactNode;
testId?: string;
submitTestId?: string;
maxWidth?: "sm" | "md" | "lg";
}) {
const { t } = useTranslation();
const titleId = useId();
const subtitleId = useId();
const widthClass =
maxWidth === "lg" ? "max-w-lg" : maxWidth === "md" ? "max-w-md" : "max-w-sm";
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isPending) {
e.stopPropagation();
onCancel();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isPending, onCancel]);
return (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className={cn(
"glass-panel rounded-3xl w-full overflow-hidden shadow-xl flex flex-col max-h-[90vh]",
widthClass,
)}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
data-testid={testId}
>
<div className="flex items-start gap-3 p-5 sm:p-6">
<div
className="shrink-0 w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
aria-hidden="true"
>
{icon}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h2
id={titleId}
className="text-base sm:text-lg font-semibold text-foreground leading-snug"
>
{title}
</h2>
{subtitle && (
<p
id={subtitleId}
className="text-xs text-muted-foreground mt-0.5 leading-relaxed"
>
{subtitle}
</p>
)}
</div>
</div>
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-4 overflow-y-auto">
{children}
</div>
<div className="flex items-center justify-end gap-2 px-5 sm:px-6 py-4 border-t border-foreground/10 bg-foreground/[0.02]">
<Button
variant="ghost"
onClick={onCancel}
disabled={isPending}
className="text-muted-foreground hover:text-foreground"
>
{cancelLabel ?? t("common.cancel")}
</Button>
<Button
onClick={onSubmit}
disabled={isPending || submitDisabled}
data-testid={submitTestId}
className="min-w-[110px] gap-2"
>
{isPending && (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
)}
{submitLabel ?? t("common.save")}
</Button>
</div>
</div>
</div>
);
}
// 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<number | "">("");
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 (
<div
className="space-y-2 border-t border-slate-200/70 pt-3"
data-testid="app-permissions-picker"
>
<Label>{t("admin.appPermissions.title")}</Label>
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.help")}
</p>
{selected.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.none")}
</p>
) : (
<ul className="space-y-1" data-testid="app-permissions-picker-list">
{selected.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 rounded-xl bg-white/70 border border-slate-200 px-3 py-1.5"
>
<span className="text-sm text-foreground truncate">{p.name}</span>
<button
type="button"
aria-label={t("admin.appPermissions.removeAria", { name: p.name })}
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => onRemove(p.id)}
data-testid={`app-permission-picker-remove-${p.id}`}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
className="flex-1 rounded-xl bg-white/70 border border-slate-200 px-3 py-2 text-sm"
value={pendingId === "" ? "" : String(pendingId)}
onChange={(e) =>
setPendingId(e.target.value === "" ? "" : Number(e.target.value))
}
disabled={available.length === 0}
data-testid="app-permission-picker-select"
>
<option value="">{t("admin.appPermissions.selectPlaceholder")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
size="sm"
onClick={onAdd}
disabled={pendingId === "" || available.length === 0}
data-testid="app-permission-picker-add"
>
{t("admin.appPermissions.add")}
</Button>
</div>
</div>
);
}
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<number | "">("");
const [impact, setImpact] = useState<AppPermissionsImpact | null>(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 (
<div
className="space-y-2 border-t border-slate-200/70 pt-3"
data-testid="app-permissions-editor"
>
<Label>{t("admin.appPermissions.title")}</Label>
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.help")}
</p>
{isLoading ? (
<p className="text-xs text-muted-foreground">{t("common.loading")}</p>
) : (assigned?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.none")}
</p>
) : (
<ul className="space-y-1" data-testid="app-permissions-list">
{assigned!.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 rounded-xl bg-white/70 border border-slate-200 px-3 py-1.5"
>
<span className="text-sm text-foreground truncate">{p.name}</span>
<button
type="button"
aria-label={t("admin.appPermissions.removeAria", {
name: p.name,
})}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermission.isPending}
onClick={() => onRemove(p.id)}
data-testid={`app-permission-remove-${p.id}`}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
className="flex-1 rounded-xl bg-white/70 border border-slate-200 px-3 py-2 text-sm"
value={pendingId === "" ? "" : String(pendingId)}
onChange={(e) =>
setPendingId(e.target.value === "" ? "" : Number(e.target.value))
}
disabled={available.length === 0 || addPermission.isPending}
data-testid="app-permission-select"
>
<option value="">{t("admin.appPermissions.selectPlaceholder")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
size="sm"
onClick={onAdd}
disabled={
pendingId === "" ||
addPermission.isPending ||
available.length === 0 ||
impactLoading ||
impactError
}
data-testid="app-permission-add"
>
{addPermission.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.appPermissions.add")
)}
</Button>
</div>
{pendingId !== "" && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-1"
data-testid="app-perm-impact"
role="status"
aria-live="polite"
>
<p className="font-semibold">{t("admin.appPermissions.impactTitle")}</p>
{impactLoading ? (
<div className="flex items-center gap-2 text-amber-800">
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.appPermissions.impactLoading")}</span>
</div>
) : impactError || !impact ? (
<p
className="text-amber-900"
data-testid="app-perm-impact-error"
>
{t("admin.appPermissions.impactError")}
</p>
) : impact.affectedUserCount === 0 ? (
<p data-testid="app-perm-impact-none">
{t("admin.appPermissions.impactNone", {
visible: impact.currentlyVisibleUserCount,
})}
</p>
) : (
<>
<p data-testid="app-perm-impact-summary">
{t("admin.appPermissions.impactSummary", {
affected: impact.affectedUserCount,
visible: impact.currentlyVisibleUserCount,
})}
</p>
{impact.groups.length > 0 && (
<p
className="text-amber-800"
data-testid="app-perm-impact-groups"
>
{t("admin.appPermissions.impactViaGroups", {
groups: impact.groups.map((g) => g.name).join(", "),
})}
</p>
)}
</>
)}
</div>
)}
{confirmAdd && impact && impact.affectedUserCount > 0 && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3"
data-testid="app-perm-confirm-dialog"
role="alertdialog"
aria-modal="true"
>
<h2 className="font-semibold text-foreground">
{t("admin.appPermissions.confirmTitle")}
</h2>
<p className="text-sm text-foreground">
{t("admin.appPermissions.confirmBody", {
affected: impact.affectedUserCount,
visible: impact.currentlyVisibleUserCount,
})}
</p>
{impact.groups.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.impactViaGroups", {
groups: impact.groups.map((g) => g.name).join(", "),
})}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setConfirmAdd(false)}
data-testid="app-perm-confirm-cancel"
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="flex-1"
disabled={addPermission.isPending}
onClick={performAdd}
data-testid="app-perm-confirm-submit"
>
{addPermission.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.appPermissions.confirmAction")
)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
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<string>(sevenAgoIso);
const [customTo, setCustomTo] = useState<string>(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<DependencyTarget | null>(null);
const [appDeleteTarget, setAppDeleteTarget] = useState<App | null>(null);
const [appDeleteConflict, setAppDeleteConflict] = useState<AppDeletionConflict | null>(null);
const [serviceDeleteTarget, setServiceDeleteTarget] = useState<Service | null>(null);
const [serviceDeleteConflict, setServiceDeleteConflict] = useState<ServiceDeletionConflict | null>(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<AdminSection>("dashboard");
const [navOpenGroups, setNavOpenGroups] = useState<Record<string, boolean>>({});
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [highlightedUserId, setHighlightedUserId] = useState<number | null>(null);
const userRowRefs = useRef<Map<number, HTMLDivElement>>(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<boolean>(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 (
<button
key={leaf.key}
onClick={() => {
setSection(leaf.key);
onSelect?.();
}}
className={cn(
"flex items-center gap-3 w-full py-2.5 rounded-xl text-sm transition-all text-start relative",
indent ? "ps-9 pe-3" : "px-3",
active
? "bg-primary text-primary-foreground font-semibold shadow-md shadow-primary/20"
: "text-foreground/70 hover:bg-slate-100 hover:text-foreground",
)}
data-testid={`nav-${leaf.key}`}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{leaf.label}</span>
</button>
);
};
return (
<nav className="flex flex-col gap-1">
{navItems.map((entry) => {
if ("children" in entry) {
const Icon = entry.Icon;
const anyChildActive = entry.children.some((c) => c.key === section);
const expanded = navOpenGroups[entry.groupKey] ?? anyChildActive;
return (
<div key={entry.groupKey} className="flex flex-col gap-0.5">
<button
type="button"
onClick={() =>
setNavOpenGroups((prev) => ({
...prev,
[entry.groupKey]: !(prev[entry.groupKey] ?? anyChildActive),
}))
}
aria-expanded={expanded}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-xl text-xs uppercase tracking-wider transition-colors text-start",
anyChildActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
data-testid={`nav-group-${entry.groupKey}`}
>
<Icon size={14} className="shrink-0" />
<span className="truncate flex-1">{entry.label}</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
{expanded && entry.children.map((leaf) => renderLeaf(leaf, true))}
</div>
);
}
return renderLeaf(entry, false);
})}
</nav>
);
};
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 (
<div className="min-h-screen os-bg flex flex-col">
{/* Header */}
<div
className="border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-30"
style={{
background: "rgba(255,255,255,0.96)",
backdropFilter: "blur(16px) saturate(160%)",
WebkitBackdropFilter: "blur(16px) saturate(160%)",
boxShadow: "0 4px 20px rgba(15,23,42,0.06)",
}}
>
<button
onClick={() => setLocation("/")}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Settings size={20} className="text-red-400 shrink-0" />
<h1 className="text-lg font-semibold text-foreground truncate">{t("admin.title")}</h1>
</div>
<Sheet open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<SheetTrigger asChild>
<button
className="md:hidden p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("admin.nav.menu")}
>
<MenuIcon size={20} />
</button>
</SheetTrigger>
<SheetContent side={isRtl ? "right" : "left"} className="w-full sm:w-72 p-4">
<div className="flex items-center gap-2 mb-4 mt-2">
<Settings size={18} className="text-red-400" />
<h2 className="font-semibold text-foreground">{t("admin.title")}</h2>
</div>
{renderNavList(() => setMobileNavOpen(false))}
</SheetContent>
</Sheet>
</div>
{/* 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 (
<div
className="fixed inset-0 bg-slate-950/50 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) setEditingApp(null); }}
>
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl border border-slate-200/80 overflow-hidden animate-in zoom-in-95 duration-150">
{/* Header */}
<div className="flex items-start justify-between gap-3 px-5 sm:px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center gap-3 min-w-0">
<div
className="shrink-0 w-10 h-10 rounded-xl text-white flex items-center justify-center shadow-md"
style={{
background: `linear-gradient(135deg, ${previewColor}, ${previewColor}cc)`,
boxShadow: `0 6px 18px ${previewColor}40`,
}}
>
{PreviewIcon ? <PreviewIcon size={18} /> : <LayoutDashboard size={18} />}
</div>
<div className="min-w-0">
<h2 className="font-semibold text-slate-900 text-base leading-tight truncate">
{editingApp.id ? t("admin.editApp") : t("admin.addApp")}
</h2>
<p className="text-xs text-slate-500 mt-0.5 truncate">
{editingApp.id
? (lang === "ar" ? "حدّث تفاصيل التطبيق وأذوناته" : "Update app details and permissions")
: (lang === "ar" ? "أضف تطبيقاً جديداً للوحة" : "Add a new app to the launcher")}
</p>
</div>
</div>
<button
type="button"
onClick={() => setEditingApp(null)}
className="shrink-0 -m-1 p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors"
aria-label={t("common.cancel")}
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 sm:px-6 py-5 space-y-5">
{/* Names row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<span className="inline-flex items-center justify-center w-4 h-4 rounded bg-slate-100 text-[10px] font-bold text-slate-600">ع</span>
{t("admin.appNameAr")}
</Label>
<Input
value={editingApp.form.nameAr}
onChange={(e) => 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="الملاحظات"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<span className="inline-flex items-center justify-center w-4 h-4 rounded bg-slate-100 text-[10px] font-bold text-slate-600">EN</span>
{t("admin.appNameEn")}
</Label>
<Input
value={editingApp.form.nameEn}
onChange={(e) => 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"
/>
</div>
</div>
{/* Identifier row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Hash size={12} className="text-slate-500" />
{t("admin.appSlug")}
</Label>
<Input
value={editingApp.form.slug}
onChange={(e) => setEditingApp({ ...editingApp, form: { ...editingApp.form, slug: 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"
/>
<p className="text-[11px] text-slate-400 leading-snug">
{isBuiltin
? t("admin.builtinPathLocked")
: (lang === "ar" ? "معرّف فريد للتطبيق (أحرف صغيرة بدون مسافات)" : "Unique app identifier (lowercase, no spaces)")}
</p>
</div>
{!isExternal && (
<div className="space-y-1.5" data-testid="app-route-field">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Link2 size={12} className="text-slate-500" />
{t("admin.appRoute")}
{isBuiltin && (
<Lock size={10} className="text-slate-400" aria-hidden="true" />
)}
</Label>
<Input
value={editingApp.form.route}
onChange={(e) => 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"
/>
<p className="text-[11px] text-slate-400 leading-snug">
{isBuiltin
? t("admin.builtinPathLocked")
: (lang === "ar" ? "المسار داخل التطبيق (مثل /notes)" : "URL path inside the app (e.g. /notes)")}
</p>
</div>
)}
</div>
{/* App image (optional, replaces the Lucide icon on the launcher) */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Upload size={12} className="text-slate-500" />
{t("admin.appImage")}
</Label>
<ServiceImageUploader
value={editingApp.form.imageUrl}
onChange={(url) =>
setEditingApp({
...editingApp,
form: { ...editingApp.form, imageUrl: url },
})
}
/>
<p className="text-[11px] text-slate-400 leading-snug">
{lang === "ar"
? "صورة اختيارية تظهر مكان الأيقونة في الشاشة الرئيسية"
: "Optional image shown on the launcher tile in place of the icon"}
</p>
</div>
{/* Open mode + external URL */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<ExternalLink size={12} className="text-slate-500" />
{t("admin.appOpenMode.label")}
</Label>
<select
value={editingApp.form.openMode}
onChange={(e) =>
setEditingApp({
...editingApp,
form: {
...editingApp.form,
openMode: e.target.value as AppOpenMode,
},
})
}
className="bg-slate-50/70 border border-slate-200 rounded-lg h-10 text-sm w-full px-3 focus-visible:ring-2 focus-visible:ring-indigo-500/30 focus-visible:border-indigo-400"
dir={lang === "ar" ? "rtl" : "ltr"}
>
<option value="internal">{t("admin.appOpenMode.internal")}</option>
<option value="external_tab">{t("admin.appOpenMode.external_tab")}</option>
<option value="external_iframe">{t("admin.appOpenMode.external_iframe")}</option>
</select>
{isExternal && (
<div className="space-y-1.5 pt-2">
<Label className="text-xs font-medium text-slate-700">
{t("admin.appExternalUrl")}
</Label>
<Input
value={editingApp.form.externalUrl}
onChange={(e) =>
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"
/>
</div>
)}
</div>
{/* Visuals row — icon + color with live previews */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Icon picker */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Grid2X2 size={12} className="text-slate-500" />
{t("admin.appIcon")}
</Label>
<div className="flex items-center gap-2">
<div
className={cn(
"shrink-0 w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
PreviewIcon
? "border-slate-200 bg-white text-slate-700"
: "border-dashed border-slate-300 bg-slate-50 text-slate-300"
)}
title={PreviewIcon ? editingApp.form.iconName : (lang === "ar" ? "اسم أيقونة غير معروف" : "Unknown icon name")}
>
{PreviewIcon ? <PreviewIcon size={18} /> : <HelpCircle size={18} />}
</div>
<Input
value={editingApp.form.iconName}
onChange={(e) => 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"
/>
</div>
<p className="text-[11px] text-slate-400 leading-snug">
{lang === "ar" ? "اسم أيقونة من Lucide (مثل: StickyNote, Coffee, Users)" : "Lucide icon name (e.g. StickyNote, Coffee, Users)"}
</p>
</div>
{/* Color picker */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<Palette size={12} className="text-slate-500" />
{t("admin.appColor")}
</Label>
<div className="flex items-center gap-2">
<label
className="shrink-0 relative w-10 h-10 rounded-lg border border-slate-200 cursor-pointer overflow-hidden shadow-sm hover:ring-2 hover:ring-indigo-500/30 transition"
style={{ backgroundColor: previewColor }}
title={lang === "ar" ? "اختر اللون" : "Pick color"}
>
<input
type="color"
value={previewColor}
onChange={(e) => setEditingApp({ ...editingApp, form: { ...editingApp.form, color: e.target.value } })}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
<Input
value={editingApp.form.color}
onChange={(e) => 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"
/>
</div>
<p className="text-[11px] text-slate-400 leading-snug">
{lang === "ar" ? "كود اللون السداسي (مثل #6366F1)" : "Hex color code (e.g. #6366F1)"}
</p>
</div>
</div>
{/* Permissions section in a bordered card */}
<div className="rounded-xl border border-slate-200 bg-slate-50/40 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-slate-200 bg-white/60">
<Shield size={14} className="text-slate-500" />
<span className="text-xs font-semibold text-slate-700">
{lang === "ar" ? "الأذونات" : "Permissions"}
</span>
</div>
<div className="p-3">
{editingApp.id !== undefined ? (
<AppPermissionsEditor appId={editingApp.id} />
) : (
<NewAppPermissionsPicker
selectedIds={editingApp.form.permissionIds}
onChange={(next) =>
setEditingApp({
...editingApp,
form: { ...editingApp.form, permissionIds: next },
})
}
/>
)}
</div>
</div>
{editingApp.id !== undefined && (
<PermissionAuditHistory
targetKind="app"
targetId={editingApp.id}
enabled={true}
language={lang}
scopeI18nKey="admin.apps.history"
changeKindLabel={null}
labelResolver={(_ck, id) => {
const p = (permissionsList ?? []).find((x) => x.id === id);
return p ? p.name : `#${id}`;
}}
/>
)}
{editingApp.id !== undefined && (
<RecentActivityForTarget
targetType="app"
targetId={editingApp.id}
enabled={true}
lang={lang}
onViewFullHistory={() =>
openAuditLogForTarget("app", editingApp.id!)
}
/>
)}
</div>
{/* Footer */}
<div className="flex gap-2 px-5 sm:px-6 py-4 border-t border-slate-100 bg-slate-50/40">
<Button
variant="outline"
className="flex-1 h-10 rounded-lg border-slate-200 hover:bg-slate-100"
onClick={() => setEditingApp(null)}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1 h-10 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-600 hover:from-indigo-600 hover:to-blue-700 shadow-md shadow-indigo-500/20"
onClick={handleSaveApp}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
})()}
{/* Edit Service Modal */}
{editingService && (
<div
className="fixed inset-0 bg-slate-950/50 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) setEditingService(null); }}
>
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] flex flex-col shadow-2xl border border-slate-200/80 overflow-hidden animate-in zoom-in-95 duration-150">
{/* Header */}
<div className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-blue-600 text-white flex items-center justify-center shadow-md shadow-indigo-500/20">
<Coffee size={18} />
</div>
<div className="min-w-0">
<h2 className="font-semibold text-slate-900 text-base leading-tight">
{editingService.id ? t("admin.editService") : t("admin.addService")}
</h2>
<p className="text-xs text-slate-500 mt-0.5">
{editingService.id
? (lang === "ar" ? "حدّث تفاصيل الخدمة وصورتها" : "Update the service details and image")
: (lang === "ar" ? "أضف خدمة جديدة إلى القائمة" : "Add a new service to the list")}
</p>
</div>
</div>
<button
type="button"
onClick={() => setEditingService(null)}
className="shrink-0 -m-1 p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors"
aria-label={t("common.cancel")}
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* Name fields — side by side on sm+ */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<span className="inline-flex items-center justify-center w-4 h-4 rounded bg-slate-100 text-[10px] font-bold text-slate-600">ع</span>
{t("admin.serviceNameAr")}
</Label>
<Input
value={editingService.form.nameAr}
onChange={(e) => 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="شاي"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<span className="inline-flex items-center justify-center w-4 h-4 rounded bg-slate-100 text-[10px] font-bold text-slate-600">EN</span>
{t("admin.serviceNameEn")}
</Label>
<Input
value={editingService.form.nameEn}
onChange={(e) => 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"
/>
</div>
</div>
{/* Image */}
<div className="space-y-1.5">
<Label className="text-xs font-medium text-slate-700 flex items-center gap-1.5">
<ImageIcon size={12} className="text-slate-500" />
{t("admin.serviceImage")}
</Label>
<ServiceImageUploader
value={editingService.form.imageUrl}
onChange={(url) =>
setEditingService({
...editingService,
form: { ...editingService.form, imageUrl: url },
})
}
/>
</div>
{/* Availability — card row */}
<div className="flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 bg-slate-50/50">
<div className={cn(
"shrink-0 w-9 h-9 rounded-lg flex items-center justify-center transition-colors",
editingService.form.isAvailable
? "bg-emerald-100 text-emerald-600"
: "bg-slate-200 text-slate-400"
)}>
<Power size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-800">{t("admin.serviceAvailable")}</div>
<div className="text-xs text-slate-500 mt-0.5">
{editingService.form.isAvailable
? (lang === "ar" ? "الخدمة معروضة للمستخدمين الآن" : "Visible to users right now")
: (lang === "ar" ? "الخدمة مخفية عن المستخدمين" : "Hidden from users")}
</div>
</div>
<Switch
checked={editingService.form.isAvailable}
onCheckedChange={(v) => setEditingService({ ...editingService, form: { ...editingService.form, isAvailable: v } })}
/>
</div>
{editingService.id !== undefined && (
<div className="pt-1">
<RecentActivityForTarget
targetType="service"
targetId={editingService.id}
enabled={true}
lang={lang}
onViewFullHistory={() =>
openAuditLogForTarget("service", editingService.id!)
}
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 px-6 py-4 border-t border-slate-100 bg-slate-50/40">
<Button
variant="outline"
className="flex-1 h-10 rounded-lg border-slate-200 hover:bg-slate-100"
onClick={() => setEditingService(null)}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1 h-10 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-600 hover:from-indigo-600 hover:to-blue-700 shadow-md shadow-indigo-500/20"
onClick={handleSaveService}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
)}
{/* Create User Modal */}
{newUserForm && (
<AdminFormDialog
title={t("admin.addUser")}
icon={<UserPlus size={20} />}
onCancel={() => setNewUserForm(null)}
onSubmit={handleCreateUser}
isPending={createUser.isPending}
>
<div className="space-y-1.5">
<Label>{t("admin.username")}</Label>
<Input
value={newUserForm.username}
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("admin.email")}</Label>
<Input
type="email"
value={newUserForm.email}
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("admin.password")}</Label>
<Input
type="password"
value={newUserForm.password}
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
/>
</div>
</AdminFormDialog>
)}
<div className="flex-1 flex w-full">
{/* Sidebar (desktop) */}
<aside className="hidden md:flex flex-col w-64 shrink-0 border-e border-slate-200/70 bg-white/70 backdrop-blur-md">
<div className="sticky top-[73px] p-4 space-y-4">
<div className="flex items-center gap-2 px-2 pb-3 border-b border-slate-200/70">
<Shield size={18} className="text-primary" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("admin.nav.menu")}
</span>
</div>
{renderNavList()}
</div>
</aside>
{/* Main content */}
<div className="flex-1 min-w-0 p-4 pb-8">
{section === "dashboard" && (
<DashboardSection
apps={apps}
services={services}
users={users}
appSettings={appSettings}
adminStats={adminStats}
adminStatsErrorMessage={adminStatsErrorMessage}
lang={lang}
statsRange={statsRange}
onStatsRangeChange={setStatsRange}
customFrom={customFrom}
customTo={customTo}
onCustomFromChange={setCustomFrom}
onCustomToChange={setCustomTo}
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
statsQueryParams={statsQueryParams}
onSelectApp={handleSelectAppFromDashboard}
onSelectUser={handleSelectUserFromDashboard}
/>
)}
{section === "apps" && (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => setEditingApp({ form: emptyAppForm })}>
<Plus size={14} className="me-1" />
{t("admin.addApp")}
</Button>
</div>
{apps?.map((app) => (
<div key={app.id} className={cn("rounded-2xl p-4 flex items-center justify-between gap-3 border", app.isActive ? "glass-panel border-transparent" : "bg-slate-100/80 border-slate-200")}>
<div className="flex items-center gap-3 min-w-0">
<div
className={cn("w-8 h-8 rounded-lg shrink-0", !app.isActive && "grayscale")}
style={{ backgroundColor: `${app.color}40` }}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className={cn("font-medium text-sm truncate", app.isActive ? "text-foreground" : "text-slate-400")}>
{lang === "ar" ? app.nameAr : app.nameEn}
</div>
{!app.isActive && (
<span className="text-[10px] font-medium bg-slate-300 text-slate-600 rounded px-1.5 py-0.5 shrink-0">
{t("admin.appDisabled")}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{app.route}</div>
{(() => {
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 (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground pt-0.5"
data-testid={`app-counts-${app.id}`}
>
{parts.map((p, i) => (
<span key={p.testid} className="flex items-center gap-2">
{i > 0 && <span aria-hidden="true"></span>}
<button
type="button"
onClick={() => setDependencyTarget(p.target)}
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
data-testid={p.testid}
aria-label={t("admin.dependents.openLabel", {
count: p.label,
})}
>
{p.label}
</button>
</span>
))}
</div>
);
})()}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={app.isActive}
onCheckedChange={(v) => {
updateApp.mutate(
{ id: app.id, data: { isActive: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
);
}}
/>
<button
onClick={() => 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: [],
},
})}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
// Pre-populate conflict from list-row counts so the
// warning dialog shows on the FIRST click. Falls back
// to the lazy 409 round-trip if counts are missing.
const groupCount = app.groupCount ?? 0;
const restrictionCount = app.restrictionCount ?? 0;
const openCount = app.openCount ?? 0;
const hasDeps =
groupCount > 0 || restrictionCount > 0 || openCount > 0;
setAppDeleteConflict(
hasDeps
? {
error: "App has dependent records",
groupCount,
restrictionCount,
openCount,
}
: null,
);
setAppDeleteTarget(app);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`app-delete-${app.id}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{section === "services" && (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => setEditingService({ form: emptyServiceForm })}>
<Plus size={14} className="me-1" />
{t("admin.addService")}
</Button>
</div>
{services?.map((service) => (
<div key={service.id} className="glass-panel rounded-2xl p-4 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm text-foreground truncate">
{lang === "ar" ? service.nameAr : service.nameEn}
</div>
{(service.orderCount ?? 0) > 0 && (
<div
className="text-[11px] text-muted-foreground pt-0.5"
data-testid={`service-counts-${service.id}`}
>
<button
type="button"
onClick={() =>
setDependencyTarget({
kind: "serviceOrders",
serviceId: service.id,
serviceName: lang === "ar" ? service.nameAr : service.nameEn,
})
}
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
data-testid={`service-counts-orders-${service.id}`}
aria-label={t("admin.dependents.openLabel", {
count: t("admin.services.counts.orders", { count: service.orderCount }),
})}
>
{t("admin.services.counts.orders", { count: service.orderCount })}
</button>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={service.isAvailable}
onCheckedChange={(v) => {
updateService.mutate(
{ id: service.id, data: { isAvailable: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }) },
);
}}
/>
<button
onClick={() => setEditingService({
id: service.id,
form: {
nameAr: service.nameAr ?? "",
nameEn: service.nameEn ?? "",
descriptionAr: service.descriptionAr ?? "",
descriptionEn: service.descriptionEn ?? "",
imageUrl: service.imageUrl ?? "",
isAvailable: service.isAvailable ?? true,
},
})}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
// Pre-populate conflict from list-row orderCount so
// the warning dialog shows on the FIRST click. Falls
// back to the lazy 409 round-trip if count is missing.
const orderCount = service.orderCount ?? 0;
setServiceDeleteConflict(
orderCount > 0
? {
error: "Service has dependent records",
orderCount,
}
: null,
);
setServiceDeleteTarget(service);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`service-delete-${service.id}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{section === "users" && (
<UsersPanel
currentUserId={user.id}
highlightedUserId={highlightedUserId}
userRowRefs={userRowRefs}
onIssueResetLink={(uid, username) =>
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" && (
<GroupsPanel onOpenAuditLogForTarget={openAuditLogForTarget} />
)}
{section === "roles" && (
<RolesPanel onOpenAuditLogForTarget={openAuditLogForTarget} />
)}
{section === "audit-log" && <AuditLogPanel />}
{section === "settings" && (
<div className="space-y-3">
<SiteSettingsPanel />
</div>
)}
</div>
</div>
{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 (
<DeletionWarningDialog
title={t("admin.deleteApp.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteApp.warning")}
forceHint={t("admin.deleteApp.forceHint")}
emptyBody={t("admin.deleteApp.emptyBody")}
confirmLabel={t("admin.deleteApp.confirm")}
anywayLabel={t("admin.deleteApp.anyway")}
items={items}
showWarning={appDeleteConflict !== null}
isPending={deleteApp.isPending}
onCancel={closeAppDeleteDialog}
onConfirm={() => 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 (
<DeletionWarningDialog
title={t("admin.deleteService.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteService.warning")}
forceHint={t("admin.deleteService.forceHint")}
emptyBody={t("admin.deleteService.emptyBody")}
confirmLabel={t("admin.deleteService.confirm")}
anywayLabel={t("admin.deleteService.anyway")}
items={items}
showWarning={serviceDeleteConflict !== null}
isPending={deleteService.isPending}
onCancel={closeServiceDeleteDialog}
onConfirm={() => performServiceDelete(target, serviceDeleteConflict !== null)}
testId="service-delete-dialog"
confirmTestId="service-delete-confirm"
/>
);
})()}
{resetLinkUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setResetLinkUser(null)}
>
<div
className="glass-panel rounded-2xl p-6 max-w-md w-full space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold">
{t("admin.resetLinkModal.title", { username: resetLinkUser.username })}
</h3>
<p className="text-sm text-muted-foreground">
{t("admin.resetLinkModal.description")}
</p>
<div className="rounded-md border border-border bg-background/50 p-3">
<code className="text-xs break-all block font-mono">
{resetLinkUser.url}
</code>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.resetLinkModal.expires", {
time: new Date(resetLinkUser.expiresAt).toLocaleString(),
})}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
navigator.clipboard
.writeText(resetLinkUser.url)
.then(() =>
toast({ title: t("admin.resetLinkModal.copied") }),
)
.catch(() => {});
}}
className="flex-1"
>
<Copy size={14} />
<span className="ml-2">{t("admin.resetLinkModal.copy")}</span>
</Button>
<Button
onClick={() => setResetLinkUser(null)}
className="flex-1"
>
{t("common.close")}
</Button>
</div>
</div>
</div>
)}
{dependencyTarget && (
<DependencyDrillIn
target={dependencyTarget}
lang={lang}
onClose={() => setDependencyTarget(null)}
/>
)}
</div>
);
}
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 (
<div className="glass-panel rounded-2xl p-4 space-y-4">
<div className="space-y-1">
<Label>{t("admin.siteNameAr")}</Label>
<Input
value={form.siteNameAr}
onChange={(e) => setForm({ ...form, siteNameAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.siteNameEn")}</Label>
<Input
value={form.siteNameEn}
onChange={(e) => setForm({ ...form, siteNameEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.footerTextAr")}</Label>
<Input
value={form.footerTextAr}
onChange={(e) => setForm({ ...form, footerTextAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.footerTextEn")}</Label>
<Input
value={form.footerTextEn}
onChange={(e) => setForm({ ...form, footerTextEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<div className="flex items-center justify-between pt-2">
<div>
<Label className="font-medium">{t("admin.registrationOpen")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.registrationOpenHint")}</p>
</div>
<Switch
checked={form.registrationOpen}
onCheckedChange={(v) => setForm({ ...form, registrationOpen: v })}
/>
</div>
<Button onClick={handleSave} disabled={update.isPending} className="w-full">
{update.isPending ? t("common.loading") : t("common.save")}
</Button>
</div>
);
}
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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
disabled={isUploading}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void uploadFile(file);
}
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
{isUploading
? `${t("admin.uploading")} ${progress}%`
: value
? t("admin.replaceImage")
: t("admin.uploadImage")}
</span>
</label>
{value && !isUploading && (
<button
type="button"
onClick={() => onChange("")}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-500 hover:text-destructive hover:border-destructive/40"
>
<Trash2 size={14} />
</button>
)}
</div>
{value && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={resolveServiceImageUrl(value) ?? ""}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
);
}
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,
) => (
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? rangeLabel}</div>
</div>
<div className={cn("flex items-end justify-between h-28 px-1", gapClass)} dir="ltr">
{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 (
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
{showCounts && (
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
)}
<div className="w-full flex-1 flex items-end">
<div
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
/>
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
</div>
</div>
);
})}
</div>
</div>
);
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 (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{stats.map(({ label, value, Icon, tint }) => (
<div key={label} className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center shrink-0", tint)}>
<Icon size={20} />
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground truncate">{label}</div>
<div className="text-xl font-semibold text-foreground truncate">{value}</div>
</div>
</div>
))}
</div>
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.trends")}
</div>
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
{rangeOptions.map((opt) => {
const active = statsRange === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onStatsRangeChange(opt.value)}
aria-pressed={active}
className={cn(
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
active
? "bg-white text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
</div>
</div>
{statsRange === "custom" && (
<div className="flex items-end gap-2 flex-wrap" dir="ltr">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.from")}
</Label>
<Input
type="date"
value={customFrom}
max={customTo || undefined}
onChange={(e) => onCustomFromChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.to")}
</Label>
<Input
type="date"
value={customTo}
min={customFrom || undefined}
onChange={(e) => onCustomToChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<Button
size="sm"
onClick={onApplyCustom}
disabled={!customInputsValid}
className="h-9"
>
{t("admin.dashboard.customRange.apply")}
</Button>
{!customInputsValid && (
<div className="text-xs text-destructive">
{t("admin.dashboard.customRange.invalid")}
</div>
)}
</div>
)}
{adminStatsErrorMessage && (
<div
role="alert"
className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2"
>
{t("admin.dashboard.customRange.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">
{adminStatsErrorMessage}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-sky-600 bg-sky-100/70">
<UserPlus size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground truncate">
{t("admin.dashboard.newUsersInRange", { range: rangeLabel })}
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-semibold text-foreground">{newUsers}</div>
{userDelta !== 0 && (
<div
className={cn(
"text-xs flex items-center gap-0.5 font-medium",
userDelta > 0 ? "text-emerald-600" : "text-rose-600",
)}
>
{userDelta > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{userDeltaPct !== null
? `${userDelta > 0 ? "+" : ""}${userDeltaPct}%`
: `${userDelta > 0 ? "+" : ""}${userDelta}`}
</div>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
</div>
</div>
</div>
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-amber-600 bg-amber-100/70">
<Activity size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.activeServices")}</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-semibold text-foreground">{activeServices}</div>
<div className="text-xs text-muted-foreground">
/ {activeServices + inactiveServices}
</div>
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{t("admin.dashboard.inactiveServices", { count: inactiveServices })}
</div>
</div>
</div>
</div>
{renderChart(
t("admin.dashboard.signupsTrend"),
signups,
maxSignup,
"from-primary/40 to-primary",
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{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 }),
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">
{t("admin.dashboard.topApps")}
</div>
<div className="text-xs text-muted-foreground shrink-0">
{t("admin.dashboard.topAppsSubtitle", { range: rangeLabel })}
</div>
</div>
{topApps.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{t("admin.dashboard.leaderboardEmpty")}
</div>
) : (
<ol className="space-y-2">
{topApps.map((app, idx) => {
const widthPct = (app.count / maxTopAppCount) * 100;
const appName = lang === "ar" ? app.nameAr : app.nameEn;
return (
<li key={app.appId}>
<button
type="button"
onClick={() => setDrillIn({ kind: "app", appId: app.appId })}
aria-label={t("admin.dashboard.viewAppDetails", { name: appName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
{idx + 1}
</div>
<div
className="w-8 h-8 rounded-lg shrink-0"
style={{ backgroundColor: `${app.color}40` }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground truncate">
{appName}
</div>
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
{app.count}
</div>
</div>
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-sky-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
</button>
</li>
);
})}
</ol>
)}
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">
{t("admin.dashboard.mostActiveUsers")}
</div>
<div className="text-xs text-muted-foreground shrink-0">
{t("admin.dashboard.mostActiveUsersSubtitle", { range: rangeLabel })}
</div>
</div>
{mostActiveUsers.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{t("admin.dashboard.leaderboardEmpty")}
</div>
) : (
<ol className="space-y-2">
{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 (
<li key={u.userId}>
<button
type="button"
onClick={() => setDrillIn({ kind: "user", userId: u.userId })}
aria-label={t("admin.dashboard.viewUserDetails", { name: displayName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
{idx + 1}
</div>
<LeaderboardAvatar src={avatarSrc} initial={initial} alt={displayName} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{displayName}
</div>
{displayName !== u.username && (
<div className="text-[11px] text-muted-foreground truncate">
@{u.username}
</div>
)}
</div>
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
{u.count}
</div>
</div>
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-300 to-emerald-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
</button>
</li>
);
})}
</ol>
)}
</div>
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.recentActivity")}
</div>
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/60 border border-slate-200/60">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-emerald-100/70 text-emerald-600 shrink-0">
<UsersIcon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{t("admin.dashboard.latestUser")}</div>
<div className="text-sm font-medium text-foreground truncate">
{latestUser?.username ?? t("admin.dashboard.none")}
</div>
</div>
{latestUser?.createdAt && (
<div className="text-xs text-muted-foreground shrink-0">{formatDate(latestUser.createdAt)}</div>
)}
</div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/60 border border-slate-200/60">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-indigo-100/70 text-indigo-600 shrink-0">
<Grid2X2 size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{t("admin.dashboard.latestApp")}</div>
<div className="text-sm font-medium text-foreground truncate">
{latestApp ? (lang === "ar" ? latestApp.nameAr : latestApp.nameEn) : t("admin.dashboard.none")}
</div>
</div>
{latestApp?.createdAt && (
<div className="text-xs text-muted-foreground shrink-0">{formatDate(latestApp.createdAt)}</div>
)}
</div>
</div>
</div>
{drillIn?.kind === "app" && (
<AppOpensDrillIn
appId={drillIn.appId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
onJumpUser={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
/>
)}
{drillIn?.kind === "user" && (
<UserOpensDrillIn
userId={drillIn.userId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
onJumpApp={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
/>
)}
</div>
);
}
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 (
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-[85vh] flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground truncate">{subtitle}</div>
</div>
<button
type="button"
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded-lg hover:bg-slate-100"
aria-label={t("common.close")}
>
{t("common.close")}
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">{children}</div>
{footer && <div className="pt-2 border-t border-slate-200/70">{footer}</div>}
</div>
</div>
);
}
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<ExtraOpen[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(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 (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(appId)}>
{t("admin.dashboard.drillIn.editApp")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<>
<ol className="space-y-1.5">
{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 (
<li key={o.id}>
<button
type="button"
onClick={() => onJumpUser(o.userId)}
aria-label={t("admin.dashboard.viewUserDetails", { name: display })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{display}</div>
{display !== o.username && (
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
)}
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</button>
</li>
);
})}
</ol>
<LoadMoreSection
shownCount={opens.length}
totalCount={data.totalCount}
nextOffset={nextOffset}
isLoadingMore={isLoadingMore}
loadMoreError={loadMoreError}
onLoadMore={onLoadMore}
/>
</>
)}
</DrillInShell>
);
}
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 (
<div className="mt-3 flex flex-col items-center gap-1.5">
<div className="text-[11px] text-muted-foreground tabular-nums">
{t("admin.dashboard.drillIn.shownOf", { shown: shownCount, total: totalCount })}
</div>
{nextOffset !== null && (
<Button
size="sm"
variant="outline"
onClick={onLoadMore}
disabled={isLoadingMore}
className="gap-2"
>
{isLoadingMore && <Loader2 size={14} className="animate-spin" />}
{t("admin.dashboard.drillIn.loadMore")}
</Button>
)}
{loadMoreError && (
<div className="text-[11px] text-destructive">
{t("admin.dashboard.drillIn.loadMoreError")}
</div>
)}
</div>
);
}
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<ExtraOpen[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(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 (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(userId)}>
{t("admin.dashboard.drillIn.viewUser")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<>
<ol className="space-y-1.5">
{opens.map((o) => {
const name = lang === "ar" ? o.nameAr : o.nameEn;
return (
<li key={o.id}>
<button
type="button"
onClick={() => onJumpApp(o.appId)}
aria-label={t("admin.dashboard.viewAppDetails", { name })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<div
className="w-8 h-8 rounded-lg shrink-0"
style={{ backgroundColor: `${o.color}40` }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{name}</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</button>
</li>
);
})}
</ol>
<LoadMoreSection
shownCount={opens.length}
totalCount={data.totalCount}
nextOffset={nextOffset}
isLoadingMore={isLoadingMore}
loadMoreError={loadMoreError}
onLoadMore={onLoadMore}
/>
</>
)}
</DrillInShell>
);
}
// ===== Dependency drill-in =====
//
// Generic shell + per-kind list rendering that turns each "<n> 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 <button>s in the panels so keyboard focus + Enter open
// the drill-in.
type DependencyTarget =
| { kind: "appGroups"; appId: number; appName: string }
| { kind: "appRestrictions"; appId: number; appName: string }
| { kind: "appOpens"; appId: number; appName: string }
| { kind: "serviceOrders"; serviceId: number; serviceName: string }
| { kind: "userNotes"; userId: number; userLabel: string }
| { kind: "userOrders"; userId: number; userLabel: string };
type DependencyPage =
| AppDependentGroupsPage
| AppDependentRestrictionsPage
| AppDependentOpensPage
| ServiceDependentOrdersPage
| UserDependentNotesPage
| UserDependentOrdersPage;
const DEPENDENT_PAGE_LIMIT = 50;
function DependencyDrillIn({
target,
lang,
onClose,
}: {
target: DependencyTarget;
lang: string;
onClose: () => void;
}) {
const { t } = useTranslation();
// Each kind picks its own React Query hook + paginated fetcher so the
// "load more" behavior matches the existing AppOpensDrillIn pattern.
const initial = useDependencyInitial(target);
type Item = DependencyPage["items"][number];
const [extra, setExtra] = useState<Item[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
// Reset paging state if the user reopens the drill on a different row
// before this instance unmounts (state otherwise persists with the key).
const targetKey = JSON.stringify(target);
useEffect(() => {
setExtra([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [targetKey]);
const errorMessage = initial.error
? (initial.error as { message?: string }).message ?? null
: null;
const data = initial.data;
const items = data ? [...data.items, ...extra] : [];
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
const onLoadMore = async () => {
if (nextOffset === null || isLoadingMore) return;
setIsLoadingMore(true);
setLoadMoreError(null);
try {
const next = await fetchDependentsPage(target, {
limit: DEPENDENT_PAGE_LIMIT,
offset: nextOffset,
});
setExtra((prev) => [...prev, ...(next.items as Item[])]);
setExtraNextOffset(next.nextOffset);
setHasExtras(true);
} catch (e) {
setLoadMoreError((e as { message?: string }).message ?? "Error");
} finally {
setIsLoadingMore(false);
}
};
const totalCount = data?.totalCount ?? 0;
const headings = describeDependencyTarget(t, target, totalCount);
return (
<DrillInShell title={headings.title} subtitle={headings.subtitle} onClose={onClose}>
{initial.isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dependents.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">
{errorMessage}
</div>
</div>
) : !data || items.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dependents.empty")}
</div>
) : (
<>
<ul className="space-y-1.5">
{items.map((item, idx) => (
<li key={`${idx}-${(item as { id?: number }).id ?? idx}`}>
<DependencyRow target={target} item={item} lang={lang} t={t} />
</li>
))}
</ul>
<LoadMoreSection
shownCount={items.length}
totalCount={totalCount}
nextOffset={nextOffset}
isLoadingMore={isLoadingMore}
loadMoreError={loadMoreError}
onLoadMore={onLoadMore}
/>
</>
)}
</DrillInShell>
);
}
// React Query hooks must be called unconditionally and in a stable order,
// so we always call all eight, but only the matching one is `enabled`.
function useDependencyInitial(target: DependencyTarget): {
data: DependencyPage | undefined;
isLoading: boolean;
error: unknown;
} {
const params = { limit: DEPENDENT_PAGE_LIMIT, offset: 0 };
const appId =
target.kind === "appGroups" ||
target.kind === "appRestrictions" ||
target.kind === "appOpens"
? target.appId
: 0;
const serviceId = target.kind === "serviceOrders" ? target.serviceId : 0;
const userId =
target.kind === "userNotes" || target.kind === "userOrders"
? target.userId
: 0;
const enabledFor = (k: DependencyTarget["kind"]) => target.kind === k;
const groups = useGetAdminAppDependentGroups(appId, params, {
query: {
queryKey: getGetAdminAppDependentGroupsQueryKey(appId, params),
enabled: enabledFor("appGroups"),
retry: false,
},
});
const restrictions = useGetAdminAppDependentRestrictions(appId, params, {
query: {
queryKey: getGetAdminAppDependentRestrictionsQueryKey(appId, params),
enabled: enabledFor("appRestrictions"),
retry: false,
},
});
const opens = useGetAdminAppDependentOpens(appId, params, {
query: {
queryKey: getGetAdminAppDependentOpensQueryKey(appId, params),
enabled: enabledFor("appOpens"),
retry: false,
},
});
const sOrders = useGetAdminServiceDependentOrders(serviceId, params, {
query: {
queryKey: getGetAdminServiceDependentOrdersQueryKey(serviceId, params),
enabled: enabledFor("serviceOrders"),
retry: false,
},
});
const uNotes = useGetAdminUserDependentNotes(userId, params, {
query: {
queryKey: getGetAdminUserDependentNotesQueryKey(userId, params),
enabled: enabledFor("userNotes"),
retry: false,
},
});
const uOrders = useGetAdminUserDependentOrders(userId, params, {
query: {
queryKey: getGetAdminUserDependentOrdersQueryKey(userId, params),
enabled: enabledFor("userOrders"),
retry: false,
},
});
switch (target.kind) {
case "appGroups":
return groups;
case "appRestrictions":
return restrictions;
case "appOpens":
return opens;
case "serviceOrders":
return sOrders;
case "userNotes":
return uNotes;
case "userOrders":
return uOrders;
}
}
async function fetchDependentsPage(
target: DependencyTarget,
params: { limit: number; offset: number },
): Promise<DependencyPage> {
switch (target.kind) {
case "appGroups":
return getAdminAppDependentGroups(target.appId, params);
case "appRestrictions":
return getAdminAppDependentRestrictions(target.appId, params);
case "appOpens":
return getAdminAppDependentOpens(target.appId, params);
case "serviceOrders":
return getAdminServiceDependentOrders(target.serviceId, params);
case "userNotes":
return getAdminUserDependentNotes(target.userId, params);
case "userOrders":
return getAdminUserDependentOrders(target.userId, params);
}
}
function describeDependencyTarget(
t: ReturnType<typeof useTranslation>["t"],
target: DependencyTarget,
totalCount: number,
): { title: string; subtitle: string } {
switch (target.kind) {
case "appGroups":
return {
title: t("admin.dependents.appGroups.title", { name: target.appName }),
subtitle: t("admin.dependents.appGroups.subtitle", { count: totalCount }),
};
case "appRestrictions":
return {
title: t("admin.dependents.appRestrictions.title", { name: target.appName }),
subtitle: t("admin.dependents.appRestrictions.subtitle", { count: totalCount }),
};
case "appOpens":
return {
title: t("admin.dependents.appOpens.title", { name: target.appName }),
subtitle: t("admin.dependents.appOpens.subtitle", { count: totalCount }),
};
case "serviceOrders":
return {
title: t("admin.dependents.serviceOrders.title", { name: target.serviceName }),
subtitle: t("admin.dependents.serviceOrders.subtitle", { count: totalCount }),
};
case "userNotes":
return {
title: t("admin.dependents.userNotes.title", { name: target.userLabel }),
subtitle: t("admin.dependents.userNotes.subtitle", { count: totalCount }),
};
case "userOrders":
return {
title: t("admin.dependents.userOrders.title", { name: target.userLabel }),
subtitle: t("admin.dependents.userOrders.subtitle", { count: totalCount }),
};
}
}
function DependencyRow({
target,
item,
lang,
t,
}: {
target: DependencyTarget;
item: DependencyPage["items"][number];
lang: string;
t: ReturnType<typeof useTranslation>["t"];
}) {
const baseRowClass =
"flex items-start gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 text-start";
switch (target.kind) {
case "appGroups": {
const g = item as AppDependentGroupsPage["items"][number];
const description = lang === "ar" ? g.descriptionAr : g.descriptionEn;
return (
<div className={baseRowClass}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{g.name}</div>
{description && (
<div className="text-[11px] text-muted-foreground truncate">{description}</div>
)}
</div>
</div>
);
}
case "appRestrictions": {
const p = item as AppDependentRestrictionsPage["items"][number];
const description = lang === "ar" ? p.descriptionAr : p.descriptionEn;
return (
<div className={baseRowClass + " flex-col items-stretch"}>
<div className="text-sm font-medium text-foreground truncate">{p.name}</div>
{description && (
<div className="text-[11px] text-muted-foreground truncate">{description}</div>
)}
<div className="flex flex-wrap gap-1 pt-1">
{p.roles.length === 0 ? (
<span className="text-[11px] text-muted-foreground">
{t("admin.dependents.appRestrictions.noRoles")}
</span>
) : (
p.roles.map((r) => (
<span
key={r}
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary"
>
{r}
</span>
))
)}
</div>
</div>
);
}
case "appOpens": {
const o = item as AppDependentOpensPage["items"][number];
const display = (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
return (
<div className={baseRowClass}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{display}</div>
{display !== o.username && (
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
)}
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</div>
);
}
case "serviceOrders": {
const o = item as ServiceDependentOrdersPage["items"][number];
const display = (lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
return (
<div className={baseRowClass}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{display}</div>
<div className="text-[11px] text-muted-foreground truncate">
{t(`admin.dependents.orderStatus.${o.status}`, {
defaultValue: o.status,
})}
</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</div>
);
}
case "userNotes": {
const n = item as UserDependentNotesPage["items"][number];
const title = n.title.trim() || t("admin.dependents.userNotes.untitled");
return (
<div className={baseRowClass}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{title}</div>
<div className="text-[11px] text-muted-foreground truncate">
{n.isPinned && <span className="me-2">{t("admin.dependents.userNotes.pinned")}</span>}
{n.isArchived && <span>{t("admin.dependents.userNotes.archived")}</span>}
</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(n.updatedAt, lang)}
</div>
</div>
);
}
case "userOrders": {
const o = item as UserDependentOrdersPage["items"][number];
const serviceName = lang === "ar" ? o.serviceNameAr : o.serviceNameEn;
return (
<div className={baseRowClass}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{serviceName}</div>
<div className="text-[11px] text-muted-foreground truncate">
{t(`admin.dependents.orderStatus.${o.status}`, {
defaultValue: o.status,
})}
</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</div>
);
}
}
}
// ===== Users Management Panel =====
type UserSortKey = "username" | "email" | "createdAt";
type OpenAuditLogForTarget = (
targetType: "app" | "service" | "user" | "group" | "role",
targetId: number,
) => void;
function UsersPanel({
currentUserId,
highlightedUserId,
userRowRefs,
onIssueResetLink,
onOpenAuditLogForTarget,
}: {
currentUserId: number;
highlightedUserId: number | null;
userRowRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
onIssueResetLink: (userId: number, username: string) => void;
onOpenAuditLogForTarget: OpenAuditLogForTarget;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [groupFilter, setGroupFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [sortKey, setSortKey] = useState<UserSortKey>("username");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [editingUser, setEditingUser] = useState<UserProfile | null>(null);
const [userDeleteTarget, setUserDeleteTarget] = useState<UserProfile | null>(null);
const [userDeleteConflict, setUserDeleteConflict] = useState<UserDeletionConflict | null>(null);
const [newUserOpen, setNewUserOpen] = useState(false);
const [dependencyTarget, setDependencyTarget] = useState<DependencyTarget | null>(null);
const [newUserForm, setNewUserForm] = useState<{
username: string;
email: string;
password: string;
groupIds: number[];
}>({ username: "", email: "", password: "", groupIds: [] });
const listParams = useMemo<ListUsersParams>(() => {
const p: ListUsersParams = {};
if (search.trim()) p.q = search.trim();
if (groupFilter !== "all") p.groupId = groupFilter;
if (statusFilter === "inactive") p.status = ListUsersStatus.disabled;
else if (statusFilter === "active") p.status = ListUsersStatus.active;
return p;
}, [search, groupFilter, statusFilter]);
const { data: users } = useListUsers(listParams, { query: { queryKey: getListUsersQueryKey(listParams) } });
const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } });
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const addUserRole = useAddUserRole();
const removeUserRole = useRemoveUserRole();
const filtered = (users ?? []).slice().sort((a, b) => {
const av =
sortKey === "createdAt" ? a.createdAt : sortKey === "email" ? a.email : a.username;
const bv =
sortKey === "createdAt" ? b.createdAt : sortKey === "email" ? b.email : b.username;
const cmp = String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
const invalidateUsers = () =>
queryClient.invalidateQueries({ queryKey: [`/api/users`] });
const closeUserDeleteDialog = () => {
setUserDeleteTarget(null);
setUserDeleteConflict(null);
};
const performUserDelete = (target: UserProfile, force: boolean) => {
deleteUser.mutate(
{ id: target.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
invalidateUsers();
toast({ title: t("common.success") });
closeUserDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setUserDeleteConflict(err.data as UserDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
return (
<div className="space-y-3">
<div className="glass-panel rounded-2xl p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Input
placeholder={t("admin.users.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1 min-w-[180px]"
data-testid="user-search"
/>
<select
value={groupFilter === "all" ? "all" : String(groupFilter)}
onChange={(e) => setGroupFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-group-filter"
>
<option value="all">{t("admin.users.allGroups")}</option>
{groups?.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as "all" | "active" | "inactive")}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-status-filter"
>
<option value="all">{t("admin.users.allStatuses")}</option>
<option value="active">{t("admin.users.statusActive")}</option>
<option value="inactive">{t("admin.users.statusInactive")}</option>
</select>
<Button size="sm" onClick={() => { setNewUserForm({ username: "", email: "", password: "", groupIds: [] }); setNewUserOpen(true); }}>
<Plus size={14} className="me-1" />
{t("admin.addUser")}
</Button>
</div>
<div className="text-xs text-muted-foreground">
{t("admin.users.showing", { count: filtered.length, total: users?.length ?? 0 })}
</div>
</div>
{/* Table (desktop) / cards (mobile) */}
<div className="overflow-hidden rounded-2xl glass-panel">
<div className="hidden md:grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-3 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-slate-200/70">
{(["username", "email", "createdAt"] as UserSortKey[]).map((k) => (
<button
key={k}
onClick={() => {
if (sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc");
else { setSortKey(k); setSortDir("asc"); }
}}
className="text-start uppercase tracking-wider hover:text-foreground"
>
{t(`admin.users.col.${k}`)}
{sortKey === k && (sortDir === "asc" ? " ▲" : " ▼")}
</button>
))}
<div className="uppercase tracking-wider">{t("admin.users.col.groups")}</div>
<div className="uppercase tracking-wider text-end">{t("admin.users.col.actions")}</div>
</div>
<div className="divide-y divide-slate-200/70">
{filtered.map((u) => (
<div
key={u.id}
ref={(node) => {
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}`}
>
<div className="min-w-0">
<div className="font-medium text-sm text-foreground truncate">
{u.username}
{!u.isActive && (
<span className="ms-2 text-[10px] uppercase px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">
{t("admin.users.statusInactive")}
</span>
)}
</div>
{(u.displayNameAr || u.displayNameEn) && (
<div className="text-[11px] text-muted-foreground truncate" data-testid={`user-displayname-${u.id}`}>
{u.displayNameAr || u.displayNameEn}
</div>
)}
{(() => {
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 (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground pt-0.5"
data-testid={`user-counts-${u.id}`}
>
{parts.map((p, i) => (
<span key={p.testid} className="flex items-center gap-2">
{i > 0 && <span aria-hidden="true"></span>}
<button
type="button"
onClick={() => setDependencyTarget(p.target)}
className="underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm hover:text-foreground transition-colors"
data-testid={p.testid}
aria-label={t("admin.dependents.openLabel", { count: p.label })}
>
{p.label}
</button>
</span>
))}
</div>
);
})()}
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground truncate">{u.email}</div>
{u.preferredLanguage && (
<div className="text-[11px] uppercase tracking-wide text-muted-foreground" data-testid={`user-lang-${u.id}`}>
{u.preferredLanguage}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "—"}
</div>
<div className="flex flex-wrap gap-1">
{u.groups?.length ? u.groups.map((g) => (
<span key={g.id} className="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary">
{g.name}
</span>
)) : <span className="text-xs text-muted-foreground"></span>}
</div>
<div className="flex items-center gap-1 justify-end">
<Switch
checked={u.isActive}
onCheckedChange={(v) =>
updateUser.mutate(
{ id: u.id, data: { isActive: v } },
{ onSuccess: invalidateUsers },
)
}
/>
<button
onClick={() => setEditingUser(u)}
title={t("admin.users.editGroups")}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`user-edit-${u.id}`}
>
<Pencil size={14} />
</button>
<button
onClick={() => onIssueResetLink(u.id, u.username)}
title={t("admin.issueResetLink")}
className="p-1.5 hover:bg-primary/20 rounded-lg text-muted-foreground hover:text-primary"
>
<KeyRound size={14} />
</button>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground select-none">
<span className="hidden lg:inline">{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: invalidateUsers,
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
};
if (v) addUserRole.mutate({ id: u.id, data: { roleName: "order_receiver" } }, opts);
else removeUserRole.mutate({ id: u.id, roleName: "order_receiver" }, opts);
}}
/>
</label>
{u.id !== currentUserId && (
<button
onClick={() => {
// Pre-populate conflict from list-row counts so the
// warning dialog shows on the FIRST click. Falls back
// to the lazy 409 round-trip if counts are missing.
const noteCount = u.noteCount ?? 0;
const orderCount = u.orderCount ?? 0;
const hasDeps = noteCount > 0 || orderCount > 0;
setUserDeleteConflict(
hasDeps
? {
error: "User has dependent records",
noteCount,
orderCount,
}
: null,
);
setUserDeleteTarget(u);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`user-delete-${u.id}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t("admin.users.empty")}
</div>
)}
</div>
</div>
{newUserOpen && (
<AdminFormDialog
title={t("admin.addUser")}
icon={<UserPlus size={20} />}
onCancel={() => setNewUserOpen(false)}
isPending={createUser.isPending}
onSubmit={() => {
createUser.mutate(
{
data: {
username: newUserForm.username,
email: newUserForm.email,
password: newUserForm.password,
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
},
},
{
onSuccess: () => {
invalidateUsers();
setNewUserOpen(false);
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}}
>
<div className="space-y-1.5">
<Label>{t("auth.username")}</Label>
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t("auth.email")}</Label>
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t("auth.password")}</Label>
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t("admin.users.col.groups")}</Label>
<div className="max-h-44 overflow-y-auto rounded-xl border border-foreground/10 bg-foreground/[0.03] p-1.5 space-y-0.5">
{(groups ?? []).length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-2">{t("admin.groups.empty")}</div>
)}
{(groups ?? []).map((g) => {
const checked = newUserForm.groupIds.includes(g.id);
return (
<label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-2 py-1.5 rounded-lg hover:bg-foreground/5 transition-colors">
<input
type="checkbox"
className="accent-primary"
checked={checked}
onChange={(e) => {
setNewUserForm((prev) => ({
...prev,
groupIds: e.target.checked
? [...prev.groupIds, g.id]
: prev.groupIds.filter((id) => id !== g.id),
}));
}}
/>
<span className="text-foreground">{g.name}</span>
</label>
);
})}
</div>
</div>
</AdminFormDialog>
)}
{editingUser && (
<UserGroupsEditor
user={editingUser}
groups={groups ?? []}
onClose={() => 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 (
<DeletionWarningDialog
title={t("admin.deleteUser.title", { name: target.username })}
warning={t("admin.deleteUser.warning")}
forceHint={t("admin.deleteUser.forceHint")}
emptyBody={t("admin.deleteUser.emptyBody")}
confirmLabel={t("admin.deleteUser.confirm")}
anywayLabel={t("admin.deleteUser.anyway")}
items={items}
showWarning={userDeleteConflict !== null}
isPending={deleteUser.isPending}
onCancel={closeUserDeleteDialog}
onConfirm={() => performUserDelete(target, userDeleteConflict !== null)}
testId="user-delete-dialog"
confirmTestId="user-delete-confirm"
/>
);
})()}
{dependencyTarget && (
<DependencyDrillIn
target={dependencyTarget}
lang={lang}
onClose={() => setDependencyTarget(null)}
/>
)}
</div>
);
}
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<number>(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<Set<number>>(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<HTMLButtonElement>(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<HTMLElement | null>(() => {
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<number, string>();
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 (
<Dialog
open={true}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DialogContent
data-testid="edit-user-dialog"
className="glass-panel border-0 shadow-none rounded-3xl sm:rounded-3xl p-6 w-full max-w-md max-h-[85vh] overflow-y-auto gap-3"
onCloseAutoFocus={(e) => {
// 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();
}
}}
>
<DialogHeader>
<DialogTitle className="text-base font-semibold text-foreground">
{t("admin.users.editUserGroups", { username: user.username })}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.displayNameAr")}</Label>
<Input
value={displayNameAr}
onChange={(e) => setDisplayNameAr(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="edit-user-display-ar"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.displayNameEn")}</Label>
<Input
value={displayNameEn}
onChange={(e) => setDisplayNameEn(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="edit-user-display-en"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 items-end">
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.language")}</Label>
<select
value={preferredLanguage}
onChange={(e) => setPreferredLanguage(e.target.value)}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="edit-user-language"
>
<option value="ar">العربية</option>
<option value="en">English</option>
</select>
</div>
<label className="flex items-center gap-2 pb-2 text-sm">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<span>{t("admin.users.statusActive")}</span>
</label>
</div>
<div className="pt-1">
<Label className="text-xs">{t("admin.users.editGroups")}</Label>
<Input
placeholder={t("admin.groups.searchPlaceholder")}
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="bg-white/70 border-slate-200 mt-1 mb-2"
data-testid="edit-user-group-search"
/>
<div className="space-y-1 max-h-48 overflow-y-auto">
{visibleGroups.map((g) => (
<label key={g.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={selected.has(g.id)} onChange={() => toggle(g.id)} />
<span className="text-sm flex-1">{g.name}</span>
{g.isSystem && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">{t("admin.groups.system")}</span>}
</label>
))}
{visibleGroups.length === 0 && <p className="text-sm text-muted-foreground p-2">{t("admin.groups.empty")}</p>}
</div>
</div>
<PermissionAuditHistory
targetKind="user"
targetId={user.id}
enabled={true}
language={i18n.language}
scopeI18nKey="admin.users.history"
changeKindLabel={(ck) =>
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}`;
}}
/>
<RecentActivityForTarget
targetType="user"
targetId={user.id}
enabled={true}
lang={i18n.language}
onViewFullHistory={() => onOpenAuditLogForTarget("user", user.id)}
/>
<DialogFooter className="flex flex-row gap-2 pt-2 sm:justify-stretch sm:space-x-0">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
ref={saveBtnRef}
className="flex-1"
disabled={!hasChanges}
title={hasChanges ? undefined : t("admin.users.review.noChanges")}
onClick={() => setReviewOpen(true)}
data-testid="edit-user-save"
>
{t("common.save")}
</Button>
</DialogFooter>
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
<DialogContent
data-testid="edit-user-review-dialog"
className="glass-panel border-0 shadow-none rounded-3xl sm:rounded-3xl p-6 w-full max-w-md max-h-[85vh] overflow-y-auto gap-4"
onCloseAutoFocus={(e) => {
// 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();
}}
>
<DialogHeader className="space-y-1">
<DialogTitle className="text-base font-semibold text-foreground">
{t("admin.users.review.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.users.review.subtitle", { username: user.username })}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2 text-sm">
{fieldChanges.map((c) => (
<li
key={c.key}
className="flex flex-wrap items-baseline gap-x-2 gap-y-1"
data-testid={`edit-user-review-change-${c.key}`}
>
<span className="text-muted-foreground">{fieldLabel(c.key)}:</span>
<span className="line-through text-muted-foreground/70">
{renderValue(c.key, c.before)}
</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-foreground">
{renderValue(c.key, c.after)}
</span>
</li>
))}
</ul>
{groupDelta.added.length > 0 && (
<div data-testid="edit-user-review-group-added" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsAdded")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.added.map((name) => (
<span
key={`add-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800"
>
{name}
</span>
))}
</div>
</div>
)}
{groupDelta.removed.length > 0 && (
<div data-testid="edit-user-review-group-removed" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsRemoved")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.removed.map((name) => (
<span
key={`rem-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800 line-through"
>
{name}
</span>
))}
</div>
</div>
)}
<DialogFooter className="flex flex-row gap-2 pt-1 sm:justify-stretch sm:space-x-0">
<Button
variant="outline"
className="flex-1"
disabled={updateUser.isPending}
onClick={() => setReviewOpen(false)}
data-testid="edit-user-review-back"
>
{t("admin.users.review.back")}
</Button>
<Button
className="flex-1"
disabled={updateUser.isPending}
onClick={handleConfirmSave}
data-testid="edit-user-review-confirm"
>
{updateUser.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("admin.users.review.confirm")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}
// ===== 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<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [groupSearch, setGroupSearch] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Group | null>(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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder={t("admin.groups.searchPlaceholder")}
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1"
data-testid="group-search"
/>
<Button size="sm" onClick={() => { setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" }); setCreateOpen(true); }} data-testid="create-group-btn">
<Plus size={14} className="me-1" />
{t("admin.groups.addGroup")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{visibleGroups.map((g) => (
<div key={g.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`group-card-${g.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<Shield size={16} className="text-primary" />
<span className="truncate">{g.name}</span>
{g.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
{(g.descriptionAr || g.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1 truncate">
{g.descriptionEn || g.descriptionAr}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setEditingGroupId(g.id)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-group-${g.id}`}
>
<Pencil size={14} />
</button>
{!g.isSystem && (
<button
onClick={() => {
setConflictCounts(null);
setDeleteTarget(g);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-group-${g.id}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground pt-1">
<span>{t("admin.groups.members", { count: g.memberCount })}</span>
<span></span>
<span>{t("admin.groups.appsCount", { count: g.appCount })}</span>
<span></span>
<span>{t("admin.groups.rolesCount", { count: g.roleCount })}</span>
</div>
</div>
))}
{visibleGroups.length === 0 && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.groups.empty")}
</div>
)}
</div>
{createOpen && (
<AdminFormDialog
title={t("admin.groups.addGroup")}
icon={<UsersIcon size={20} />}
onCancel={() => setCreateOpen(false)}
submitDisabled={!createForm.name.trim()}
isPending={createGroup.isPending}
submitTestId="create-group-submit"
onSubmit={() =>
createGroup.mutate(
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
{
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
>
<div className="space-y-1.5">
<Label>{t("admin.groups.name")}</Label>
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} data-testid="group-name-input" />
</div>
<div className="space-y-1.5">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} dir="rtl" />
</div>
<div className="space-y-1.5">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} dir="ltr" />
</div>
</AdminFormDialog>
)}
{editingGroupId != null && (
<GroupDetailEditor
groupId={editingGroupId}
onClose={() => 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 (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="delete-group-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.groups.deleteTitle", { name: deleteTarget.name })}
</h2>
{showWarning ? (
<>
<p className="text-sm text-destructive">
{t("admin.groups.deleteWarning")}
</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.groups.members", { count: counts.memberCount })}</li>
<li>{t("admin.groups.appsCount", { count: counts.appCount })}</li>
<li>{t("admin.groups.rolesCount", { count: counts.roleCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">
{t("admin.groups.deleteForceHint")}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("admin.groups.deleteEmptyBody")}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={closeDeleteDialog}
disabled={deleteGroup.isPending}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
variant="destructive"
disabled={deleteGroup.isPending}
onClick={() => performDelete(deleteTarget, showWarning)}
data-testid="confirm-delete-group"
>
{showWarning
? t("admin.groups.deleteAnyway")
: t("admin.groups.deleteConfirm")}
</Button>
</div>
</div>
</div>
);
})()}
</div>
);
}
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<Set<number>>(new Set());
const [userIds, setUserIds] = useState<Set<number>>(new Set());
const [roleIds, setRoleIds] = useState<Set<number>>(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<number>, id: number, setter: (s: Set<number>) => void) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
setter(next);
};
if (!group) {
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md text-center">
<Loader2 className="animate-spin mx-auto" />
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-lg space-y-3 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("admin.groups.editGroup")}: {group.name}
</h2>
{group.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
<div className="flex border-b border-slate-200/70">
{(["info", "apps", "users", "roles", "history"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
"px-3 py-2 text-sm border-b-2 transition-colors",
tab === k ? "border-primary text-primary font-semibold" : "border-transparent text-muted-foreground hover:text-foreground",
)}
data-testid={`group-tab-${k}`}
>
{t(`admin.groups.tab.${k}`)}
</button>
))}
</div>
{tab === "info" && (
<div className="space-y-2">
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={group.isSystem} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={descriptionAr} onChange={(e) => setDescriptionAr(e.target.value)} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} className="bg-white/70 border-slate-200" dir="ltr" />
</div>
</div>
)}
{tab === "apps" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.appsHint")}</p>
{apps?.map((a) => (
<label key={a.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={appIds.has(a.id)} onChange={() => toggle(appIds, a.id, setAppIds)} />
<span className="text-sm flex-1">{lang === "ar" ? a.nameAr : a.nameEn}</span>
<span className="text-[10px] text-muted-foreground">{a.slug}</span>
</label>
))}
</div>
)}
{tab === "roles" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.rolesHint")}</p>
{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 (
<Tooltip key={r.id}>
<TooltipTrigger asChild>
<label
className="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer"
data-testid={`group-role-${r.id}`}
>
<input
type="checkbox"
className="mt-1"
checked={roleIds.has(r.id)}
onChange={() => toggle(roleIds, r.id, setRoleIds)}
/>
<span className="flex-1 min-w-0">
<span className="block text-sm">{heading}</span>
{showSecondary && (
<span
className="block text-xs text-muted-foreground"
dir={secondaryDir}
data-testid={`group-role-${r.id}-secondary`}
>
{secondaryDesc}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground mt-1 shrink-0 font-mono">
{r.name}
</span>
</label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1 text-xs">
{r.descriptionAr && <div dir="rtl">{r.descriptionAr}</div>}
{r.descriptionEn && <div dir="ltr">{r.descriptionEn}</div>}
<div className="opacity-70 font-mono" dir="ltr">{r.name}</div>
</div>
</TooltipContent>
</Tooltip>
);
})}
{(!roles || roles.length === 0) && (
<p className="text-sm text-muted-foreground p-2">{t("admin.groups.rolesEmpty")}</p>
)}
</div>
)}
{tab === "users" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground px-1">{t("admin.groups.usersHint")}</p>
<Input
placeholder={t("admin.users.searchPlaceholder")}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="group-user-search"
/>
<div className="space-y-1 max-h-60 overflow-y-auto">
{visibleUsers.map((u) => (
<label key={u.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={userIds.has(u.id)} onChange={() => toggle(userIds, u.id, setUserIds)} />
<span className="text-sm flex-1">{u.username}</span>
<span className="text-[10px] text-muted-foreground">{u.email}</span>
</label>
))}
{visibleUsers.length === 0 && (
<p className="text-sm text-muted-foreground p-2">{t("admin.users.empty")}</p>
)}
</div>
</div>
)}
{tab === "history" && (
<>
<PermissionAuditHistory
targetKind="group"
targetId={group.id}
enabled={true}
language={lang}
scopeI18nKey="admin.groups.history"
changeKindLabel={(ck) =>
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}`;
}}
/>
<RecentActivityForTarget
targetType="group"
targetId={group.id}
enabled={true}
lang={lang}
onViewFullHistory={() => {
onClose();
onOpenAuditLogForTarget("group", group.id);
}}
/>
</>
)}
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() =>
updateGroup.mutate(
{
id: group.id,
data: {
name: group.isSystem ? undefined : name.trim() || undefined,
descriptionAr: descriptionAr || null,
descriptionEn: descriptionEn || null,
appIds: Array.from(appIds),
userIds: Array.from(userIds),
roleIds: Array.from(roleIds),
},
},
{
onSuccess: () => { toast({ title: t("common.success") }); onSaved(); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="save-group"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
function permissionLabel(
id: number,
permissionsById: Map<number, Permission>,
): 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<number, Permission>;
language: string;
}) {
const { t } = useTranslation();
// Two layers of state: the live <input> 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<number | "">("");
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
// 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<RolePermissionAuditEntry[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(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<string | null>(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 <a download> 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 (
<div className="space-y-2 pt-2" data-testid="role-history-section">
<Label>{t("admin.roles.historyTitle")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.roles.historyHint")}</p>
<div
className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"
data-testid="role-history-filters"
>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.actor")}</Label>
<select
value={actorUserId === "" ? "" : String(actorUserId)}
onChange={(e) => {
const v = e.target.value;
setActorUserId(v === "" ? "" : Number(v));
// Pagination state is reset by the filtersKey effect.
}}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="role-history-actor-filter"
>
<option value="">
{t("admin.roles.historyFilters.allActors")}
</option>
{sortedUsers.map((u) => {
const label =
(language === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="role-history-from-input"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="role-history-to-input"
/>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid="role-history-apply-filters"
>
{t("admin.roles.historyFilters.apply")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={resetFilters}
disabled={!filtersActive && !fromInput && !toInput}
data-testid="role-history-reset-filters"
>
{t("admin.roles.historyFilters.reset")}
</Button>
</div>
</div>
{filterErrors.map((msg, i) => (
<p
key={i}
className="text-xs text-destructive"
data-testid="role-history-filter-error"
>
{msg}
</p>
))}
<div
className="text-xs text-muted-foreground"
data-testid="role-history-showing"
>
{filtersValid
? t("admin.roles.historyShowing", {
shown: showingCount,
total: totalCount,
})
: t("admin.roles.historyErrors.fixFiltersFirst")}
</div>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid="role-history-list"
>
{!filtersValid ? (
// Hide stale entries while the filter inputs are invalid so the
// list can't be confused with the (now wrong) filtered results.
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid="role-history-fix-filters"
>
{t("admin.roles.historyErrors.fixFiltersFirst")}
</p>
) : isLoading ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid="role-history-empty"
>
{filtersActive
? t("admin.roles.historyEmptyFiltered")
: t("admin.roles.historyEmpty")}
</p>
) : (
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 (
<div
key={entry.id}
className="p-2.5 text-xs space-y-1"
data-testid={`role-history-entry-${entry.id}`}
>
<div className="flex items-center justify-between gap-2 text-foreground">
<span className="font-medium" data-testid={`role-history-actor-${entry.id}`}>
{actorName ?? t("admin.roles.historyActorUnknown")}
</span>
<span
className="text-muted-foreground"
data-testid={`role-history-time-${entry.id}`}
>
{formatAuditTimestamp(entry.createdAt, language)}
</span>
</div>
{entry.addedPermissionIds.length > 0 && (
<div
className="text-emerald-700"
data-testid={`role-history-added-${entry.id}`}
>
<span className="font-semibold">
{t("admin.roles.historyAdded", {
count: entry.addedPermissionIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.addedPermissionIds
.map((id) => permissionLabel(id, permissionsById))
.join(", ")}
</span>
</div>
)}
{entry.removedPermissionIds.length > 0 && (
<div
className="text-rose-700"
data-testid={`role-history-removed-${entry.id}`}
>
<span className="font-semibold">
{t("admin.roles.historyRemoved", {
count: entry.removedPermissionIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.removedPermissionIds
.map((id) => permissionLabel(id, permissionsById))
.join(", ")}
</span>
</div>
)}
<div
className="text-muted-foreground"
data-testid={`role-history-total-${entry.id}`}
>
{t("admin.roles.historyTotal", {
count: entry.newPermissionIds.length,
})}
</div>
</div>
);
})
)}
</div>
{filtersValid && (hasMore || entries.length > 0) && (
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{hasMore && (
<Button
type="button"
size="sm"
variant="outline"
disabled={isLoadingMore || isFetching}
onClick={loadMore}
data-testid="role-history-load-more"
>
{isLoadingMore || isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.roles.historyLoadMore")
)}
</Button>
)}
{entries.length > 0 && (
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
disabled={isDownloadingCsv}
onClick={downloadCsv}
data-testid="role-history-download-csv"
>
{isDownloadingCsv ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t("admin.roles.historyDownloadCsv")}
</Button>
)}
</div>
)}
{loadMoreError && (
<p
className="text-xs text-destructive text-center"
data-testid="role-history-load-more-error"
>
{loadMoreError}
</p>
)}
{downloadCsvError && (
<p
className="text-xs text-destructive text-center"
data-testid="role-history-download-csv-error"
>
{t("admin.roles.historyDownloadCsvError")}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<number | "">("");
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [extraEntries, setExtraEntries] = useState<PermissionAuditEntry[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(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 (
<div className="space-y-2 pt-2" data-testid={`${testIdRoot}-section`}>
<Label>{t(`${scopeI18nKey}Title`)}</Label>
<p className="text-xs text-muted-foreground">{t(`${scopeI18nKey}Hint`)}</p>
<div
className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"
data-testid={`${testIdRoot}-filters`}
>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.actor")}</Label>
<select
value={actorUserId === "" ? "" : String(actorUserId)}
onChange={(e) => {
const v = e.target.value;
setActorUserId(v === "" ? "" : Number(v));
}}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid={`${testIdRoot}-actor-filter`}
>
<option value="">
{t("admin.roles.historyFilters.allActors")}
</option>
{sortedUsers.map((u) => {
const label =
(language === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid={`${testIdRoot}-from-input`}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid={`${testIdRoot}-to-input`}
/>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid={`${testIdRoot}-apply-filters`}
>
{t("admin.roles.historyFilters.apply")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={resetFilters}
disabled={!filtersActive && !fromInput && !toInput}
data-testid={`${testIdRoot}-reset-filters`}
>
{t("admin.roles.historyFilters.reset")}
</Button>
</div>
</div>
{filterErrors.map((msg, i) => (
<p
key={i}
className="text-xs text-destructive"
data-testid={`${testIdRoot}-filter-error`}
>
{msg}
</p>
))}
<div
className="text-xs text-muted-foreground"
data-testid={`${testIdRoot}-showing`}
>
{filtersValid
? t("admin.roles.historyShowing", {
shown: showingCount,
total: totalCount,
})
: t("admin.roles.historyErrors.fixFiltersFirst")}
</div>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid={`${testIdRoot}-list`}
>
{!filtersValid ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid={`${testIdRoot}-fix-filters`}
>
{t("admin.roles.historyErrors.fixFiltersFirst")}
</p>
) : isLoading ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid={`${testIdRoot}-empty`}
>
{filtersActive
? t(`${scopeI18nKey}EmptyFiltered`)
: t(`${scopeI18nKey}Empty`)}
</p>
) : (
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 (
<div
key={entry.id}
className="p-2.5 text-xs space-y-1"
data-testid={`${testIdRoot}-entry-${entry.id}`}
>
<div className="flex items-center justify-between gap-2 text-foreground">
<span
className="font-medium"
data-testid={`${testIdRoot}-actor-${entry.id}`}
>
{actorName ?? t("admin.roles.historyActorUnknown")}
</span>
<span
className="text-muted-foreground"
data-testid={`${testIdRoot}-time-${entry.id}`}
>
{formatAuditTimestamp(entry.createdAt, language)}
</span>
</div>
{changeKindLabel && (
<div
className="text-[10px] uppercase tracking-wider text-muted-foreground"
data-testid={`${testIdRoot}-kind-${entry.id}`}
>
{changeKindLabel(ck)}
</div>
)}
{entry.addedIds.length > 0 && (
<div
className="text-emerald-700"
data-testid={`${testIdRoot}-added-${entry.id}`}
>
<span className="font-semibold">
{t(`${scopeI18nKey}Added`, {
count: entry.addedIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.addedIds
.map((id) => labelResolver(ck, id))
.join(", ")}
</span>
</div>
)}
{entry.removedIds.length > 0 && (
<div
className="text-rose-700"
data-testid={`${testIdRoot}-removed-${entry.id}`}
>
<span className="font-semibold">
{t(`${scopeI18nKey}Removed`, {
count: entry.removedIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.removedIds
.map((id) => labelResolver(ck, id))
.join(", ")}
</span>
</div>
)}
<div
className="text-muted-foreground"
data-testid={`${testIdRoot}-total-${entry.id}`}
>
{t(`${scopeI18nKey}Total`, {
count: entry.newIds.length,
})}
</div>
</div>
);
})
)}
</div>
{filtersValid && hasMore && (
<div className="flex justify-center pt-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={isLoadingMore || isFetching}
onClick={loadMore}
data-testid={`${testIdRoot}-load-more`}
>
{isLoadingMore || isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.roles.historyLoadMore")
)}
</Button>
</div>
)}
{loadMoreError && (
<p
className="text-xs text-destructive text-center"
data-testid={`${testIdRoot}-load-more-error`}
>
{loadMoreError}
</p>
)}
</div>
);
}
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<number | null>(null);
const [editingIsSystem, setEditingIsSystem] = useState(false);
const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [originalEditName, setOriginalEditName] = useState("");
const [editPermissionIds, setEditPermissionIds] = useState<Set<number>>(new Set());
const [initialPermissionIds, setInitialPermissionIds] = useState<Set<number>>(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<RolePermissionsImpact | null>(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<number, Permission>();
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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder={t("admin.roles.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1"
data-testid="role-search"
/>
<Button
size="sm"
onClick={() => {
setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" });
setCreateOpen(true);
}}
data-testid="create-role-btn"
>
<Plus size={14} className="me-1" />
{t("admin.roles.addRole")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{visibleRoles.map((r) => (
<div key={r.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`role-card-${r.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<KeyRound size={16} className="text-primary" />
<span className="truncate">{r.name}</span>
{(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.roles.system")}
</span>
)}
</div>
{(r.descriptionAr || r.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1">
{r.descriptionEn || r.descriptionAr}
</p>
)}
{r.descriptionAr && r.descriptionEn && (
<p className="text-xs text-muted-foreground mt-0.5" dir="rtl">
{r.descriptionAr}
</p>
)}
{(() => {
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 (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground pt-0.5"
data-testid={`role-counts-${r.id}`}
>
{parts.map((p, i) => (
<span key={i} className="flex items-center gap-2">
{i > 0 && <span aria-hidden="true"></span>}
<span>{p}</span>
</span>
))}
</div>
);
})()}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => beginEdit(r)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-role-${r.id}`}
aria-label={t("admin.roles.edit")}
>
<Pencil size={14} />
</button>
{!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
<button
onClick={() => {
setDeleteConflict(null);
setDeleteTarget({ id: r.id, name: r.name });
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-role-${r.id}`}
aria-label={t("admin.roles.delete")}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
{visibleRoles.length === 0 && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.roles.empty")}
</div>
)}
</div>
{createOpen && (
<AdminFormDialog
title={t("admin.roles.addRole")}
icon={<KeyRound size={20} />}
onCancel={() => setCreateOpen(false)}
onSubmit={handleCreate}
submitDisabled={!createForm.name.trim()}
isPending={createRole.isPending}
testId="create-role-dialog"
submitTestId="create-role-submit"
>
<div className="space-y-1.5">
<Label>{t("admin.roles.name")}</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder={t("admin.roles.namePlaceholder")}
data-testid="role-name-input"
/>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
</div>
<div className="space-y-1.5">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={createForm.descriptionAr}
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
dir="rtl"
data-testid="role-desc-ar-input"
/>
</div>
<div className="space-y-1.5">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={createForm.descriptionEn}
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
dir="ltr"
data-testid="role-desc-en-input"
/>
</div>
</AdminFormDialog>
)}
{editingId != null && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[90vh] overflow-y-auto"
data-testid="edit-role-dialog"
>
<h2 className="font-semibold text-foreground">{t("admin.roles.editRole")}</h2>
<div className="space-y-1">
<Label>{t("admin.roles.name")}</Label>
<Input
value={editForm.name}
onChange={(e) => 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"
/>
<p className="text-xs text-muted-foreground">
{editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")}
</p>
{isRenaming && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-1"
data-testid="role-rename-warning"
role="alert"
>
<p className="font-semibold">
{t("admin.roles.renameWarningTitle", {
from: originalEditName,
to: editNameTrimmed,
})}
</p>
<p>{t("admin.roles.renameWarningBody")}</p>
{editingRoleUsage && (
<p
className="text-amber-800"
data-testid="role-rename-usage"
>
{t("admin.roles.renameUsage", {
users: editingRoleUsage.userCount,
groups: editingRoleUsage.groupCount,
})}
</p>
)}
</div>
)}
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={editForm.descriptionAr}
onChange={(e) => setEditForm({ ...editForm, descriptionAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-edit-desc-ar"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={editForm.descriptionEn}
onChange={(e) => setEditForm({ ...editForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-edit-desc-en"
/>
</div>
<div className="space-y-1 pt-2">
<Label>{t("admin.roles.permissions")}</Label>
<p className="text-xs text-muted-foreground">
{editingIsSystem
? t("admin.roles.permissionsSystemHint")
: t("admin.roles.permissionsHint")}
</p>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid="role-permissions-list"
>
{editingPermsLoading || !allPermissions ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : allPermissions.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{t("admin.roles.permissionsEmpty")}
</p>
) : (
allPermissions.map((p) => {
const checked = editPermissionIds.has(p.id);
const desc =
i18n.language === "ar"
? p.descriptionAr ?? p.descriptionEn
: p.descriptionEn ?? p.descriptionAr;
return (
<label
key={p.id}
className={cn(
"flex items-start gap-2 p-2.5 text-sm",
editingIsSystem
? "cursor-not-allowed opacity-80"
: "cursor-pointer hover:bg-slate-50",
)}
data-testid={`role-perm-row-${p.id}`}
>
<input
type="checkbox"
className="mt-0.5"
checked={checked}
disabled={editingIsSystem}
onChange={() => togglePermission(p.id)}
data-testid={`role-perm-checkbox-${p.id}`}
/>
<div className="min-w-0 flex-1">
<div className="font-mono text-xs text-foreground">{p.name}</div>
{desc && (
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
)}
</div>
</label>
);
})
)}
</div>
{!editingIsSystem && hasRemovals && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-2"
data-testid="role-perm-impact"
role="status"
aria-live="polite"
>
<p className="font-semibold">
{t("admin.roles.impactTitle", { count: removedPermissionIds.length })}
</p>
{impactLoading ? (
<div className="flex items-center gap-2 text-amber-800">
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.roles.impactLoading")}</span>
</div>
) : impactError || !permissionsImpact ? (
<p
className="text-amber-900"
data-testid="role-perm-impact-error"
>
{t("admin.roles.impactError")}
</p>
) : permissionsImpact.removed.length === 0 ? (
<p>{t("admin.roles.impactNoUsers")}</p>
) : (
<>
<ul
className="space-y-1.5 ps-4 list-disc"
data-testid="role-perm-impact-list"
>
{permissionsImpact.removed.map((item) => (
<li key={item.permissionId}>
<span className="font-mono">{item.permissionName}</span>
{": "}
<span data-testid={`role-perm-impact-counts-${item.permissionId}`}>
{t("admin.roles.impactPerPermission", {
users: item.userCount,
groups: item.groupCount,
})}
</span>
{item.groupCount > 0 && (
<span className="text-amber-800">
{" "}
{t("admin.roles.impactViaGroups", {
groups: item.groups.map((g) => g.name).join(", "),
})}
</span>
)}
</li>
))}
</ul>
{permissionsImpact.totalAffectedUsers > 0 && (
<p
className="font-semibold text-amber-900"
data-testid="role-perm-impact-total"
>
{t("admin.roles.impactTotal", {
count: permissionsImpact.totalAffectedUsers,
})}
</p>
)}
</>
)}
</div>
)}
</div>
<RolePermissionHistory
roleId={editingPermissionsId}
enabled={editingId != null}
permissionsById={permissionsById}
language={i18n.language}
/>
<RecentActivityForTarget
targetType="role"
targetId={editingPermissionsId}
enabled={editingId != null}
lang={i18n.language}
onViewFullHistory={() => {
closeEditDialog();
onOpenAuditLogForTarget("role", editingPermissionsId);
}}
/>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={
!editForm.name.trim() ||
updateRole.isPending ||
replaceRolePermissions.isPending ||
(!editingIsSystem && hasRemovals && (impactLoading || impactError))
}
onClick={handleEdit}
data-testid="edit-role-submit"
>
{updateRole.isPending || replaceRolePermissions.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("common.save")
)}
</Button>
</div>
</div>
</div>
)}
{confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3"
data-testid="role-perm-confirm-dialog"
role="alertdialog"
aria-modal="true"
>
<h2 className="font-semibold text-foreground">
{t("admin.roles.confirmRemovalTitle")}
</h2>
<p className="text-sm text-foreground">
{t("admin.roles.confirmRemovalBody", {
count: permissionsImpact.totalAffectedUsers,
})}
</p>
<ul
className="text-xs text-muted-foreground space-y-1 ps-4 list-disc max-h-40 overflow-y-auto"
>
{permissionsImpact.removed.map((item) => (
<li key={item.permissionId}>
<span className="font-mono text-foreground">{item.permissionName}</span>
{": "}
{t("admin.roles.impactPerPermission", {
users: item.userCount,
groups: item.groupCount,
})}
</li>
))}
</ul>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setConfirmRemoval(false)}
data-testid="role-perm-confirm-cancel"
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="flex-1"
disabled={updateRole.isPending || replaceRolePermissions.isPending}
onClick={handleEdit}
data-testid="role-perm-confirm-submit"
>
{updateRole.isPending || replaceRolePermissions.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.roles.confirmRemovalAction")
)}
</Button>
</div>
</div>
</div>
)}
{deleteTarget && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="delete-role-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.roles.deleteTitle", { name: deleteTarget.name })}
</h2>
{deleteConflict ? (
<>
<p className="text-sm text-destructive">{t("admin.roles.deleteConflict")}</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.roles.usersCount", { count: deleteConflict.userCount })}</li>
<li>{t("admin.roles.groupsCount", { count: deleteConflict.groupCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">{t("admin.roles.deleteConflictHint")}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("admin.roles.deleteEmptyBody")}</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setDeleteTarget(null);
setDeleteConflict(null);
}}
>
{t("common.cancel")}
</Button>
{!deleteConflict && (
<Button
variant="destructive"
className="flex-1"
disabled={deleteRole.isPending}
onClick={handleDelete}
data-testid="delete-role-confirm"
>
{deleteRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("admin.roles.deleteConfirm")}
</Button>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ===== 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<typeof useTranslation>["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<string, string> = {
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<string, string> = {
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 (
<div
className="glass-panel rounded-2xl p-3 space-y-2"
data-testid={`audit-row-${entry.id}`}
>
<div className="flex items-start gap-3">
{canPivotActor ? (
<button
type="button"
onClick={() => onPivotToActor(actor!.id)}
aria-label={actorPivotAria}
title={actorPivotAria}
className="rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 hover:opacity-80 transition-opacity"
data-testid={`audit-row-actor-avatar-${entry.id}`}
>
<LeaderboardAvatar
src={actor?.avatarUrl ?? null}
initial={initial}
alt={actor?.username ?? ""}
/>
</button>
) : (
<LeaderboardAvatar src={null} initial={initial} alt="" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
{canPivotActor ? (
<button
type="button"
onClick={() => onPivotToActor(actor!.id)}
aria-label={actorPivotAria}
title={actorPivotAria}
className="font-medium text-sm text-foreground truncate text-start hover:text-sky-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 rounded-sm"
data-testid={`audit-row-actor-name-${entry.id}`}
>
{actorLabel(actor, lang)}
</button>
) : (
<span className="font-medium text-sm text-foreground truncate">
{actorLabel(actor, lang)}
</span>
)}
{actor && (
<span className="text-xs text-muted-foreground">
@{actor.username}
</span>
)}
</div>
{summary ? (
<div
className="text-sm text-foreground mt-1 leading-snug"
data-testid={`audit-summary-${entry.id}`}
>
{summary}
</div>
) : (
<div className="text-xs text-muted-foreground mt-1">
{showDeletedTarget
? t("admin.audit.targetWithName", {
type: entry.targetType,
id: entry.targetId,
name: deletedName,
})
: t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
})}
</div>
)}
{summary && showDeletedTarget && (
<div
className="text-xs text-muted-foreground mt-0.5"
data-testid={`audit-deleted-target-${entry.id}`}
>
{t("admin.audit.targetWithName", {
type: entry.targetType,
id: entry.targetId,
name: deletedName,
})}
</div>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<span
className={cn(
"inline-block text-[11px] font-mono px-2 py-0.5 rounded-md",
isForced
? "bg-rose-100 text-rose-800"
: "bg-amber-100 text-amber-800",
)}
data-testid={`audit-action-${entry.id}`}
>
{entry.action}
</span>
<span className="text-xs text-muted-foreground">
{formatAuditTimestamp(entry.createdAt, lang)}
</span>
</div>
{chips.length > 0 && (
<div
className="flex flex-wrap items-center gap-1.5 mt-1.5"
data-testid={`audit-deps-${entry.id}`}
aria-label={t("admin.audit.dependencies.label")}
>
{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 (
<button
type="button"
key={chip.key}
onClick={() => onPivotToTarget(chip.pivot)}
aria-label={ariaLabel}
title={ariaLabel}
className="inline-flex items-center text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100 hover:bg-rose-100 hover:border-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 transition-colors cursor-pointer"
data-testid={`audit-dep-${entry.id}-${chip.key}`}
>
{chip.label}
</button>
);
})}
</div>
)}
</div>
{hasMetadata && (
<button
onClick={() => setExpanded((v) => !v)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground shrink-0"
aria-expanded={expanded}
data-testid={`audit-toggle-${entry.id}`}
>
<ChevronDown
size={16}
className={cn("transition-transform", expanded ? "rotate-180" : "")}
/>
</button>
)}
</div>
{expanded && hasMetadata && (
<pre
className="text-[11px] bg-slate-100/80 rounded-lg p-2 overflow-x-auto leading-snug whitespace-pre-wrap break-all"
dir="ltr"
data-testid={`audit-metadata-${entry.id}`}
>
{JSON.stringify(entry.metadata, null, 2)}
</pre>
)}
</div>
);
}
// #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 (
<div
className="space-y-2 pt-3 border-t border-slate-200/70"
data-testid={testIdRoot}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">
{t("admin.audit.recent.title")}
</h3>
{totalCount > entries.length && (
<span className="text-[11px] text-muted-foreground">
{t("admin.audit.showing", {
shown: entries.length,
total: totalCount,
})}
</span>
)}
</div>
{isLoading ? (
<div
className="flex items-center gap-2 text-xs text-muted-foreground"
data-testid={`${testIdRoot}-loading`}
>
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.audit.recent.loading")}</span>
</div>
) : entries.length === 0 ? (
<p
className="text-xs text-muted-foreground"
data-testid={`${testIdRoot}-empty`}
>
{t("admin.audit.recent.empty")}
</p>
) : (
<ul
className="space-y-1.5"
data-testid={`${testIdRoot}-list`}
>
{entries.map((entry) => {
const summary =
formatAuditSummary(entry, t, lang) ??
t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
});
return (
<li
key={entry.id}
className="text-xs text-foreground rounded-lg bg-white/60 border border-slate-100 px-2.5 py-1.5"
data-testid={`${testIdRoot}-row-${entry.id}`}
>
<div
className="leading-snug"
data-testid={`${testIdRoot}-summary-${entry.id}`}
>
{summary}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground mt-0.5">
<span className="truncate">
{actorLabel(entry.actor, lang)}
</span>
<span aria-hidden="true">·</span>
<span>{formatAuditTimestamp(entry.createdAt, lang)}</span>
</div>
</li>
);
})}
</ul>
)}
<button
type="button"
onClick={onViewFullHistory}
aria-label={t("admin.audit.recent.viewAllAria", { type: targetType })}
className="text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm"
data-testid={`${testIdRoot}-view-all`}
>
{t("admin.audit.recent.viewAll")}
</button>
</div>
);
}
// 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 <select> so
// admins on a busy site (hundreds of users) can find a teammate by typing
// a few characters instead of scrolling. The "Anyone" item clears the
// filter; the rest pass back the user's id to the parent.
function AuditActorPicker({
actors,
lang,
actorUserId,
onChange,
placeholder,
searchPlaceholder,
emptyMessage,
clearLabel,
}: {
actors: UserProfile[];
lang: string;
actorUserId: number | "";
onChange: (next: number | "") => void;
placeholder: string;
searchPlaceholder: string;
emptyMessage: string;
clearLabel: string;
}) {
const [open, setOpen] = useState(false);
const selected =
actorUserId !== "" ? actors.find((u) => u.id === actorUserId) ?? null : null;
const triggerLabel = selected
? (lang === "ar"
? selected.displayNameAr ?? selected.displayNameEn
: selected.displayNameEn ?? selected.displayNameAr) ?? selected.username
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
data-testid="audit-actor-filter"
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full text-start flex items-center justify-between gap-2 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{triggerLabel}
</span>
<ChevronDown size={14} className="shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0" align="start">
<Command
// cmdk's default search ranks against a single string. We feed it
// a joined "displayName · username · @username" so a query matches
// either the display name or the handle (with or without @).
filter={(value, search) => {
if (!search) return 1;
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput
placeholder={searchPlaceholder}
data-testid="audit-actor-filter-search"
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
<CommandItem
value={`__all__ ${placeholder} ${clearLabel}`}
onSelect={() => {
onChange("");
setOpen(false);
}}
data-testid="audit-actor-filter-option-any"
>
<span className="text-muted-foreground">{placeholder}</span>
</CommandItem>
{actors.map((u) => {
const display =
(lang === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<CommandItem
key={u.id}
value={`${display} ${u.username} @${u.username}`}
onSelect={() => {
onChange(u.id);
setOpen(false);
}}
data-testid={`audit-actor-filter-option-${u.id}`}
>
<div className="min-w-0 flex-1">
<div className="text-sm text-foreground truncate">
{display}
</div>
<div className="text-[11px] text-muted-foreground truncate">
@{u.username}
</div>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function AuditLogPanel() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [actionFilter, setActionFilter] = useState<string>("");
const [forcedOnly, setForcedOnly] = useState<boolean>(false);
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [pageLimit, setPageLimit] = useState<number>(50);
const [exporting, setExporting] = useState<boolean>(false);
const [exportError, setExportError] = useState<string | null>(null);
// #197: actor filter — applies immediately like the History tabs do, since
// picking a different person is the natural way to commit the choice.
// Initialised from the URL hash so a deep link like
// `/admin#section=audit-log&actorId=42` lands directly on Alice's audit
// history, and so chip pivots survive reload / back-forward navigation.
const [actorUserId, setActorUserId] = useState<number | "">(() => {
const fromHash = parseAuditHashActor();
return fromHash != null ? fromHash : "";
});
// Deep-link target filters. Initialised from the URL hash so a user who
// pastes /admin#section=audit-log&targetType=group lands directly on a
// pre-filtered view. Updated by chip clicks and the explicit clear button.
const [targetTypeFilter, setTargetTypeFilter] = useState<string>(
() => parseAuditHashTarget().targetType,
);
const [targetIdFilter, setTargetIdFilter] = useState<number | null>(
() => parseAuditHashTarget().targetId,
);
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
const toValid = !appliedTo || DATE_RE.test(appliedTo);
const orderValid =
!appliedFrom || !appliedTo || appliedFrom <= appliedTo;
const filtersValid = fromValid && toValid && orderValid;
const params: ListAuditLogsParams = {
limit: pageLimit,
offset: 0,
...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
};
// #197: list of users for the actor dropdown. Reuses the existing admin
// users query (already cached for the rest of the admin page).
const { data: usersForActor } = useListUsers(undefined, {
query: { queryKey: getListUsersQueryKey() },
});
const sortedActors = useMemo(() => {
const list = (usersForActor ?? []).slice();
list.sort((a, b) => {
const aLabel =
(lang === "ar"
? a.displayNameAr ?? a.displayNameEn
: a.displayNameEn ?? a.displayNameAr) ?? a.username;
const bLabel =
(lang === "ar"
? b.displayNameAr ?? b.displayNameEn
: b.displayNameEn ?? b.displayNameAr) ?? b.username;
return aLabel.localeCompare(bLabel);
});
return list;
}, [usersForActor, lang]);
const queryKey = getListAuditLogsQueryKey(params);
const { data, isLoading, isFetching } = useListAuditLogs(params, {
query: { queryKey, enabled: filtersValid },
});
const inputErrors: string[] = [];
if (!fromValid || !toValid) inputErrors.push(t("admin.audit.errors.invalidDate"));
else if (!orderValid) inputErrors.push(t("admin.audit.errors.invertedDates"));
const knownActions = data?.actions ?? [];
const entries = data?.entries ?? [];
const totalCount = data?.totalCount ?? 0;
const showingCount = entries.length;
const hasMore = data?.nextOffset != null;
const applyFilters = () => {
setAppliedFrom(fromInput.trim());
setAppliedTo(toInput.trim());
setPageLimit(50);
};
const resetFilters = () => {
setActionFilter("");
setForcedOnly(false);
setFromInput("");
setToInput("");
setAppliedFrom("");
setAppliedTo("");
setPageLimit(50);
setTargetTypeFilter("");
setTargetIdFilter(null);
setActorUserId("");
syncAuditHashTarget({ targetType: "", targetId: null });
syncAuditHashActor(null);
};
const toggleForcedOnly = () => {
setForcedOnly((v) => {
const next = !v;
if (next) setActionFilter("");
setPageLimit(50);
return next;
});
};
// Chip click on a forced-delete row: pre-filter the panel to the chosen
// target_type (and optionally target_id). Resets pagination so the new
// result set starts fresh, and updates the URL hash so the filter
// survives reload / back-forward navigation.
const pivotToTarget = (pivot: AuditTargetPivot) => {
setTargetTypeFilter(pivot.targetType);
setTargetIdFilter(pivot.targetId);
setPageLimit(50);
syncAuditHashTarget(pivot);
};
const clearTargetFilter = () => {
setTargetTypeFilter("");
setTargetIdFilter(null);
setPageLimit(50);
syncAuditHashTarget({ targetType: "", targetId: null });
};
// #197: actor name/avatar click on any audit row pivots the panel to that
// actor's filtered view. Mirrors the dependency-chip → target pivot above
// so admins can hop between "what was acted on" and "who acted" with one
// click. Resets pagination so the new result set starts fresh and updates
// the URL hash so the filter survives reload / back-forward navigation.
const pivotToActor = (newActorUserId: number) => {
setActorUserId(newActorUserId);
setPageLimit(50);
syncAuditHashActor(newActorUserId);
};
const clearActorFilter = () => {
setActorUserId("");
setPageLimit(50);
syncAuditHashActor(null);
};
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => {
if (!filtersValid || exporting) return;
setExportError(null);
setExporting(true);
try {
const url = getExportAuditLogsCsvUrl({
...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
});
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();
const stamp = new Date().toISOString().slice(0, 10);
const filename = `audit-log-${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 (err) {
setExportError(
err instanceof Error ? err.message : t("admin.audit.export.failed"),
);
} finally {
setExporting(false);
}
};
const activeTargetPillLabel = targetTypeFilter
? targetIdFilter != null
? t("admin.audit.filters.targetWithId", {
type: targetTypeFilter,
id: targetIdFilter,
})
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
: null;
// #197: Resolve the currently selected actor so the picker's trigger and
// the filter pill can render their name/username (rather than just an ID).
const selectedActor =
actorUserId !== ""
? sortedActors.find((u) => u.id === actorUserId) ?? null
: null;
const selectedActorLabel = selectedActor
? (lang === "ar"
? selectedActor.displayNameAr ?? selectedActor.displayNameEn
: selectedActor.displayNameEn ?? selectedActor.displayNameAr) ??
selectedActor.username
: null;
const activeActorPillLabel =
actorUserId !== ""
? selectedActorLabel
? t("admin.audit.filters.actorWithName", { name: selectedActorLabel })
: t("admin.audit.filters.actorWithId", { id: actorUserId })
: null;
return (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggleForcedOnly}
aria-pressed={forcedOnly}
title={t("admin.audit.filters.forcedOnlyHint")}
data-testid="audit-forced-only-chip"
className={cn(
"inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-colors",
forcedOnly
? "bg-rose-100 text-rose-800 border-rose-200"
: "bg-white/70 text-foreground border-slate-200 hover:bg-slate-50",
)}
>
<Trash2 size={12} />
{t("admin.audit.filters.forcedOnly")}
</button>
{activeTargetPillLabel && (
<span
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border bg-emerald-100 text-emerald-800 border-emerald-200"
data-testid="audit-target-filter-pill"
>
<span className="truncate">{activeTargetPillLabel}</span>
<button
type="button"
onClick={clearTargetFilter}
aria-label={t("admin.audit.filters.clearTargetFilter")}
title={t("admin.audit.filters.clearTargetFilter")}
className="ms-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-emerald-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400"
data-testid="audit-clear-target-filter"
>
×
</button>
</span>
)}
{activeActorPillLabel && (
<span
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border bg-sky-100 text-sky-800 border-sky-200"
data-testid="audit-actor-filter-pill"
>
<span className="truncate">{activeActorPillLabel}</span>
<button
type="button"
onClick={clearActorFilter}
aria-label={t("admin.audit.filters.clearActorFilter")}
title={t("admin.audit.filters.clearActorFilter")}
className="ms-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-sky-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
data-testid="audit-clear-actor-filter"
>
×
</button>
</span>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-5">
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
<select
value={actionFilter}
onChange={(e) => 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"
>
<option value="">{t("admin.audit.filters.allActions")}</option>
{knownActions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.actor")}</Label>
<AuditActorPicker
actors={sortedActors}
lang={lang}
actorUserId={actorUserId}
onChange={(next) => {
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")}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-from-input"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-to-input"
/>
</div>
<div className="flex items-end gap-2">
<Button
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid="audit-apply-filters"
>
{t("admin.audit.filters.apply")}
</Button>
<Button
size="sm"
variant="outline"
onClick={resetFilters}
data-testid="audit-reset-filters"
>
{t("admin.audit.filters.reset")}
</Button>
</div>
</div>
<div className="flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={handleExportCsv}
disabled={!filtersValid || exporting}
className="gap-1.5"
data-testid="audit-export-csv"
>
{exporting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t("admin.audit.export.button")}
</Button>
</div>
{inputErrors.map((msg, i) => (
<p key={i} className="text-xs text-destructive">
{msg}
</p>
))}
{exportError && (
<p className="text-xs text-destructive" data-testid="audit-export-error">
{t("admin.audit.export.failed")}
</p>
)}
<div className="text-xs text-muted-foreground">
{filtersValid
? t("admin.audit.showing", {
shown: showingCount,
total: totalCount,
})
: t("admin.audit.errors.fixFiltersFirst")}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground">
{t("admin.audit.empty")}
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<AuditLogRow
key={entry.id}
entry={entry}
lang={lang}
onPivotToTarget={pivotToTarget}
onPivotToActor={pivotToActor}
/>
))}
</div>
)}
{hasMore && (
<div className="flex justify-center pt-1">
<Button
size="sm"
variant="outline"
disabled={pageLimit >= 200 || isFetching}
onClick={loadMore}
data-testid="audit-load-more"
>
{isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.audit.loadMore")
)}
</Button>
</div>
)}
</div>
);
}