Add admin Audit Log view (task-84)

Original task: Show admins a history of sensitive actions. Forced group
deletions already write to the new `audit_logs` table; admins now have
a UI to browse and filter those entries.

API
- New OpenAPI endpoint GET /admin/audit-logs (admin-only) with optional
  filters: action (exact), from / to (YYYY-MM-DD, inclusive), limit
  (1-200, default 50), offset (default 0).
- New schemas: AuditLogActor, AuditLogEntry, AuditLogList. List
  response includes paginated `entries`, `totalCount`, `nextOffset`,
  and a distinct `actions` list to power the filter dropdown.
- New `audit` tag added to the spec; ran orval codegen so
  api-client-react / api-zod expose useListAuditLogs et al.
- Implemented `artifacts/api-server/src/routes/audit.ts` and registered
  it in routes/index.ts. Validates date format / order, joins users for
  actor info, and orders newest-first.

UI (artifacts/tx-os/src/pages/admin.tsx)
- New "Audit log" entry under the User Management nav group (icon:
  ScrollText). Section deep-link works via #section=audit-log.
- New AuditLogPanel + AuditLogRow components: action chip + target,
  formatted timestamp, actor avatar/name, expandable JSON metadata.
- Filters bar: action dropdown (populated from API's distinct
  actions), from/to date inputs, Apply / Reset, plus a "Load more"
  control that increases the page size (caps at 200, the API limit).
  Read-only by design.
- Added en/ar locale strings for nav.auditLog and the audit subtree.

Verification
- Typecheck: pnpm -w run typecheck (clean).
- API smoke tested via curl: 401 unauthenticated, validation errors
  for bad / inverted dates, returns the seeded `group.force_delete`
  entry once produced.
- End-to-end browser test (testing skill): logged in as admin,
  navigated to /admin#section=audit-log, verified the row, expanded
  metadata, exercised filters (empty state + reset).

No deviations from the task description.

Replit-Task-Id: 5b5bf9b1-6937-43c3-85c9-81f0c19a5e49
This commit is contained in:
riyadhafraa
2026-04-27 11:28:20 +00:00
parent c05ae786fc
commit 1ca5d5ae4b
10 changed files with 799 additions and 3 deletions
@@ -763,6 +763,45 @@ export interface AdminAppOpensByUser {
opens: AppOpenByUserEntry[];
}
export interface AuditLogActor {
id: number;
username: string;
/** @nullable */
displayNameAr?: string | null;
/** @nullable */
displayNameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
}
/**
* @nullable
*/
export type AuditLogEntryMetadata = { [key: string]: unknown } | null;
export interface AuditLogEntry {
id: number;
action: string;
targetType: string;
/** @nullable */
targetId: number | null;
/** @nullable */
metadata: AuditLogEntryMetadata;
createdAt: string;
actor: AuditLogActor | null;
}
export interface AuditLogList {
entries: AuditLogEntry[];
totalCount: number;
limit: number;
offset: number;
/** @nullable */
nextOffset: number | null;
/** All distinct action names present in the audit log (for building filter UI). */
actions: string[];
}
export type LeaveConversationBody = {
/** When the leaver is the only admin, optionally name a specific
remaining member to promote. Omit (or send null) to keep the
@@ -879,3 +918,27 @@ export const GetAdminAppOpensByUserRange = {
"90d": "90d",
custom: "custom",
} as const;
export type ListAuditLogsParams = {
/**
* Exact action name to filter by (e.g. "group.force_delete").
*/
action?: string;
/**
* Start date (inclusive, YYYY-MM-DD UTC).
*/
from?: string;
/**
* End date (inclusive, YYYY-MM-DD UTC).
*/
to?: string;
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
};
+99
View File
@@ -25,6 +25,7 @@ import type {
AdminStats,
App,
AppSettings,
AuditLogList,
AuthUser,
ConversationWithDetails,
CreateAppBody,
@@ -47,6 +48,7 @@ import type {
HealthStatus,
HomeStats,
LeaveConversationBody,
ListAuditLogsParams,
ListGroupsParams,
ListUsersParams,
LoginBody,
@@ -5862,3 +5864,100 @@ export function useGetAdminAppOpensByUser<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Returns recent entries from the audit log, newest first. Admin only.
Filter by action (exact match) and/or a date range (inclusive).
* @summary List audit log entries (admin)
*/
export const getListAuditLogsUrl = (params?: ListAuditLogsParams) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? "null" : value.toString());
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0
? `/api/admin/audit-logs?${stringifiedParams}`
: `/api/admin/audit-logs`;
};
export const listAuditLogs = async (
params?: ListAuditLogsParams,
options?: RequestInit,
): Promise<AuditLogList> => {
return customFetch<AuditLogList>(getListAuditLogsUrl(params), {
...options,
method: "GET",
});
};
export const getListAuditLogsQueryKey = (params?: ListAuditLogsParams) => {
return [`/api/admin/audit-logs`, ...(params ? [params] : [])] as const;
};
export const getListAuditLogsQueryOptions = <
TData = Awaited<ReturnType<typeof listAuditLogs>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ListAuditLogsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListAuditLogsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listAuditLogs>>> = ({
signal,
}) => listAuditLogs(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAuditLogsQueryResult = NonNullable<
Awaited<ReturnType<typeof listAuditLogs>>
>;
export type ListAuditLogsQueryError = ErrorType<ErrorResponse>;
/**
* @summary List audit log entries (admin)
*/
export function useListAuditLogs<
TData = Awaited<ReturnType<typeof listAuditLogs>>,
TError = ErrorType<ErrorResponse>,
>(
params?: ListAuditLogsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAuditLogs>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAuditLogsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}