Task #147: Add structured permission-change audit (users, groups, apps)
Mirrors the existing role-permission audit pattern with a unified
`permission_audit` table capturing actor, target, prev/new id sets, and
timestamp written in the same transaction as the change.
Schema & API
- New `permission_audit` table (target_kind, target_id, change_kind,
actor_user_id, previous_ids[], new_ids[], created_at) with index on
(target_kind, target_id, created_at).
- Transactional audit writes in routes/users.ts (POST/DELETE roles,
PATCH groupIds), routes/groups.ts (PATCH + add/remove members for
users/roles/apps), routes/apps.ts (POST/DELETE permissions).
- Cross-entity mirroring: when group membership changes via a group
endpoint, a user.groups row is also written for each affected user
(and vice versa via PATCH /users), so each entity's history is
exhaustive regardless of which editor was used.
- Admin-only GET /users/:id/audit, /groups/:id/audit, /apps/:id/audit
with limit/offset/actorUserId/from/to filters and the same response
shape as role audit.
- OpenAPI types + codegen regenerated.
UI
- Reusable PermissionAuditHistory component in admin.tsx wired into
UserGroupsEditor, GroupDetailEditor (new "history" tab), and the
editing-app dialog. App history correctly resolves permission ids
(NOT roles) via useListPermissions.
- Bilingual i18n keys added under admin.{users,groups,apps}.history*
in en.json + ar.json.
Tests
- New backend tests: user-permission-audit, group-permission-audit,
app-permission-audit (14 cases — transactional capture, GET filters
& pagination, admin-only, 404 on unknown id, plus 2 new mirror
tests covering cross-entity audit visibility). All pass; 35
adjacent role/groups/users/audit-coverage tests still pass.
Notes
- replit.md updated to list `permission_audit` table.
- Restored opengraph.jpg (an unrelated stray binary diff).
- Code-review comments addressed: cross-entity asymmetry fixed via
mirroring; opengraph.jpg restored.
- Follow-ups proposed: timeline UI improvements, cascade audit on
delete/bulk paths, e2e UI test for History sections.
Replit-Task-Id: 4ba8c8de-8dfd-42c7-a3bc-964a1ed3a1a3
This commit is contained in:
@@ -56,6 +56,15 @@ import {
|
||||
usePreviewRolePermissionsImpact,
|
||||
useGetRolePermissionAudit,
|
||||
getGetRolePermissionAuditQueryKey,
|
||||
useGetUserPermissionAudit,
|
||||
getGetUserPermissionAuditQueryKey,
|
||||
getUserPermissionAudit,
|
||||
useGetGroupPermissionAudit,
|
||||
getGetGroupPermissionAuditQueryKey,
|
||||
getGroupPermissionAudit,
|
||||
useGetAppPermissionAudit,
|
||||
getGetAppPermissionAuditQueryKey,
|
||||
getAppPermissionAudit,
|
||||
useListAuditLogs,
|
||||
getListAuditLogsQueryKey,
|
||||
getExportAuditLogsCsvUrl,
|
||||
@@ -82,6 +91,11 @@ import type {
|
||||
RolePermissionsImpact,
|
||||
GetRolePermissionAuditParams,
|
||||
RolePermissionAuditEntry,
|
||||
GetUserPermissionAuditParams,
|
||||
GetGroupPermissionAuditParams,
|
||||
GetAppPermissionAuditParams,
|
||||
PermissionAuditEntry,
|
||||
PermissionAuditEntryChangeKind,
|
||||
Permission,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
@@ -388,6 +402,8 @@ export default function AdminPage() {
|
||||
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");
|
||||
@@ -813,6 +829,20 @@ export default function AdminPage() {
|
||||
{editingApp.id !== undefined && (
|
||||
<AppPermissionsEditor appId={editingApp.id} />
|
||||
)}
|
||||
{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}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
|
||||
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
|
||||
@@ -2836,9 +2866,12 @@ function UserGroupsEditor({
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const updateUser = useUpdateUser();
|
||||
const { data: roles } = useListRoles({
|
||||
query: { queryKey: getListRolesQueryKey() },
|
||||
});
|
||||
|
||||
const original = useMemo(
|
||||
() => ({
|
||||
@@ -3028,6 +3061,25 @@ function UserGroupsEditor({
|
||||
{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}`;
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
@@ -3400,7 +3452,9 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
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">("info");
|
||||
const [tab, setTab] = useState<
|
||||
"info" | "apps" | "users" | "roles" | "history"
|
||||
>("info");
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
|
||||
const visibleUsers = (users ?? []).filter((u) => {
|
||||
@@ -3458,7 +3512,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-slate-200/70">
|
||||
{(["info", "apps", "users", "roles"] as const).map((k) => (
|
||||
{(["info", "apps", "users", "roles", "history"] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
@@ -3583,6 +3637,35 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
</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}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
@@ -4035,6 +4118,458 @@ function RolePermissionHistory({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
Reference in New Issue
Block a user