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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user