diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts index a35656a7ff..2347810332 100644 --- a/apps/web/src/components/settings/audit-log-filters.ts +++ b/apps/web/src/components/settings/audit-log-filters.ts @@ -1,5 +1,6 @@ import { auditActionLabel, auditResourceLabel } from "@maple/domain/audit-actions" import type { AuditActorType, AuditOutcome } from "@maple/domain/http" +import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { Array as Arr, Option, String as Str } from "effect" export interface AuditLogSearch { @@ -127,6 +128,11 @@ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { } } +/** The entry a shared link points at (`?entry=alog_…`), as the internal id rows carry; none if malformed. */ +export function linkedAuditEntryId(search: { readonly entry?: string | undefined }): string | null { + return search.entry === undefined ? null : decodePublicId(PublicIdPrefixes.auditLogEntry, search.entry) +} + /** The filter one pasted value stands for: an id of a known shape, or an action name. */ export type AuditLookup = | { readonly key: "actorId"; readonly value: string } diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts index 688198668c..a4514d404e 100644 --- a/apps/web/src/lab/audit-log-fixture.ts +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -162,6 +162,7 @@ export const AUDIT_ENTRIES: ReadonlyArray = Arr.map( action: "member.role_changed", actor: "grace", source: "dashboard", + resource: { type: "member" }, affected: LINUS, changes: { fields: ["role"], before: { role: "org:member" }, after: { role: "org:admin" } }, ip: "192.0.2.44", diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index 5332b2de89..dff9c09e16 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -173,7 +173,7 @@ const templateTile = ( ), typography(h, { size: "sm", weight: "medium" }, [template.title]), ]), - inlineCode(h, { className: "self-start text-2xs text-muted-foreground" }, [template.summary]), + inlineCode(h, { size: "2xs", tone: "muted", className: "self-start" }, [template.summary]), ], ) } diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 84512e9897..0ab288c304 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -1,11 +1,15 @@ -import { Result, Array as Arr } from "effect" -import { expect as expectView, given, role, scene, withViewInputs } from "foldkit/scene" +import { Array as Arr, Option, Record as Rec, Result, Schema } from "effect" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "../../../lab/audit-log-fixture" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../contract" import * as CustomRange from "../../kit/custom-range" import { Message } from "./model" import { init, revisit, searchChanged, update } from "./update" import { view } from "./view" +import { entryHref } from "./view-detail" +import { SettingsSearch } from "../../../route/search" const page = { data: [], has_more: true, next_cursor: "cursor_2" } @@ -232,4 +236,192 @@ describe("audit log tab", () => { ) expect(picked.model.customRange).toBeNull() }) + + describe("an entry's detail", () => { + const search = { tab: "audit-log" } as const + const loadedWith = (searchFor: typeof SettingsSearch.Type) => { + const entered = init(searchFor).model + const withMembers = update( + entered, + Message.CompletedFetchMembers({ members: AUDIT_MEMBERS }), + searchFor, + ).model + return update( + withMembers, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed({ data: AUDIT_ENTRIES, has_more: false, next_cursor: null }), + }), + searchFor, + ) + } + const sceneOf = (searchFor: Parameters[0]) => ({ + update: (model: ReturnType["model"], message: Message) => + update(model, message, searchFor), + view: withViewInputs(view, { + search: searchFor, + nowMs: AUDIT_NOW, + timeZone: "UTC", + apiKeys: [], + })(), + }) + + test("names a member the entry froze no name for, from the org's memberships", () => { + scene( + sceneOf(search), + given(loadedWith(search).model), + expectView(text("Linus Torvalds")).toExist(), + ) + }) + + test("Filter beside the actor lists that actor's entries of any type", () => { + const step = update( + loadedWith({ ...search }).model, + Message.ClickedPivot({ key: "actorId", value: "key_aXwpxqBkqtYwtBtmsGbR41" }), + { tab: "audit-log", actor_type: "user" }, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41", + isReplace: true, + }), + ) + }) + + test("Filter beside the action also picks its resource type, so the action select shows it", () => { + const step = update( + loadedWith(search).model, + Message.ClickedPivot({ key: "action", value: "dashboard.updated" }), + search, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&resource_type=dashboard&action=dashboard.updated", + isReplace: true, + }), + ) + }) + + test("Filter from a shared entry link leaves that link's one-minute window", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = Schema.decodeSync(SettingsSearch)( + Rec.fromEntries([...new URL(entryHref(target), "https://maple.test").searchParams.entries()]), + ) + const step = update( + loadedWith(linked).model, + Message.ClickedPivot({ key: "actorId", value: "key_aXwpxqBkqtYwtBtmsGbR41" }), + linked, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41&kind=all", + isReplace: true, + }), + ) + }) + + test("a shared entry link opens that entry and scrolls to it", () => { + const target = AUDIT_ENTRIES[2] + if (target === undefined) throw new Error("fixture") + const linked = { + tab: "audit-log", + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + } as const + const loaded = loadedWith(linked) + expect(loaded.model.expanded).toEqual([target.id]) + expect(Arr.map(loaded.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) + }) + + test("a shared entry link still opens its entry when it arrives on a later page", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = { + tab: "audit-log", + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + } as const + const entered = init(linked).model + const first = update( + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed({ + data: AUDIT_ENTRIES.slice(0, 3), + has_more: true, + next_cursor: "cursor_2", + }), + }), + linked, + ).model + expect(first.expanded).toEqual([]) + const asked = update(first, Message.ClickedLoadMore(), linked).model + const second = update( + asked, + Message.CompletedFetchPage({ + request: asked.request, + resolvedSince: null, + result: Result.succeed({ + data: AUDIT_ENTRIES.slice(3), + has_more: false, + next_cursor: null, + }), + }), + linked, + ) + expect(second.model.expanded).toEqual([target.id]) + expect(Arr.map(second.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) + }) + + test("a filter change leaves a shared entry link behind", () => { + const linked = { tab: "audit-log", entry: "alog_gQUY7khzfVuTjEMzpTTstW" } as const + const step = update(init(linked).model, Message.ClickedOutcome({ value: "denied" }), linked) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&outcome=denied", + isReplace: true, + }), + ) + }) + + test("an entry's link lists the minute that ends at the entry, and opens it", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = Schema.decodeSync(SettingsSearch)( + Rec.fromEntries([...new URL(entryHref(target), "https://maple.test").searchParams.entries()]), + ) + // The fixture's sixth entry happened 47 minutes before `AUDIT_NOW`. + expect(linked).toEqual({ + tab: "audit-log", + kind: "all", + since: new Date(AUDIT_NOW - 48 * 60_000).toISOString(), + until: new Date(AUDIT_NOW - 47 * 60_000).toISOString(), + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + }) + expect(loadedWith(linked).model.expanded).toEqual([target.id]) + }) + + test("an entry Maple itself made still names its surface", () => { + const target = Arr.findFirst(AUDIT_ENTRIES, (entry) => entry.source === "system") + if (Option.isNone(target)) throw new Error("fixture") + const opened = update( + loadedWith(search).model, + Message.ToggledEntry({ id: target.value.id }), + search, + ).model + scene(sceneOf(search), given(opened), expectView(text("Surface")).toExist()) + }) + + test("the Origin section names the country an entry came from", () => { + const target = AUDIT_ENTRIES[4] + if (target === undefined) throw new Error("fixture") + const opened = update( + loadedWith(search).model, + Message.ToggledEntry({ id: target.id }), + search, + ).model + scene(sceneOf(search), given(opened), expectView(text(/United Kingdom \(GB\)/)).toExist()) + }) + }) }) diff --git a/apps/web/src/page/settings/audit-log/model.ts b/apps/web/src/page/settings/audit-log/model.ts index 83d3a89d37..c9aa5cf8b9 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -116,5 +116,8 @@ export const Message = defineMessageUnion({ lastDay: CustomRange.CalendarDate, }, GotCustomRangeMessage: { message: CustomRange.Message }, + /** "Filter" beside a value in an entry's detail: the filter key and the value to filter by. */ + ClickedPivot: { key: Schema.String, value: Schema.String }, + CompletedScrollToEntry: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 9748a3fde0..ef9d3628c4 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -8,6 +8,7 @@ import { type AuditLogFilters, auditLogKindOf, auditLogSearchFrom, + linkedAuditEntryId, parseAuditLogSearch, parseAuditLookup, } from "../../../components/settings/audit-log-filters" @@ -216,7 +217,10 @@ const dedupeById = (existing: ReadonlyArray, next: ReadonlyArra const navigateFilters = (model: Model, search: SettingsSearch, filters: AuditLogFilters): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ - url: hrefOf(AppRoute.Settings({ search: { ...search, ...auditLogSearchFrom(filters) } })), + // A filter change leaves a shared entry link behind: it named one entry, not a view. + url: hrefOf( + AppRoute.Settings({ search: { ...search, ...auditLogSearchFrom(filters), entry: undefined } }), + ), isReplace: true, }), }) @@ -306,30 +310,75 @@ const withoutFilter = (filters: AuditLogFilters, key: string): AuditLogFilters = const liftCustom = (model: Model, customRange: CustomRange.Model | null): Model => ({ ...model, customRange }) +/** The detail row's DOM id: what a shared link scrolls to. */ +export const entryRowId = (id: string) => `audit-entry-row-${id}` + +/** A shared entry link opens with its row in view. */ +const ScrollToAuditEntry = Command.define("ScrollToAuditEntry", { + args: { id: Schema.String }, + messages: [Message.CompletedScrollToEntry], + execute: ({ id }) => + Effect.sync(() => { + document.getElementById(entryRowId(id))?.scrollIntoView({ block: "center" }) + return Message.CompletedScrollToEntry() + }), +}) + +/** The filters a detail pivot navigates to: the value replaces that filter, the rest stay. */ +const pivoted = (filters: AuditLogFilters, key: string, value: string): AuditLogFilters => { + switch (key) { + case "action": { + // The action select lives under its resource type, so pick that too. + const resourceType = Arr.headNonEmpty(Str.split(".")(value)) + return { ...filters, action: value, resourceType } + } + case "actorId": + // A person's entries are any actor type; a stale type filter would hide them. + return { ...filters, actorId: value, actorType: undefined } + case "resourceId": + case "affectedUserId": + case "requestId": + return { ...filters, [key]: value } + default: + return filters + } +} + export const update = (model: Model, message: Message, search: SettingsSearch): Return => { const filters = parseAuditLogSearch(search) return Message.match(message, { CompletedFetchMembers: ({ members }) => ({ model: { ...model, members } }), CompletedFetchPage: ({ request, resolvedSince, result }) => { if (!sameRequest(request, model.request)) return { model } + const linked = linkedAuditEntryId(search) return Result.match(result, { - onSuccess: (page) => ({ - model: { - ...model, - resolvedSince, - isWaiting: false, - isQuiet: false, - failure: null, - view: { - entries: - request.cursor === undefined - ? page.data - : dedupeById(model.view?.entries ?? [], page.data), - hasMore: page.has_more, - nextCursor: page.next_cursor, + onSuccess: (page) => { + // A shared link opens on its entry, once, on whichever page holds it. + const reveal = + linked !== null && + !model.expanded.includes(linked) && + Arr.some(page.data, (entry) => entry.id === linked) + return { + commands: reveal && linked !== null ? [ScrollToAuditEntry({ id: linked })] : [], + model: { + ...model, + resolvedSince, + isWaiting: false, + isQuiet: false, + failure: null, + view: { + entries: + request.cursor === undefined + ? page.data + : dedupeById(model.view?.entries ?? [], page.data), + hasMore: page.has_more, + nextCursor: page.next_cursor, + }, + expanded: + reveal && linked !== null ? [...model.expanded, linked] : model.expanded, }, - }, - }), + } + }, onFailure: (failure) => ({ model: { ...model, resolvedSince, isWaiting: false, isQuiet: false, failure }, }), @@ -426,6 +475,16 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), }), RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), + ClickedPivot: ({ key, value }) => { + const next = pivoted(filters, key, value) + // An entry link's window is that entry's minute: "every entry with this" must not stay inside it. + return navigateFilters( + model, + search, + search.entry === undefined ? next : { ...next, range: { kind: "all" } }, + ) + }, + CompletedScrollToEntry: () => ({ model }), ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), ResolvedCustomToday: ({ timeZone, today, lastDay }) => { const range = filters.range diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 506af359a4..01b479c786 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -7,7 +7,7 @@ import { } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" import type { AuditResourceLink } from "../../../components/settings/audit-log-resource" -import { Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" +import { DateTime, Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import * as CopyButton from "@maple/views/ui/copy-button" @@ -21,6 +21,8 @@ import { ACTOR_BADGES, SOURCE_LABELS } from "./view" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" +import { textLink } from "@maple/views/ui/text-link" +import { attributeFilterAction } from "@maple/views/ui/attributes/attribute-row" // `AuditLogDetail` and its parts. @@ -84,7 +86,7 @@ const changeValue = (h: H, value: unknown): Html => { const identifier = (h: H, model: Model, value: string, label: string): Html => { const key = `${label}:${value}` return hstack(h, { gap: "0.5", align: "center", inline: true, as: "span", className: "max-w-full" }, [ - inlineCode(h, { variant: "plain", className: "truncate text-2xs text-foreground" }, [value]), + inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground", className: "truncate" }, [value]), CopyButton.view(h, { model: model.copies[key] ?? CopyButton.init(), toParentMessage: (message) => Message.GotCopyMessage({ key, message }), @@ -99,11 +101,7 @@ const identifier = (h: H, model: Model, value: string, label: string): Html => { const changesTable = (h: H, changes: V2AuditChanges): Html => table(h, { size: "sm", className: "font-mono" }, [ tableHeader(h, [ - tableRow(h, [ - tableHead(h, ["Field"], "w-40"), - tableHead(h, ["Before"]), - tableHead(h, ["After"]), - ]), + tableRow(h, [tableHead(h, ["Field"], "w-40"), tableHead(h, ["Before"]), tableHead(h, ["After"])]), ]), tableBody( h, @@ -131,7 +129,7 @@ const metadataList = (h: H, metadata: Readonly>): Readon { layout: "grid" }, Arr.map(keys, (key) => { const block = metadataBlock(metadata[key]) - return keyValue(h, { wrap: true, label: key }, [ + return keyValue(h, { wrap: true, label: metadataLabel(key) }, [ block !== null ? h.pre( [ @@ -150,6 +148,144 @@ const metadataList = (h: H, metadata: Readonly>): Readon ] } +/** A member's name for a `user_…` id, from the org's memberships; null for anyone else. */ +export const memberName = (model: Model, userId: string | null): string | null => + userId === null + ? null + : Option.getOrNull( + Option.map( + Arr.findFirst(model.members, (member) => member.value === userId), + (member) => member.label, + ), + ) + +/** "Filter" beside a value: lists every entry with that value instead. */ +const pivot = (h: H, key: string, value: string, what: string): Html => + attributeFilterAction(h, { + label: `Show every entry with this ${what}`, + text: "Filter", + onClick: Message.ClickedPivot({ key, value }), + }) + +const regionNames = new Intl.DisplayNames(["en"], { type: "region" }) + +/** `DE` -> "🇩🇪 Germany (DE)"; an unknown code reads as itself. */ +const countryText = (code: string): string => { + const upper = Str.toUpperCase(code) + if (!/^[A-Z]{2}$/.test(upper)) return code + const flag = String.fromCodePoint( + ...Arr.map( + Str.split("")(upper), + (char) => 0x1f1e6 + Option.getOrElse(Str.charCodeAt(0)(char), () => 65) - 65, + ), + ) + const name = regionNames.of(upper) + return name === undefined || name === upper ? `${flag} ${upper}` : `${flag} ${name} (${upper})` +} + +/** Labels for the metadata keys the backend writes; anything else is its key, made readable. */ +const METADATA_LABELS = { + endpoint: "Endpoint", + method: "Method", + path: "Path", + status: "HTTP status", + body: "Request body", + tool: "Tool", + surface: "Surface", + is_error: "Failed", + params: "Parameters", + sql: "SQL", + context: "Ran from", + start_time: "Window start", + end_time: "Window end", + row_count: "Rows returned", + error: "Error", + connector: "Chat connector", + workspace_id: "Chat workspace", + external_user_id: "Chat user ID", + display_name: "Chat user", + name: "Name", + scopes: "Scopes", + key: "Key", + platform: "Platform", +} satisfies Readonly> + +const isLabelledKey = (key: string): key is keyof typeof METADATA_LABELS => + Rec.has(METADATA_LABELS, key) + +const metadataLabel = (key: string): string => + isLabelledKey(key) ? METADATA_LABELS[key] : Str.capitalize(Str.replaceAll("_", " ")(key)) + +/** + * The audit log ending at one entry, with that entry open. Newest first, so the entry heads the + * first page however busy the minute was, and the minute before it follows as context. + */ +export const entryHref = (entry: V2AuditLogEntry): string => { + const at = DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)) + return hrefOf( + AppRoute.Settings({ + search: { + tab: "audit-log", + kind: "all", + since: new Date(at - 60_000).toISOString(), + until: entry.occurred_at, + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), + }, + }), + ) +} + +// Every entry has a surface, so the section shows even when Maple itself acted and nothing else is known. +const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArray => { + return [ + h.section( + [h.Class("space-y-1.5")], + [ + eyebrow(h, { variant: "mono", as: "h4" }, ["Origin"]), + keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ + keyValue(h, { wrap: true, label: "Surface" }, [ + h.span([], [SOURCE_LABELS[entry.source]]), + ]), + ...(entry.origin_ip === null + ? [] + : [ + keyValue(h, { wrap: true, label: "IP address" }, [ + identifier(h, model, entry.origin_ip, "IP address"), + ]), + ]), + ...(entry.origin_country === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Country" }, [ + h.span([], [countryText(entry.origin_country)]), + ]), + ]), + ...(entry.request_id === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Request" }, [ + hstack( + h, + { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, + [ + identifier(h, model, entry.request_id, "Request id"), + pivot(h, "requestId", entry.request_id, "request"), + ], + ), + ]), + ]), + ]), + ], + ), + ] +} + +const DETAIL_GRID = + "@2xl:grid-cols-[minmax(6rem,max-content)_minmax(0,1fr)_minmax(6rem,max-content)_minmax(0,1fr)] @2xl:gap-x-6" + +const valueRow = (h: H, children: ReadonlyArray): Html => + hstack(h, { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, children) + export const auditDetail = ( h: H, model: Model, @@ -163,104 +299,92 @@ export const auditDetail = ( const actionText = auditActionLabel(entry.action) const changes = entry.changes const metadata = entry.metadata + const actorName = entry.actor_name ?? memberName(model, entry.actor_id) + const affectedName = memberName(model, entry.affected_user) return h.div( [h.Class("border-border-subtle space-y-4 border-t px-4 py-3 @md:pl-11")], [ - keyValueList( - h, - { - layout: "grid", - className: - "@2xl:grid-cols-[minmax(6rem,max-content)_minmax(0,1fr)_minmax(6rem,max-content)_minmax(0,1fr)] @2xl:gap-x-6", - }, - [ - keyValue(h, { wrap: true, label: "Occurred" }, [full(entry.occurred_at)]), - keyValue(h, { wrap: true, label: "Recorded" }, [full(entry.recorded_at)]), - keyValue(h, { wrap: true, label: "Actor" }, [ - hstack(h, { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, [ - badge(h, { variant: actor.variant, size: "sm" }, [actor.label]), - ...(entry.actor_name === null ? [] : [h.span([], [entry.actor_name])]), - ...(entry.actor_id === null - ? [] - : [identifier(h, model, entry.actor_id, "Actor id")]), - ]), - ]), - keyValue(h, { wrap: true, label: "Source" }, [ - h.span([], [SOURCE_LABELS[entry.source]]), - ...(entry.origin_ip === null && entry.origin_country === null + keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ + keyValue(h, { wrap: true, label: "Occurred" }, [full(entry.occurred_at)]), + keyValue(h, { wrap: true, label: "Recorded" }, [full(entry.recorded_at)]), + keyValue(h, { wrap: true, label: "Actor" }, [ + valueRow(h, [ + badge(h, { variant: actor.variant, size: "sm" }, [actor.label]), + ...(actorName === null ? [] : [h.span([], [actorName])]), + ...(entry.actor_id === null ? [] : [ - typography(h, { tone: "muted" }, [ - " · ", - entry.origin_ip ?? "unknown IP", - ...(entry.origin_country === null - ? [] - : [` (${entry.origin_country})`]), - ]), + identifier(h, model, entry.actor_id, "Actor id"), + pivot(h, "actorId", entry.actor_id, "actor"), ]), ]), - keyValue(h, { wrap: true, label: "Action" }, [ - hstack(h, { gap: "1.5", wrap: true, as: "span" }, [ - ...(actionText === null ? [] : [h.span([], [actionText])]), - inlineCode(h, { variant: "plain", className: "text-2xs text-foreground" }, [ - entry.action, - ]), - entry.outcome === "denied" - ? badge(h, { variant: "crit", size: "sm" }, ["Denied"]) - : badge(h, { variant: "ok", size: "sm" }, ["Allowed"]), - ]), - ...(entry.denial_reason === null - ? [] - : [h.p([h.Class("text-muted-foreground mt-0.5")], [entry.denial_reason])]), + ]), + keyValue(h, { wrap: true, label: "Action" }, [ + valueRow(h, [ + ...(actionText === null ? [] : [h.span([], [actionText])]), + inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground" }, [entry.action]), + entry.outcome === "denied" + ? badge(h, { variant: "crit", size: "sm" }, ["Denied"]) + : badge(h, { variant: "ok", size: "sm" }, ["Allowed"]), + ...(actionText === null ? [] : [pivot(h, "action", entry.action, "action")]), ]), - keyValue(h, { wrap: true, label: "Resource" }, [ - entry.resource_type === null && entry.resource_id === null - ? typography(h, { tone: "subtle" }, [EMPTY_VALUE]) - : hstack( - h, - { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, - [ - ...(entry.resource_type === null - ? [] - : [ - h.span( - [], - [ - auditResourceLabel(entry.resource_type) ?? - entry.resource_type, - ], - ), - ]), - ...(entry.resource_id === null - ? [] - : [identifier(h, model, entry.resource_id, "Resource id")]), - ], - ), - ]), - ...(entry.affected_user === null + ...(entry.denial_reason === null ? [] : [ - keyValue(h, { wrap: true, label: "Affected user" }, [ - identifier(h, model, entry.affected_user, "Affected user id"), + typography(h, { tone: "muted", as: "p", className: "mt-0.5" }, [ + entry.denial_reason, ]), ]), - ...(entry.request_id === null - ? [] - : [ - keyValue(h, { wrap: true, label: "Request" }, [ - identifier(h, model, entry.request_id, "Request id"), + ]), + keyValue(h, { wrap: true, label: "Resource" }, [ + entry.resource_type === null && entry.resource_id === null + ? typography(h, { tone: "subtle" }, [EMPTY_VALUE]) + : valueRow(h, [ + ...(entry.resource_type === null + ? [] + : [ + h.span( + [], + [ + auditResourceLabel(entry.resource_type) ?? + entry.resource_type, + ], + ), + ]), + ...(entry.resource_id === null + ? [] + : [ + identifier(h, model, entry.resource_id, "Resource id"), + pivot(h, "resourceId", entry.resource_id, "resource"), + ]), + ]), + ]), + ...(entry.affected_user === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Affected user" }, [ + valueRow(h, [ + ...(affectedName === null ? [] : [h.span([], [affectedName])]), + identifier(h, model, entry.affected_user, "Affected user id"), + pivot(h, "affectedUserId", entry.affected_user, "affected user"), ]), ]), - keyValue(h, { wrap: true, label: "Entry" }, [ + ]), + keyValue(h, { wrap: true, label: "Entry" }, [ + valueRow(h, [ identifier( h, model, encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), "Entry id", ), + textLink(h, { href: entryHref(entry) }, [ + typography(h, { size: "2xs" }, ["Link to entry"]), + ]), ]), - ], - ), + ]), + ]), + ...originSection(h, model, entry), ...(changes !== null && changes.fields.length > 0 ? [ h.section( diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 27d6b39798..22705fb914 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -1,4 +1,9 @@ -import { auditActionLabel, AuditResourceLabels, AuditResources } from "@maple/domain/audit-actions" +import { + auditActionLabel, + AuditResourceLabels, + AuditResources, + auditResourceLabel, +} from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes, type V2ApiKey, type V2AuditLogEntry } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" @@ -41,8 +46,8 @@ import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { settingsSection } from "../kit/section" import { Message, type Model } from "./model" -import { ANY, ANYONE } from "./update" -import { auditDetail, resourceHref } from "./view-detail" +import { ANY, ANYONE, entryRowId } from "./update" +import { auditDetail, memberName, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" @@ -113,8 +118,11 @@ const abbreviateId = (id: string): string => { ) } -const actorDisplayName = (entry: V2AuditLogEntry): string | null => - entry.actor_name ?? (entry.actor_id === null ? null : abbreviateId(entry.actor_id)) +/** The name frozen on the entry, else the member's current name, else a short id. */ +const actorDisplayName = (model: Model, entry: V2AuditLogEntry): string | null => + entry.actor_name ?? + memberName(model, entry.actor_id) ?? + (entry.actor_id === null ? null : abbreviateId(entry.actor_id)) const rangeLabel = (range: AuditDateRange, timeZone: string): string => { if (range.kind === "all") return "All time" @@ -321,18 +329,15 @@ const personFilter = ( // ROWS -const actorCell = (h: H, entry: V2AuditLogEntry): Html => { +const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { const actor = ACTOR_BADGES[entry.actor_type] - const name = actorDisplayName(entry) + const name = actorDisplayName(model, entry) + const isNamed = entry.actor_name !== null || memberName(model, entry.actor_id) !== null return h.div( [h.Class(cn(COL.actor, hstackClass({ gap: "1.5" })))], [ entry.actor_type === "user" - ? avatarFallback( - h, - { className: "size-4 shrink-0 text-4xs" }, - initialsOf(entry.actor_name ?? entry.actor_id ?? ""), - ) + ? avatarFallback(h, { className: "size-4 shrink-0 text-4xs" }, initialsOf(name ?? "")) : badge(h, { variant: actor.variant, size: "sm", className: "shrink-0" }, [actor.label]), ...(name === null ? [] @@ -341,13 +346,10 @@ const actorCell = (h: H, entry: V2AuditLogEntry): Html => { h, { text: - entry.actor_name !== null && entry.actor_id !== null + isNamed && entry.actor_id !== null ? `${name} (${entry.actor_id})` : (entry.actor_id ?? name), - className: cn( - "text-xs", - entry.actor_name === null && "text-muted-foreground", - ), + className: cn("text-xs", !isNamed && "text-muted-foreground"), }, [name], ), @@ -371,8 +373,8 @@ const actionLabel = (h: H, action: string): ReadonlyArray => { const deniedBadge = (h: H) => badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, ["Denied"]) -const resourceCell = (h: H, entry: V2AuditLogEntry): Html => { - const display = auditResourceDisplay(entry) +const resourceCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { + const display = withMemberName(model, entry, auditResourceDisplay(entry)) if (display === null) return typography(h, { size: "xs", tone: "muted" }, [EMPTY_VALUE]) if (display.link === null) return truncatedText(h, { text: display.text, className: "text-xs" }) return textLink( @@ -388,13 +390,25 @@ const resourceCell = (h: H, entry: V2AuditLogEntry): Html => { ) } +/** A membership change acts on a person: name them rather than showing their id. */ +const withMemberName = ( + model: Model, + entry: V2AuditLogEntry, + display: ReturnType, +): ReturnType => { + const name = memberName(model, entry.affected_user) + return display === null || name === null || entry.resource_type !== "member" + ? display + : { ...display, text: `${auditResourceLabel(entry.resource_type) ?? "Member"} · ${name}` } +} + const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs): Html => { const isExpanded = model.expanded.includes(entry.id) const detailId = `audit-entry-${entry.id}` const isDenied = entry.outcome === "denied" const label = auditActionLabel(entry.action) ?? entry.action return h.div( - [h.Key(entry.id), ...(isExpanded ? [h.Class("bg-surface-faint")] : [])], + [h.Key(entry.id), h.Id(entryRowId(entry.id)), ...(isExpanded ? [h.Class("bg-surface-faint")] : [])], [ hstack( h, @@ -433,7 +447,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs }), ], ), - actorCell(h, entry), + actorCell(h, model, entry), h.div( [h.Class(cn(COL.action, vstackClass({ shrink: true })))], [ @@ -451,7 +465,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs : []), ], ), - h.div([h.Class(COL.resource)], [resourceCell(h, entry)]), + h.div([h.Class(COL.resource)], [resourceCell(h, model, entry)]), h.span( [h.Class(COL.source)], [badge(h, { variant: "outline", size: "sm" }, [SOURCE_LABELS[entry.source]])], @@ -659,7 +673,7 @@ export const view = Submodel.defineView((model, inpu ]), ]), hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ - hstack(h, { gap: "3", wrap: true }, [ + hstack(h, { gap: "3", wrap: true, className: "min-w-0 max-w-full" }, [ segmentedSelect(h, { size: "sm", ariaLabel: "Actor type", diff --git a/packages/views/src/ui/inline-code.ts b/packages/views/src/ui/inline-code.ts index 454f329afb..de7a8c684d 100644 --- a/packages/views/src/ui/inline-code.ts +++ b/packages/views/src/ui/inline-code.ts @@ -9,15 +9,29 @@ import type { Html, HtmlBuilder } from "foldkit/html" export const inlineCodeClassName = (variant: "chip" | "plain", className?: string): string => cn(inlineCodeStyles, variant === "chip" ? inlineCodeChipStyles : inlineCodePlainStyles, className) +const SIZE = { inherit: "", "2xs": "text-2xs" } as const +const TONE = { inherit: "", foreground: "text-foreground", muted: "text-muted-foreground" } as const + /** `InlineCode`: inline monospace for identifiers and commands inside prose. */ export const inlineCode = ( h: HtmlBuilder, - options: { readonly variant?: "chip" | "plain"; readonly className?: string }, + options: { + readonly variant?: "chip" | "plain" + /** `2xs` for ids in dense detail grids; otherwise it follows the surrounding text. */ + readonly size?: keyof typeof SIZE + readonly tone?: keyof typeof TONE + readonly className?: string + }, children: ReadonlyArray, ): Html => h.code( [ - h.Class(inlineCodeClassName(options.variant ?? "chip", options.className)), + h.Class( + inlineCodeClassName( + options.variant ?? "chip", + cn(SIZE[options.size ?? "inherit"], TONE[options.tone ?? "inherit"], options.className), + ), + ), h.DataAttribute("slot", "inline-code"), ], children, diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 1174a19c65..07a9602f2c 100644 --- a/scripts/oxlint-plugins/ui-kit-baseline.json +++ b/scripts/oxlint-plugins/ui-kit-baseline.json @@ -39,7 +39,6 @@ "apps/web/src/page/alerts/editor/view-notify.ts": 4, "apps/web/src/page/alerts/editor/view-sql.ts": 14, "apps/web/src/page/alerts/editor/view-templates.ts": 2, - "apps/web/src/page/alerts/hub/view-empty.ts": 2, "apps/web/src/page/alerts/hub/view-inbox.ts": 2, "apps/web/src/page/alerts/hub/view-maintenance.ts": 1, "apps/web/src/page/alerts/hub/view-oncall.ts": 1, @@ -203,7 +202,6 @@ "apps/web/src/page/settings/account/profile/view.ts": 3, "apps/web/src/page/settings/account/two-factor/view.ts": 1, "apps/web/src/page/settings/ai/view.ts": 2, - "apps/web/src/page/settings/audit-log/view-detail.ts": 4, "apps/web/src/page/settings/audit-log/view.ts": 3, "apps/web/src/page/settings/automation/view.ts": 1, "apps/web/src/page/settings/billing/details/view.ts": 1,