68a338f4cf
Refactors AdminFormDialog to create a reusable component for admin modals, including aria-labelledby/describedby attributes and Escape key closing functionality.
8025 lines
306 KiB
TypeScript
8025 lines
306 KiB
TypeScript
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>
|
||
);
|
||
}
|