diff --git a/.changeset/11701-audit-actor-name.md b/.changeset/11701-audit-actor-name.md new file mode 100644 index 0000000000..8633b18bef --- /dev/null +++ b/.changeset/11701-audit-actor-name.md @@ -0,0 +1,11 @@ +--- +'@object-ui/console': patch +--- + +The console's Audit Log page now names who made each change, and its actor filter picks a user instead of taking a typed id (objectui#11701). + +- **Actor column.** The page's `/api/v1/data/sys_audit_log` read now asks for `$expand=user_id`. `user_id` is the log's lookup to `sys_user`, so the server returns each actor's user record in place of the id, in the same request. The column shows the user's name, and hovering over it shows the user id. When the server cannot resolve the user, because the user was deleted or the viewer may not read them, it returns the bare id, and the column shows that id. A change with no user, where `user_id` is empty, reads "System". Hovering over it shows the recorded service principal (`svc:NAME`), if there is one. +- **Detail drawer.** The Actor row shows the user's name with the full user id below it. A change with no user shows "System" and the principal. +- **Actor filter.** The free-text "user id" box is now the `sys_user` lookup from `@object-ui/fields`, the same picker a lookup field to users gets. It lists users by name, and choosing one filters the log by that user's id. Removing the chosen user, or using "Clear filters", removes the filter. + +**Clause-②: no.** No export, prop, type member or i18n key is added or removed. The page's own labels stay English literals. The lookup uses its existing translated strings. diff --git a/apps/console/src/pages/system/AuditLogPage.actorName-11701.test.tsx b/apps/console/src/pages/system/AuditLogPage.actorName-11701.test.tsx new file mode 100644 index 0000000000..096059f312 --- /dev/null +++ b/apps/console/src/pages/system/AuditLogPage.actorName-11701.test.tsx @@ -0,0 +1,215 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The audit log names the actor, and the actor filter is a user lookup + * (objectui#11701). + * + * `sys_audit_log.user_id` is objectstack's lookup to `sys_user`. The page used + * to print it as a truncated raw id and to filter on it with a free-text box, + * so finding "what did this person change" meant knowing their id first. + * + * The transport is stubbed at both seams the page reads through: + * - `fetch` answers `/data/sys_audit_log` the way the engine answers + * `$expand=user_id`. A user it could read is put in place of the id. A user + * it could not read keeps the bare id. `$filter` on `user_id` is honoured, + * so a filtered request lists only that user's rows. + * - the console adapter (`useAdapter`) serves `sys_user` to the filter's + * lookup. + * + * Pinned: + * - the list fetch asks for the expansion; + * - a change by the seeded admin shows "Admin User", with the id on hover; + * - a user the engine could not resolve shows the id, never a blank; + * - a change no user made reads "System", with the principal on hover; + * - the drawer names the actor and keeps the full id; + * - choosing the admin in the filter sends `user_id` equal to the admin's id, + * and the admin's row is listed; + * - removing the chosen user removes the filter. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor, act, fireEvent, within } from '@testing-library/react'; + +const { ADMIN, OTHER, ADAPTER } = vi.hoisted(() => { + // OTHER has no ledger rows: the lookup lists more than the one user it is asked for. + const ADMIN = { id: 'usr_admin_0001', name: 'Admin User', email: 'admin@objectos.ai' }; + const OTHER = { id: 'usr_other_0002', name: 'Olive Other', email: 'olive@example.com' }; + const USERS = [ADMIN, OTHER]; + const ADAPTER = { + find: vi.fn(async (objectName: string, params?: { $filter?: { id?: { $in?: unknown[] } } }) => { + if (objectName !== 'sys_user') return { data: [], total: 0 }; + const wanted = params?.$filter?.id?.$in; + const rows = wanted ? USERS.filter((u) => wanted.includes(u.id)) : USERS; + return { data: rows.map((u) => ({ ...u })), total: rows.length }; + }), + findOne: vi.fn(async (objectName: string, id: string) => { + const row = objectName === 'sys_user' ? USERS.find((u) => u.id === id) : undefined; + return row ? { ...row } : null; + }), + getObjectSchema: vi.fn(async (objectName: string) => + objectName === 'sys_user' + ? { name: 'sys_user', nameField: 'name', fields: { name: { type: 'text', label: 'Name' }, email: { type: 'email', label: 'Email' } } } + : undefined, + ), + }; + return { ADMIN, OTHER, ADAPTER }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); + +import { AuditLogPage } from './AuditLogPage'; + +/** The stored ledger: user ids, as `sys_audit_log` holds them. */ +const LEDGER = [ + { id: 'log_admin', created_at: '2026-10-06T10:00:00.000Z', action: 'update', object_name: 'account', record_id: 'acc_1', user_id: ADMIN.id, actor: ADMIN.id }, + { id: 'log_gone', created_at: '2026-10-06T09:00:00.000Z', action: 'delete', object_name: 'contact', record_id: 'con_1', user_id: 'usr_gone_0009', actor: 'usr_gone_0009' }, + { id: 'log_system', created_at: '2026-10-06T08:00:00.000Z', action: 'create', object_name: 'invoice', record_id: 'inv_1', user_id: null, actor: 'svc:nightly_sync' }, +]; + +/** What the engine can resolve: the gone user's record no longer exists. */ +const READABLE_USERS = new Map([ADMIN, OTHER].map((u) => [u.id, u])); + +/** Every `/data/sys_audit_log` request, as the parsed query string. */ +let requests: URLSearchParams[] = []; + +function stubTransport(): void { + requests = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://console.test'); + requests.push(url.searchParams); + const filter = JSON.parse(url.searchParams.get('$filter') ?? '{}') as { user_id?: string }; + const expand = url.searchParams.get('$expand') === 'user_id'; + const records = LEDGER + .filter((r) => filter.user_id === undefined || r.user_id === filter.user_id) + .map((r) => ({ + ...r, + user_id: expand && r.user_id ? (READABLE_USERS.get(r.user_id) ?? r.user_id) : r.user_id, + })); + return new Response(JSON.stringify({ object: 'sys_audit_log', records, total: records.length }), { status: 200 }); + }), + ); +} + +beforeEach(stubTransport); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function settle(): Promise { + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** The table row for one audit entry, found by its object name cell. */ +async function rowOf(objectName: string): Promise { + const cell = await screen.findByRole('cell', { name: objectName }); + return cell.closest('tr') as HTMLElement; +} + +/** The actor cell of a row: the fifth column (Timestamp, Action, Object, Record, Actor, IP). */ +function actorCell(row: HTMLElement): HTMLElement { + return within(row).getAllByRole('cell')[4]; +} + +describe('AuditLogPage — the actor is named (objectui#11701)', () => { + it('asks for the actor on the list fetch itself', async () => { + render(); + await rowOf('account'); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((q) => q.get('$expand') === 'user_id')).toBe(true); + }); + + it("shows the seeded admin's name in the actor column, with the id on hover", async () => { + render(); + const cell = actorCell(await rowOf('account')); + expect(cell).toHaveTextContent('Admin User'); + expect(cell).not.toHaveTextContent(ADMIN.id); + expect(within(cell).getByText('Admin User')).toHaveAttribute('title', ADMIN.id); + }); + + it('shows the id of a user the engine could not resolve, never a blank', async () => { + render(); + const cell = actorCell(await rowOf('contact')); + expect(cell).toHaveTextContent('usr_gone_0009'); + expect(within(cell).getByText('usr_gone_0009')).toHaveAttribute('title', 'usr_gone_0009'); + }); + + it('reads "System" for a change no user made, with the principal on hover', async () => { + render(); + const cell = actorCell(await rowOf('invoice')); + expect(within(cell).getByText('System')).toHaveAttribute('title', 'svc:nightly_sync'); + }); + + it('names the actor in the drawer and keeps the full id', async () => { + render(); + fireEvent.click(await rowOf('account')); + const drawer = await screen.findByRole('dialog'); + expect(within(drawer).getByText('Admin User')).toBeInTheDocument(); + expect(within(drawer).getByText(ADMIN.id)).toBeInTheDocument(); + }); +}); + +describe('AuditLogPage — the actor filter is a user lookup (objectui#11701)', () => { + /** Open the actor lookup and pick a user by name. */ + async function chooseActor(name: string): Promise { + await act(async () => { + fireEvent.click(screen.getByTestId('lookup-trigger-user_id')); + }); + const option = await screen.findByRole('option', { name: new RegExp(name) }); + // The lookup lists the user directory, not only the user being chosen. + expect(screen.getByRole('option', { name: new RegExp(OTHER.name) })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(option); + }); + await settle(); + } + + it("choosing the admin sends user_id equal to the admin's id, and lists the admin's row", async () => { + render(); + await rowOf('contact'); + await chooseActor('Admin User'); + + await waitFor(() => + expect(JSON.parse(requests[requests.length - 1].get('$filter') ?? '{}')).toEqual({ user_id: ADMIN.id }), + ); + expect(requests[requests.length - 1].get('$expand')).toBe('user_id'); + expect(actorCell(await rowOf('account'))).toHaveTextContent('Admin User'); + await waitFor(() => expect(screen.queryByRole('cell', { name: 'contact' })).toBeNull()); + expect(screen.queryByRole('cell', { name: 'invoice' })).toBeNull(); + // The lookup names who is chosen. + expect(screen.getByRole('button', { name: 'Remove Admin User' })).toBeInTheDocument(); + }); + + it('removing the chosen user removes the filter', async () => { + render(); + await rowOf('contact'); + await chooseActor('Admin User'); + await waitFor(() => expect(screen.queryByRole('cell', { name: 'contact' })).toBeNull()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Remove Admin User' })); + }); + await settle(); + + await waitFor(() => expect(requests[requests.length - 1].get('$filter')).toBeNull()); + expect(await screen.findByRole('cell', { name: 'contact' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove Admin User' })).toBeNull(); + }); +}); diff --git a/apps/console/src/pages/system/AuditLogPage.tsx b/apps/console/src/pages/system/AuditLogPage.tsx index ab8999cc9d..970a60a9c0 100644 --- a/apps/console/src/pages/system/AuditLogPage.tsx +++ b/apps/console/src/pages/system/AuditLogPage.tsx @@ -7,11 +7,14 @@ * objectui#10520). Talks to the framework REST endpoint * `/api/v1/data/sys_audit_log` with standard ObjectQL filter params. * - * Field shape mirrors framework/packages/platform-objects/src/audit/ - * sys-audit-log.object.ts (snake_case columns). + * Field shape mirrors `sys_audit_log` as objectstack declares it + * (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, + * snake_case columns). * * Scope (MVP): - * - Filter by action / object_name / actor (user_id) / date range + * - Filter by action / object_name / actor (a `sys_user` lookup) / date range + * - The actor is named, not shown as a raw id (objectui#11701): see + * `actorOf` below * - Paginated table (50/page) * - Row click opens a side drawer with full event details + JSON * diff of old_value → new_value when present @@ -56,6 +59,8 @@ import { SelectValue, Separator, } from '@object-ui/components'; +import { LookupField } from '@object-ui/fields'; +import { useAdapter } from '@object-ui/app-shell'; import { RefreshCw, Search, X, AlertCircle, ScrollText } from 'lucide-react'; import { ACTION_OPTIONS, ACTION_VARIANT } from './auditLogActions'; @@ -66,7 +71,15 @@ interface AuditRow { id: string; created_at?: string; action?: string; - user_id?: string | null; + /** + * `sys_audit_log.user_id` is a lookup to `sys_user`. The list fetch asks for + * `$expand=user_id`, so a resolved actor arrives as the user's record and an + * unresolved one (the user is gone, or the viewer may not read them) as the + * bare id. `null` means no user made the change. + */ + user_id?: string | Record | null; + /** The principal that acted: a user id, `svc:NAME` for a service, or null for the system. */ + actor?: string | null; object_name?: string | null; record_id?: string | null; ip_address?: string | null; @@ -100,8 +113,77 @@ function tryPrettyJson(s: string | null | undefined): string { try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } } +/** Who acted on one audit row, as the column and the drawer show it. */ +type ActorFace = + | { + kind: 'user'; + /** The user's display name, when the expanded record carries one. */ + name: string | null; + /** The user id, resolved or not. Shown on hover and in the drawer. */ + id: string | null; + } + | { + kind: 'system'; + /** The recorded principal (`svc:NAME`), if any. */ + principal: string | null; + }; + +/** + * The actor of an audit row (objectui#11701). + * + * The fetch expands `user_id`, and the engine replaces the id in place with + * the `sys_user` record. It keeps the bare id when that record cannot be read + * (the user is gone, or the viewer may not see them). Such a row shows the id, + * never a blank. `name` is `sys_user`'s declared name field. A record whose + * `name` the viewer may not read is shown by its id. + * + * A `null` `user_id` is a change no user made. The writer records the system + * as `null` and a service as `svc:NAME` on `actor`. Both read "System", and + * the principal stays on hover and in the drawer. + */ +function actorOf(row: AuditRow): ActorFace { + const ref = row.user_id; + if (ref && typeof ref === 'object') { + const id = typeof ref.id === 'string' && ref.id ? ref.id : null; + const name = typeof ref.name === 'string' && ref.name.trim() ? ref.name.trim() : null; + return { kind: 'user', name, id }; + } + if (typeof ref === 'string' && ref) return { kind: 'user', name: null, id: ref }; + const principal = typeof row.actor === 'string' && row.actor.trim() ? row.actor.trim() : null; + return { kind: 'system', principal }; +} + +/** The actor column's face: the name with the id on hover, else the id, else "System". */ +function ActorCell({ face }: { face: ActorFace }) { + if (face.kind === 'system') { + return System; + } + if (face.name) return {face.name}; + return {truncate(face.id, 18)}; +} + +/** The drawer's actor row: the name, then the full id (or the principal) to copy. */ +function ActorDetail({ face }: { face: ActorFace }) { + if (face.kind === 'system') { + return ( + <> +
System
+ {face.principal &&
{face.principal}
} + + ); + } + return ( + <> + {face.name &&
{face.name}
} +
{face.id || '—'}
+ + ); +} + export function AuditLogPage() { const displayLocale = useDisplayLocale(); + // The actor filter's user lookup queries `sys_user` through the console's adapter. + const adapter = useAdapter(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -109,6 +191,7 @@ export function AuditLogPage() { // Filters const [actionFilter, setActionFilter] = useState('all'); const [objectFilter, setObjectFilter] = useState(''); + /** The chosen user's id, or '' for any actor. */ const [actorFilter, setActorFilter] = useState(''); const [fromDate, setFromDate] = useState(''); // YYYY-MM-DD const [toDate, setToDate] = useState(''); @@ -135,6 +218,9 @@ export function AuditLogPage() { if (Object.keys(filter).length > 0) { params.set('$filter', JSON.stringify(filter)); } + // Name the actor on this one read: the engine batch-loads the referenced + // users and puts each record in place of its id (objectui#11701). + params.set('$expand', 'user_id'); params.set('$orderby', 'created_at desc'); params.set('$top', String(PAGE_SIZE + 1)); // +1 to detect hasMore params.set('$skip', String(page * PAGE_SIZE)); @@ -229,12 +315,20 @@ export function AuditLogPage() { />
- - { setActorFilter(e.target.value); setPage(0); }} - className="h-9" + + {/* The same lookup a `sys_user` reference field gets. It commits the + chosen user's id, and removing the chip commits null. */} + { setActorFilter(v == null ? '' : String(v)); setPage(0); }} + dataSource={adapter} + field={{ + type: 'lookup', + name: 'user_id', + label: 'Actor', + reference: 'sys_user', + placeholder: 'Any user', + }} />
@@ -307,7 +401,7 @@ export function AuditLogPage() { {r.action || '—'} {r.object_name || '—'} {truncate(r.record_id, 18)} - {truncate(r.user_id, 18)} + {r.ip_address || '—'} ); @@ -360,8 +454,8 @@ export function AuditLogPage() {
{selected.ip_address || '—'}
-
Actor (user_id)
-
{selected.user_id || '—'}
+
Actor
+
Tenant