From 9c7a8ffe9bf995812ee9c73ec89cb4771ec87c14 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:31:29 +0100 Subject: [PATCH] feat(audit-log): split changes from data access, add resource/action/lookup filters The audit log listed every telemetry read and MCP tool call beside the changes, so the entries an admin opens the page for were buried. The list now opens on Changes (data access hidden unless it was denied), with Data access and All one click away, backed by a new kind param on GET /v2/audit_log. Also: resource type, action and affected user filters, a lookup field that turns a pasted id or action into its filter, chips for filters with no control of their own, a working Custom range, and a /lab/audit-log page over fixtures. --- apps/api/src/routes/v2/audit-log.http.ts | 3 + .../settings/audit-log-filters.test.ts | 57 ++++ .../components/settings/audit-log-filters.ts | 84 ++++- apps/web/src/lab/audit-log-fixture.ts | 251 ++++++++++++++ apps/web/src/lab/audit-log-lab.ts | 181 +++++++++++ apps/web/src/lab/entries/audit-log.ts | 16 + apps/web/src/lab/registry.ts | 8 + .../audit-log/audit-log.scene.test.ts | 129 +++++++- apps/web/src/page/settings/audit-log/model.ts | 33 ++ .../web/src/page/settings/audit-log/update.ts | 227 +++++++++++-- apps/web/src/page/settings/audit-log/view.ts | 305 +++++++++++++++--- apps/web/src/route/search.ts | 7 + .../services/audit/AuditLogService.test.ts | 59 ++++ .../src/services/audit/AuditLogService.ts | 38 ++- packages/domain/src/http/v2/audit-log.ts | 13 + .../query-engine/src/ch/queries/audit-log.ts | 16 +- packages/views/src/ui/compact-search-input.ts | 12 + 17 files changed, 1349 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/lab/audit-log-fixture.ts create mode 100644 apps/web/src/lab/audit-log-lab.ts create mode 100644 apps/web/src/lab/entries/audit-log.ts diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts index 5bfc00cf92..cbaa3bcee4 100644 --- a/apps/api/src/routes/v2/audit-log.http.ts +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -206,6 +206,9 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", ( const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => audit .list(tenant.orgId, { + ...(query.kind !== undefined && query.kind !== "all" + ? { actionKind: query.kind } + : undefined), ...(query.actor_type !== undefined ? { actorType: query.actor_type } : undefined), ...identity, ...(affectedUser !== undefined ? { affectedUserId: affectedUser } : undefined), diff --git a/apps/web/src/components/settings/audit-log-filters.test.ts b/apps/web/src/components/settings/audit-log-filters.test.ts index 9426cf5b19..3cc86da397 100644 --- a/apps/web/src/components/settings/audit-log-filters.test.ts +++ b/apps/web/src/components/settings/audit-log-filters.test.ts @@ -1,9 +1,11 @@ +import { Option } from "effect" import { describe, expect, it } from "vitest" import { auditLogSearchFrom, hasAuditLogFilters, parseAuditLogSearch, + parseAuditLookup, pickAuditLogSearch, resolveAuditDateRange, } from "./audit-log-filters" @@ -111,3 +113,58 @@ describe("resolveAuditDateRange", () => { expect(resolveAuditDateRange({ kind: "all" }, now)).toEqual({ since: undefined, until: undefined }) }) }) + +describe("the resource, action, request and kind filters", () => { + it("keeps known values and round-trips them through the URL", () => { + const search = { + kind: "access", + resource_type: "dashboard", + action: "dashboard.updated", + resource: "dash_YofPTrK9782DWwcnXhpcCw", + affected: "user_2fj3K9dLqWm8xYbT", + request: "8f2c1a9d4b7e3f60", + } + const filters = parseAuditLogSearch(search) + expect(filters).toMatchObject({ + kind: "access", + resourceType: "dashboard", + action: "dashboard.updated", + resourceId: "dash_YofPTrK9782DWwcnXhpcCw", + affectedUserId: "user_2fj3K9dLqWm8xYbT", + requestId: "8f2c1a9d4b7e3f60", + }) + expect(auditLogSearchFrom(filters)).toMatchObject(search) + }) + + it("drops unknown resource types and actions, and leaves the default kind out of the URL", () => { + const filters = parseAuditLogSearch({ + kind: "changes", + resource_type: "spaceship", + action: "dashboard.launched", + request: "not-a-ray", + }) + expect(filters.kind).toBeUndefined() + expect(filters.resourceType).toBeUndefined() + expect(filters.action).toBeUndefined() + expect(filters.requestId).toBeUndefined() + expect(auditLogSearchFrom(filters).kind).toBeUndefined() + }) +}) + +describe("parseAuditLookup", () => { + it("reads each id shape as its filter", () => { + expect(Option.getOrNull(parseAuditLookup("user_2fj3K9dLqWm8xYbT"))).toEqual({ + key: "actorId", + value: "user_2fj3K9dLqWm8xYbT", + }) + expect(Option.getOrNull(parseAuditLookup("alrt_YofPTrK9782DWwcnXhpcCw"))).toEqual({ + key: "resourceId", + value: "alrt_YofPTrK9782DWwcnXhpcCw", + }) + expect(Option.getOrNull(parseAuditLookup("api_key.revoked"))).toEqual({ + key: "action", + value: "api_key.revoked", + }) + expect(Option.isNone(parseAuditLookup("dashboards"))).toBe(true) + }) +}) diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts index 0cebb89c53..a35656a7ff 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 { Array as Arr, Option } from "effect" +import { Array as Arr, Option, String as Str } from "effect" export interface AuditLogSearch { readonly range?: string @@ -8,6 +9,12 @@ export interface AuditLogSearch { readonly actor?: string readonly actor_type?: string readonly outcome?: string + readonly kind?: string + readonly resource_type?: string + readonly action?: string + readonly resource?: string + readonly affected?: string + readonly request?: string } /** Every key present, unset ones `undefined`, so spreading it over the search clears them. */ @@ -18,6 +25,12 @@ export interface AuditLogSearchUpdate { readonly actor: string | undefined readonly actor_type: string | undefined readonly outcome: string | undefined + readonly kind: string | undefined + readonly resource_type: string | undefined + readonly action: string | undefined + readonly resource: string | undefined + readonly affected: string | undefined + readonly request: string | undefined } export const AUDIT_RANGE_PRESETS = [ @@ -32,6 +45,10 @@ export type AuditRangePreset = (typeof AUDIT_RANGE_PRESETS)[number]["value"] const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] const OUTCOMES: ReadonlyArray = ["allowed", "denied"] +/** Which part of the log the list shows. `changes` is the default and stays out of the URL. */ +export type AuditLogKind = "changes" | "access" | "all" +const KINDS: ReadonlyArray = ["changes", "access", "all"] + /** The window: a rolling preset, a fixed `since`/`until` pair, or everything. */ export type AuditDateRange = | { readonly kind: "all" } @@ -43,8 +60,18 @@ export interface AuditLogFilters { readonly actorId?: string | undefined readonly actorType?: AuditActorType | undefined readonly outcome?: AuditOutcome | undefined + readonly kind?: AuditLogKind | undefined + readonly resourceType?: string | undefined + readonly action?: string | undefined + /** A resource's public id (`dash_…`). */ + readonly resourceId?: string | undefined + readonly affectedUserId?: string | undefined + readonly requestId?: string | undefined } +/** The list's kind, `changes` when the URL names none. */ +export const auditLogKindOf = (filters: AuditLogFilters): AuditLogKind => filters.kind ?? "changes" + // The API takes explicit-UTC ISO timestamps only; anything else would be a 400. const isIsoTimestamp = (value: string | undefined): value is string => value !== undefined && /^\d{4}-\d{2}-\d{2}T.+Z$/.test(value) && !Number.isNaN(Date.parse(value)) @@ -53,6 +80,23 @@ const isIsoTimestamp = (value: string | undefined): value is string => const isActorId = (value: string | undefined): value is string => value !== undefined && /^(?:user|key|actor)_[A-Za-z0-9]+$/.test(value) +const isUserId = (value: string | undefined): value is string => + value !== undefined && /^user_[A-Za-z0-9]+$/.test(value) + +// Public resource ids: a lowercase prefix and a base58 body (`dash_…`, `alrt_…`). +const isPublicId = (value: string | undefined): value is string => + value !== undefined && /^[a-z]+_[A-Za-z0-9]{6,}$/.test(value) + +// Request ids are Cloudflare ray ids: hex, sometimes with a `-` suffix. +const isRequestId = (value: string | undefined): value is string => + value !== undefined && /^[0-9a-f]{16}(?:-[A-Z]{3})?$/.test(value) + +const isKnownAction = (value: string | undefined): value is string => + value !== undefined && auditActionLabel(value) !== null + +const isKnownResourceType = (value: string | undefined): value is string => + value !== undefined && auditResourceLabel(value) !== null + /** Narrow the raw search params to filters the API accepts, dropping anything malformed. */ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { const since = isIsoTimestamp(search.since) ? search.since : undefined @@ -72,9 +116,34 @@ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { actorId: isActorId(search.actor) ? search.actor : undefined, actorType: Option.getOrUndefined(Arr.findFirst(ACTOR_TYPES, (type) => type === search.actor_type)), outcome: Option.getOrUndefined(Arr.findFirst(OUTCOMES, (value) => value === search.outcome)), + kind: Option.getOrUndefined( + Arr.findFirst(KINDS, (value) => value !== "changes" && value === search.kind), + ), + resourceType: isKnownResourceType(search.resource_type) ? search.resource_type : undefined, + action: isKnownAction(search.action) ? search.action : undefined, + resourceId: isPublicId(search.resource) ? search.resource : undefined, + affectedUserId: isUserId(search.affected) ? search.affected : undefined, + requestId: isRequestId(search.request) ? search.request : undefined, } } +/** 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 } + | { readonly key: "resourceId"; readonly value: string } + | { readonly key: "requestId"; readonly value: string } + | { readonly key: "action"; readonly value: string } + +/** Read a pasted id or action as the filter it stands for; none for anything else. */ +export function parseAuditLookup(raw: string): Option.Option { + const value = Str.trim(raw) + if (isActorId(value)) return Option.some({ key: "actorId", value }) + if (isRequestId(value)) return Option.some({ key: "requestId", value }) + if (isKnownAction(value)) return Option.some({ key: "action", value }) + if (isPublicId(value)) return Option.some({ key: "resourceId", value }) + return Option.none() +} + /** Write filters back as search params; unset ones are `undefined` so the URL drops them. */ export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpdate { const { range } = filters @@ -85,6 +154,12 @@ export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpda actor: filters.actorId, actor_type: filters.actorType, outcome: filters.outcome, + kind: filters.kind === "changes" ? undefined : filters.kind, + resource_type: filters.resourceType, + action: filters.action, + resource: filters.resourceId, + affected: filters.affectedUserId, + request: filters.requestId, } } @@ -117,6 +192,11 @@ export function hasAuditLogFilters(filters: AuditLogFilters): boolean { filters.range.kind !== "all" || filters.actorId !== undefined || filters.actorType !== undefined || - filters.outcome !== undefined + filters.outcome !== undefined || + filters.resourceType !== undefined || + filters.action !== undefined || + filters.resourceId !== undefined || + filters.affectedUserId !== undefined || + filters.requestId !== undefined ) } diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts new file mode 100644 index 0000000000..688198668c --- /dev/null +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -0,0 +1,251 @@ +import { encodePublicId, type PublicIdPrefix, PublicIdPrefixes, V2AuditLogEntry } from "@maple/domain/http/v2" +import { Array as Arr, Schema, String as Str } from "effect" + +// Audit log entries for `/lab/audit-log`: a morning of changes, reads, tool calls and refusals +// across users, an API key, an agent and Maple itself. + +export const AUDIT_NOW = Date.UTC(2026, 10, 9, 15, 30, 0) + +export const AUDIT_MEMBERS = [ + { value: "user_2fj3K9dLqWm8xYbT", label: "Ada Lovelace" }, + { value: "user_7HqLm2NpRt4VwXyZ", label: "Grace Hopper" }, + { value: "user_9KdTq3RsVw5XyZaB", label: "Linus Torvalds" }, +] as const + +const ADA = AUDIT_MEMBERS[0].value +const GRACE = AUDIT_MEMBERS[1].value +const LINUS = AUDIT_MEMBERS[2].value + +const decode = Schema.decodeUnknownSync(V2AuditLogEntry) + +const uuid = (n: number) => `3f1b7c02-9a44-4d1e-8b2f-${Str.padStart(12, "0")(String(n))}` + +type Wire = Readonly<{ + n: number + minutesAgo: number + action: string + actor: "ada" | "grace" | "linus" | "key" | "agent" | "system" + source: "dashboard" | "api" | "mcp" | "chat_platform" | "system" + resource?: { type: string; prefix?: PublicIdPrefix } + denied?: string + affected?: string + changes?: { + fields: ReadonlyArray + before: Record + after: Record + } + metadata?: Record + ip?: string + country?: string +}> + +const ACTORS = { + ada: { actor_type: "user", actor_id: ADA, actor_name: "Ada Lovelace" }, + grace: { actor_type: "user", actor_id: GRACE, actor_name: "Grace Hopper" }, + linus: { actor_type: "user", actor_id: LINUS, actor_name: null }, + key: { actor_type: "api_key", actor_id: "key_aXwpxqBkqtYwtBtmsGbR41", actor_name: "CI deploy key" }, + agent: { actor_type: "agent", actor_id: "actor_4CzLmR1pTxWvYbNhQd82Kf", actor_name: "Claude Code" }, + system: { actor_type: "system", actor_id: null, actor_name: null }, +} as const + +const entry = (wire: Wire): V2AuditLogEntry => { + const occurred = new Date(AUDIT_NOW - wire.minutesAgo * 60_000).toISOString() + return decode({ + id: encodePublicId(PublicIdPrefixes.auditLogEntry, uuid(wire.n)), + object: "audit_log_entry", + action: wire.action, + outcome: wire.denied === undefined ? "allowed" : "denied", + denial_reason: wire.denied ?? null, + ...ACTORS[wire.actor], + actor_avatar_url: null, + affected_user: wire.affected ?? null, + source: wire.source, + resource_type: wire.resource?.type ?? null, + resource_id: + wire.resource?.prefix === undefined + ? null + : encodePublicId(wire.resource.prefix, uuid(wire.n + 100)), + changes: wire.changes ?? null, + metadata: wire.metadata ?? null, + request_id: wire.source === "system" ? null : `8f2c1a9d4b7e${Str.padStart(4, "0")(String(wire.n))}`, + origin_ip: wire.source === "system" ? null : (wire.ip ?? "203.0.113.7"), + origin_country: wire.source === "system" ? null : (wire.country ?? "DE"), + occurred_at: occurred, + recorded_at: occurred, + }) +} + +export const AUDIT_ENTRIES: ReadonlyArray = Arr.map( + [ + { + n: 1, + minutesAgo: 3, + action: "telemetry.sql_executed", + actor: "agent", + source: "mcp", + denied: "Only SELECT statements may run", + metadata: { + sql: "DELETE FROM traces WHERE ServiceName = 'checkout'", + context: "mcp.run_sql", + start_time: "2026-11-09 14:00:00", + end_time: "2026-11-09 15:00:00", + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 2, + minutesAgo: 6, + action: "mcp_tool.called", + actor: "agent", + source: "mcp", + metadata: { + tool: "search_traces", + surface: "mcp", + is_error: false, + params: '{"service":"checkout","status":"Error","limit":20}', + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 3, + minutesAgo: 9, + action: "dashboard.updated", + actor: "ada", + source: "dashboard", + resource: { type: "dashboard", prefix: PublicIdPrefixes.dashboard }, + changes: { + fields: ["name", "tags"], + before: { name: "Checkout", tags: ["prod"] }, + after: { name: "Checkout overview", tags: ["prod", "payments"] }, + }, + }, + { + n: 4, + minutesAgo: 14, + action: "telemetry.read", + actor: "ada", + source: "dashboard", + metadata: { + endpoint: "queryEngine.execute", + method: "POST", + path: "/api/query-engine/execute", + status: 200, + }, + }, + { + n: 5, + minutesAgo: 31, + action: "api_key.created", + actor: "grace", + source: "dashboard", + resource: { type: "api_key", prefix: PublicIdPrefixes.apiKey }, + metadata: { name: "CI deploy key", scopes: ["telemetry:write", "dashboards:read"] }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 6, + minutesAgo: 47, + action: "api.request", + actor: "key", + source: "api", + denied: "Missing scope: alerts:write", + metadata: { method: "POST", path: "/v2/alert_rules" }, + ip: "203.0.113.90", + country: "NL", + }, + { + n: 7, + minutesAgo: 62, + action: "member.role_changed", + actor: "grace", + source: "dashboard", + affected: LINUS, + changes: { fields: ["role"], before: { role: "org:member" }, after: { role: "org:admin" } }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 8, + minutesAgo: 95, + action: "alert_rule.created", + actor: "linus", + source: "dashboard", + resource: { type: "alert_rule", prefix: PublicIdPrefixes.alertRule }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 9, + minutesAgo: 140, + action: "session_replay.read", + actor: "grace", + source: "dashboard", + metadata: { + endpoint: "sessionReplays.retrieve", + method: "GET", + path: "/v2/session_replays/rep_1", + status: 200, + }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 10, + minutesAgo: 60 * 20, + action: "error_issue.regression", + actor: "system", + source: "system", + resource: { type: "error_issue", prefix: PublicIdPrefixes.errorIssue }, + }, + { + n: 11, + minutesAgo: 60 * 22, + action: "dashboard_share.created", + actor: "linus", + source: "dashboard", + resource: { type: "dashboard_share", prefix: PublicIdPrefixes.dashboardShare }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 12, + minutesAgo: 60 * 26, + action: "source.rolled", + actor: "ada", + source: "dashboard", + resource: { type: "source", prefix: PublicIdPrefixes.source }, + metadata: { platform: "opentelemetry" }, + }, + { + n: 13, + minutesAgo: 60 * 50, + action: "ingest_key.rolled", + actor: "ada", + source: "dashboard", + metadata: { key: "private" }, + }, + { + n: 14, + minutesAgo: 60 * 75, + action: "mcp_tool.called", + actor: "agent", + source: "chat_platform", + metadata: { + tool: "list_errors", + surface: "chat", + is_error: true, + params: '{"service":"payments"}', + connector: "slack", + workspace_id: "T024BE7LD", + external_user_id: "U012AB3CD", + display_name: "Grace Hopper", + }, + ip: "198.51.100.200", + country: "US", + }, + ] satisfies ReadonlyArray, + entry, +) diff --git a/apps/web/src/lab/audit-log-lab.ts b/apps/web/src/lab/audit-log-lab.ts new file mode 100644 index 0000000000..5a70c0c638 --- /dev/null +++ b/apps/web/src/lab/audit-log-lab.ts @@ -0,0 +1,181 @@ +import type { V2AuditLogEntry } from "@maple/domain/http/v2" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Effect, Option, Record as Rec, Result, Schema, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as AuditModel from "../page/settings/audit-log/model" +import * as AuditUpdate from "../page/settings/audit-log/update" +import * as AuditView from "../page/settings/audit-log/view" +import type { Resources } from "../platform/resources" +import { SettingsSearch } from "../route/search" +import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "./audit-log-fixture" +import { keepLocal } from "./local-commands" + +// `/lab/audit-log`: the audit log tab over fixtures. Filters, the kind switch and the lookup all +// work: a navigation re-reads the fixtures through the page's own request. `?state=` picks a start. + +export const LabState = Schema.Literals([ + "changes", + "access", + "all", + "filtered", + "expanded", + "lookup-unknown", + "empty", + "error", + "loading", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "changes", + ), +})) + +type Search = typeof SettingsSearch.Type + +export const Model = Schema.Struct({ state: LabState, search: SettingsSearch, audit: AuditModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotAuditMessage: { message: AuditModel.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type AuditCommand = Command.Command + +const TIME_ZONE = "Europe/Berlin" +const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] +const isAccess = (action: string) => Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The fixtures the API would answer `request` with. */ +const matching = (request: AuditModel.PageRequest): ReadonlyArray => + Arr.filter( + AUDIT_ENTRIES, + (entry) => + (request.kind !== "changes" || !isAccess(entry.action) || entry.outcome === "denied") && + (request.kind !== "access" || isAccess(entry.action)) && + (request.actorType === undefined || entry.actor_type === request.actorType) && + (request.actorId === undefined || entry.actor_id === request.actorId) && + (request.outcome === undefined || entry.outcome === request.outcome) && + (request.resourceType === undefined || entry.resource_type === request.resourceType) && + (request.action === undefined || entry.action === request.action) && + (request.resourceId === undefined || entry.resource_id === request.resourceId) && + (request.affectedUserId === undefined || entry.affected_user === request.affectedUserId) && + (request.requestId === undefined || entry.request_id === request.requestId), + ) + +const searchFor = (state: LabState): Search => { + switch (state) { + case "access": + return { tab: "audit-log", kind: "access" } + case "all": + return { tab: "audit-log", kind: "all" } + case "filtered": + return { tab: "audit-log", kind: "all", actor: AUDIT_MEMBERS[1].value, resource_type: "api_key" } + default: + return { tab: "audit-log" } + } +} + +const decodeSearch = Schema.decodeUnknownOption(SettingsSearch) + +/** The search a page navigation lands on, read off its URL. */ +const searchOfUrl = (url: string): Search => + Option.getOrElse( + decodeSearch(Rec.fromEntries([...new URL(url, "https://lab.local").searchParams.entries()])), + (): Search => ({ tab: "audit-log" }), + ) + +/** Answers a page read the step asked for from the fixtures, as the API would. */ +const answer = (state: LabState, model: AuditModel.Model, commands: ReadonlyArray) => { + if (state === "loading" || !Arr.some(commands, (command) => command.name === "FetchAuditLogPage")) + return model + const result = + state === "error" + ? Result.fail(toLoadFailure("The warehouse did not answer in time")) + : Result.succeed({ + data: state === "empty" ? [] : matching(model.request), + has_more: false, + next_cursor: null, + }) + return AuditUpdate.update( + model, + AuditModel.Message.CompletedFetchPage({ request: model.request, resolvedSince: null, result }), + {}, + ).model +} + +/** The kit's own commands and the custom range's clock read; every API read is answered above. */ +const local = (commands: ReadonlyArray | undefined) => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === "ResolveAuditCustomToday"), +] + +const lift = (commands: ReadonlyArray) => + Command.mapMessages(commands, (message) => Message.GotAuditMessage({ message })) + +export const init = (flags: Flags): Return => { + const search = searchFor(flags.state) + const entered = AuditUpdate.init(search) + const withMembers = AuditUpdate.update( + answer(flags.state, entered.model, entered.commands ?? []), + AuditModel.Message.CompletedFetchMembers({ members: AUDIT_MEMBERS }), + search, + ).model + const extra: ReadonlyArray = + flags.state === "expanded" + ? [AuditModel.Message.ToggledEntry({ id: AUDIT_ENTRIES[2]?.id ?? "" })] + : flags.state === "lookup-unknown" + ? [ + AuditModel.Message.ChangedLookup({ value: "checkout" }), + AuditModel.Message.SubmittedLookup(), + ] + : [] + const audit = Arr.reduce( + extra, + withMembers, + (model, message) => AuditUpdate.update(model, message, search).model, + ) + return { model: { state: flags.state, search, audit } } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotAuditMessage: ({ message: child }) => { + const step = AuditUpdate.update(model.audit, child, model.search) + const out = step.outMessage + if (out?._tag !== "RequestedNavigation") + return { + model: { ...model, audit: answer(model.state, step.model, step.commands ?? []) }, + commands: lift(local(step.commands)), + } + const search = searchOfUrl(out.url) + const moved = AuditUpdate.searchChanged(step.model, search) + return { + model: { ...model, search, audit: answer(model.state, moved.model, moved.commands ?? []) }, + commands: lift([...local(step.commands), ...local(moved.commands)]), + } + }, + }) + +export const breadcrumbs = [{ label: "Lab", href: "/lab" }, { label: "Audit log" }] as const + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("mx-auto w-full max-w-5xl p-6")], + [ + h.submodel({ + slotId: "lab-audit-log", + model: model.audit, + view: AuditView.view, + viewInputs: { search: model.search, nowMs: AUDIT_NOW, timeZone: TIME_ZONE, apiKeys: [] }, + toParentMessage: (message: AuditModel.Message) => Message.GotAuditMessage({ message }), + }), + ], + ) diff --git a/apps/web/src/lab/entries/audit-log.ts b/apps/web/src/lab/entries/audit-log.ts new file mode 100644 index 0000000000..2b2f77344a --- /dev/null +++ b/apps/web/src/lab/entries/audit-log.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../audit-log-lab" + +// `/lab/audit-log`: the settings audit log tab over fixtures (`?state=`). + +export const lab = defineShellLab("Audit log", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/audit-log", + breadcrumbs, +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..6e1a5f6cac 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/audit-log", + title: "Audit log", + description: + "The settings audit log over fixtures: changes, data access, filters, an expanded entry, an unknown lookup, empty, failing and loading (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/slos", title: "SLOs", 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 1c27425258..84512e9897 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 @@ -2,6 +2,7 @@ import { Result, Array as Arr } from "effect" import { expect as expectView, given, role, scene, withViewInputs } from "foldkit/scene" 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" @@ -22,7 +23,11 @@ describe("audit log tab", () => { const moved = searchChanged(entered, { outcome: "denied" }).model const stale = update( moved, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + Message.CompletedFetchPage({ + request: init({}).model.request, + resolvedSince: null, + result: Result.succeed(page), + }), { outcome: "denied" }, ) expect(stale.model.view).toBeNull() @@ -88,9 +93,14 @@ describe("audit log tab", () => { }) test("Load more pins the ceiling to the newest entry and asks for the next cursor", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model expect(update(loaded, Message.ClickedLoadMore(), {}).commands?.[0]).toMatchObject({ @@ -99,9 +109,14 @@ describe("audit log tab", () => { }) test("a revisit reads the first page quietly and holds Load more until it lands", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model const back = revisit(loaded, {}) @@ -113,4 +128,108 @@ describe("audit log tab", () => { test("a revisit while the same first page is in flight does not read it twice", () => { expect(revisit(init({}).model, {}).commands ?? []).toEqual([]) }) + + test("the list opens on changes, and the kind switch writes access or all to the URL", () => { + const entered = init({ tab: "audit-log" }) + expect(entered.commands?.[0]).toMatchObject({ args: { request: { kind: "changes" } } }) + expect(init({ kind: "all" }).model.request.kind).toBeUndefined() + const step = update(entered.model, Message.ClickedKind({ value: "access" }), { tab: "audit-log" }) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&kind=access", + isReplace: true, + }), + ) + const back = update(entered.model, Message.ClickedKind({ value: "changes" }), { + tab: "audit-log", + kind: "access", + }) + expect(back.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ url: "/settings?tab=audit-log", isReplace: true }), + ) + }) + + test("a pasted id becomes the filter it stands for; anything else says what the lookup takes", () => { + const search = { tab: "audit-log" } as const + const typed = update( + init(search).model, + Message.ChangedLookup({ value: " key_aXwpxqBkqtYwtBtmsGbR41 " }), + search, + ) + expect(update(typed.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41", + isReplace: true, + }), + ) + const ray = update( + init(search).model, + Message.ChangedLookup({ value: "8f2c1a9d4b7e3f60-FRA" }), + search, + ) + expect(update(ray.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&request=8f2c1a9d4b7e3f60-FRA", + isReplace: true, + }), + ) + const junk = update(init(search).model, Message.ChangedLookup({ value: "hello world" }), search) + const refused = update(junk.model, Message.SubmittedLookup(), search) + expect(refused.outMessage).toBeUndefined() + scene( + { + update: (model, message) => update(model, message, search), + view: withViewInputs(view, { search, nowMs: 0, timeZone: "UTC", apiKeys: [] })(), + }, + given(refused.model), + expectView(role("status")).toHaveText(/Not something the log filters by/), + ) + }) + + test("removing the resource type drops the action that belongs to it", () => { + const search = { + tab: "audit-log", + resource_type: "dashboard", + action: "dashboard.updated", + outcome: "denied", + } as const + expect( + update(init(search).model, Message.RemovedFilter({ key: "resourceType" }), search).outMessage, + ).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&outcome=denied", + isReplace: true, + }), + ) + }) + + test("an applied custom range lands in the URL as UTC instants", () => { + const search = { tab: "audit-log" } as const + const opened = update( + init(search).model, + Message.ResolvedCustomToday({ + timeZone: "UTC", + today: { year: 2026, month: 11, day: 9 }, + lastDay: { year: 2026, month: 11, day: 9 }, + }), + search, + ).model + const start: ReturnType = { model: opened } + const picked = Arr.reduce( + [ + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 2 } }), + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 4 } }), + CustomRange.Message.ClickedApply(), + ], + start, + (step, message) => update(step.model, Message.GotCustomRangeMessage({ message }), search), + ) + expect(picked.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&since=2026-11-02T00%3A00%3A00.000Z&until=2026-11-04T23%3A59%3A00.000Z", + isReplace: true, + }), + ) + expect(picked.model.customRange).toBeNull() + }) }) diff --git a/apps/web/src/page/settings/audit-log/model.ts b/apps/web/src/page/settings/audit-log/model.ts index 17662b1bfd..83d3a89d37 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -6,6 +6,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as CopyButton from "@maple/views/ui/copy-button" import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" +import * as CustomRange from "../../kit/custom-range" // `AuditLogSection`. Filters live in the `/settings` search; the page passes them in and a change comes back // as a navigation. @@ -16,6 +17,13 @@ export const PageRequest = Schema.Struct({ actorType: Schema.optional(AuditActorType), actorId: Schema.optional(Schema.String), outcome: Schema.optional(AuditOutcome), + /** `changes` or `access`; absent lists both. */ + kind: Schema.optional(Schema.Literals(["changes", "access"])), + resourceType: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resourceId: Schema.optional(Schema.String), + affectedUserId: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), since: Schema.optional(Schema.String), until: Schema.optional(Schema.String), /** A rolling preset: part of the key, resolved to `since` when the page is fetched. */ @@ -60,7 +68,16 @@ export const Model = Schema.Struct({ expanded: Schema.Array(Schema.String), copies: Schema.Record(Schema.String, CopyButton.Model), personSelect: Select.Model, + resourceTypeSelect: Select.Model, + actionSelect: Select.Model, + affectedSelect: Select.Model, dateRange: Popover.Model, + /** The date popover's custom tab, open once the clock gave today. */ + customRange: Schema.NullOr(CustomRange.Model), + /** The lookup field's draft: a pasted id or action, applied on Enter. */ + lookup: Schema.String, + /** The last Enter matched no id shape or action. */ + isLookupUnknown: Schema.Boolean, }) export type Model = typeof Model.Type @@ -83,5 +100,21 @@ export const Message = defineMessageUnion({ GotDateRangeMessage: { message: Popover.Message }, ClickedRangePreset: { value: Schema.String }, ClickedClearFilters: {}, + ClickedKind: { value: Schema.String }, + GotResourceTypeSelectMessage: { message: Select.Message }, + GotActionSelectMessage: { message: Select.Message }, + GotAffectedSelectMessage: { message: Select.Message }, + ChangedLookup: { value: Schema.String }, + SubmittedLookup: {}, + ClearedLookup: {}, + /** A filter chip's x: the key of the filter it removes. */ + RemovedFilter: { key: Schema.String }, + ClickedCustomRange: { timeZone: Schema.String }, + ResolvedCustomToday: { + timeZone: Schema.String, + today: CustomRange.CalendarDate, + lastDay: CustomRange.CalendarDate, + }, + GotCustomRangeMessage: { message: CustomRange.Message }, }) 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 05dd342dfe..9748a3fde0 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -6,10 +6,14 @@ import { AUDIT_RANGE_PRESETS, type AuditDateRange, type AuditLogFilters, + auditLogKindOf, auditLogSearchFrom, parseAuditLogSearch, + parseAuditLookup, } from "../../../components/settings/audit-log-filters" -import { Clock, Effect, Option, Result, Schema, Array as Arr } from "effect" +import { formatWarehouseDateTime, parseWarehouseDateTime } from "@maple/query-engine/datetime" +import * as CustomRange from "../../kit/custom-range" +import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" @@ -28,6 +32,8 @@ type Return = PageReturn type SettingsSearch = typeof SettingsSearchSchema.Type export const ANYONE = "anyone" +/** The "no filter" value of the resource type, action and affected user selects. */ +export const ANY = "any" const asActorType = Schema.decodeUnknownOption(AuditActorType) const asOutcome = Schema.decodeUnknownOption(AuditOutcome) @@ -35,7 +41,14 @@ const asOutcome = Schema.decodeUnknownOption(AuditOutcome) /** The filter input `AuditLogSection` derives from the URL; a rolling preset resolves in the Command. */ export const baseRequestOf = (filters: AuditLogFilters): PageRequest => { const range = filters.range + const kind = auditLogKindOf(filters) return { + ...(kind !== "all" && { kind }), + ...(filters.resourceType !== undefined && { resourceType: filters.resourceType }), + ...(filters.action !== undefined && { action: filters.action }), + ...(filters.resourceId !== undefined && { resourceId: filters.resourceId }), + ...(filters.affectedUserId !== undefined && { affectedUserId: filters.affectedUserId }), + ...(filters.requestId !== undefined && { requestId: filters.requestId }), ...(filters.actorType !== undefined && { actorType: filters.actorType }), ...(filters.actorId !== undefined && { actorId: filters.actorId }), ...(filters.outcome !== undefined && { outcome: filters.outcome }), @@ -53,14 +66,23 @@ const presetMsOf = (range: AuditDateRange): number | null => ) : null -const sameRequest = (a: PageRequest, b: PageRequest) => - a.cursor === b.cursor && - a.actorType === b.actorType && - a.actorId === b.actorId && - a.outcome === b.outcome && - a.since === b.since && - a.until === b.until && - a.preset === b.preset +const REQUEST_KEYS = [ + "cursor", + "kind", + "actorType", + "actorId", + "outcome", + "resourceType", + "action", + "resourceId", + "affectedUserId", + "requestId", + "since", + "until", + "preset", +] as const satisfies ReadonlyArray + +const sameRequest = (a: PageRequest, b: PageRequest) => Arr.every(REQUEST_KEYS, (key) => a[key] === b[key]) /** `auditLogPageAtom`: one page, newest first; a preset window resolves against the clock now. */ const FetchAuditPage = Command.define("FetchAuditLogPage", { @@ -87,6 +109,12 @@ const fetchPage = (request: PageRequest, presetSince: string | null) => query: { limit: AUDIT_LOG_PAGE_LIMIT, ...(request.cursor !== undefined && { cursor: request.cursor }), + kind: request.kind ?? "all", + ...(request.resourceType !== undefined && { resource_type: request.resourceType }), + ...(request.action !== undefined && { action: request.action }), + ...(request.resourceId !== undefined && { resource_id: request.resourceId }), + ...(request.affectedUserId !== undefined && { affected_user: request.affectedUserId }), + ...(request.requestId !== undefined && { request_id: request.requestId }), ...(request.actorType !== undefined && { actor_type: request.actorType }), ...(request.actorId !== undefined && { actor_id: request.actorId }), ...(request.outcome !== undefined && { outcome: request.outcome }), @@ -155,7 +183,13 @@ export const init = (search: SettingsSearch): Return => { expanded: [], copies: {}, personSelect: Select.init("audit-person", { value: ANYONE, selectedIndex: 0 }), + resourceTypeSelect: Select.init("audit-resource-type", { value: ANY, selectedIndex: 0 }), + actionSelect: Select.init("audit-action", { value: ANY, selectedIndex: 0 }), + affectedSelect: Select.init("audit-affected", { value: ANY, selectedIndex: 0 }), dateRange: Popover.init("audit-date-range", { align: "start" }), + customRange: null, + lookup: "", + isLookupUnknown: false, }, baseRequestOf(parseAuditLogSearch(search)), search, @@ -210,6 +244,68 @@ export const revisit = (model: Model, search: SettingsSearch): Return => { } } +/** "Custom range…" opens on the list's own window once the clock gave today. */ +const ResolveAuditCustomToday = Command.define("ResolveAuditCustomToday", { + args: { timeZone: Schema.String }, + messages: [Message.ResolvedCustomToday], + execute: ({ timeZone }) => + Effect.map(CustomRange.readToday(timeZone), (days) => + Message.ResolvedCustomToday({ timeZone, ...days }), + ), +}) + +const isoToWarehouse = (iso: string | undefined): string | null => + iso === undefined ? null : formatWarehouseDateTime(DateTime.toEpochMillis(DateTime.makeUnsafe(iso))) +const warehouseToIso = (value: string): string => new Date(parseWarehouseDateTime(value)).toISOString() + +type SelectField = "personSelect" | "resourceTypeSelect" | "actionSelect" | "affectedSelect" + +/** One select's step; `onChange` turns a picked value into the filters to navigate to. */ +const selectStep = ( + model: Model, + search: SettingsSearch, + field: SelectField, + child: Select.Message, + wrap: (message: Select.Message) => Message, + onChange: (value: string) => AuditLogFilters, +): Return => { + const step = Select.update(model[field], child) + const lifted: Return = { + model: { ...model, [field]: step.model }, + commands: Command.mapMessages(step.commands ?? [], wrap), + } + if (step.outMessage === undefined) return lifted + return { + ...lifted, + outMessage: navigateFilters(lifted.model, search, onChange(step.outMessage.value)).outMessage, + } +} + +const orAny = (value: string) => (value === ANY ? undefined : value) + +/** The filters without one of them: what a chip's x navigates to. */ +const withoutFilter = (filters: AuditLogFilters, key: string): AuditLogFilters => { + switch (key) { + case "range": + return { ...filters, range: { kind: "all" } } + case "resourceType": + // An action belongs to its resource type; it goes with it. + return { ...filters, resourceType: undefined, action: undefined } + case "actorId": + case "actorType": + case "outcome": + case "action": + case "resourceId": + case "affectedUserId": + case "requestId": + return { ...filters, [key]: undefined } + default: + return filters + } +} + +const liftCustom = (model: Model, customRange: CustomRange.Model | null): Model => ({ ...model, customRange }) + export const update = (model: Model, message: Message, search: SettingsSearch): Return => { const filters = parseAuditLogSearch(search) return Message.match(message, { @@ -273,25 +369,109 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), ClickedOutcome: ({ value }) => navigateFilters(model, search, { ...filters, outcome: Option.getOrUndefined(asOutcome(value)) }), - GotPersonSelectMessage: ({ message: child }) => { - const step = Select.update(model.personSelect, child) - const lifted: Return = { - model: { ...model, personSelect: step.model }, - commands: Command.mapMessages(step.commands ?? [], (inner) => - Message.GotPersonSelectMessage({ message: inner }), - ), - } - if (step.outMessage === undefined) return lifted - const actorId = step.outMessage.value === ANYONE ? undefined : step.outMessage.value + GotPersonSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "personSelect", + child, + (inner) => Message.GotPersonSelectMessage({ message: inner }), + (value) => ({ ...filters, actorId: value === ANYONE ? undefined : value }), + ), + GotResourceTypeSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "resourceTypeSelect", + child, + (inner) => Message.GotResourceTypeSelectMessage({ message: inner }), + (value) => ({ ...filters, resourceType: orAny(value), action: undefined }), + ), + GotActionSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "actionSelect", + child, + (inner) => Message.GotActionSelectMessage({ message: inner }), + (value) => ({ ...filters, action: orAny(value) }), + ), + GotAffectedSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "affectedSelect", + child, + (inner) => Message.GotAffectedSelectMessage({ message: inner }), + (value) => ({ ...filters, affectedUserId: orAny(value) }), + ), + ClickedKind: ({ value }) => + navigateFilters(model, search, { + ...filters, + kind: value === "access" || value === "all" ? value : undefined, + }), + ChangedLookup: ({ value }) => ({ model: { ...model, lookup: value, isLookupUnknown: false } }), + ClearedLookup: () => ({ model: { ...model, lookup: "", isLookupUnknown: false } }), + SubmittedLookup: () => + Option.match(parseAuditLookup(model.lookup), { + onNone: () => ({ + model: { ...model, isLookupUnknown: Str.isNonEmpty(Str.trim(model.lookup)) }, + }), + onSome: (lookup) => + navigateFilters({ ...model, lookup: "", isLookupUnknown: false }, search, { + ...filters, + [lookup.key]: lookup.value, + // A looked-up action names its resource type; a stale one would hide it. + ...(lookup.key === "action" && { resourceType: undefined }), + }), + }), + RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), + ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), + ResolvedCustomToday: ({ timeZone, today, lastDay }) => { + const range = filters.range return { - ...lifted, - outMessage: navigateFilters(lifted.model, search, { ...filters, actorId }).outMessage, + model: liftCustom( + model, + CustomRange.init({ + startTime: range.kind === "custom" ? isoToWarehouse(range.since) : null, + endTime: range.kind === "custom" ? isoToWarehouse(range.until) : null, + timeZone, + today, + lastDay, + }), + ), } }, + GotCustomRangeMessage: ({ message: child }) => { + if (model.customRange === null) return { model } + const step = CustomRange.update(model.customRange, child) + const out = step.outMessage + if (out === undefined) return { model: liftCustom(model, step.model) } + return CustomRange.OutMessage.match(out, { + CancelledRange: () => ({ model: liftCustom(model, null) }), + AppliedRange: ({ startTime, endTime }) => { + const closed = Popover.close(model.dateRange) + return { + ...navigateFilters({ ...liftCustom(model, null), dateRange: closed.model }, search, { + ...filters, + range: { + kind: "custom", + since: warehouseToIso(startTime), + until: warehouseToIso(endTime), + }, + }), + commands: Command.mapMessages(closed.commands ?? [], (inner) => + Message.GotDateRangeMessage({ message: inner }), + ), + } + }, + }) + }, GotDateRangeMessage: ({ message: child }) => { const step = Popover.update(model.dateRange, child) return { - model: { ...model, dateRange: step.model }, + // Closing the popover drops a half-picked custom range. + model: { ...model, dateRange: step.model, ...(!step.model.isOpen && { customRange: null }) }, commands: Command.mapMessages(step.commands ?? [], (inner) => Message.GotDateRangeMessage({ message: inner }), ), @@ -313,6 +493,7 @@ export const update = (model: Model, message: Message, search: SettingsSearch): ), } }, - ClickedClearFilters: () => navigateFilters(model, search, { range: { kind: "all" } }), + ClickedClearFilters: () => + navigateFilters(model, search, { range: { kind: "all" }, kind: filters.kind }), }) } diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 95d5e464fc..27d6b39798 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -1,4 +1,4 @@ -import { auditActionLabel } from "@maple/domain/audit-actions" +import { auditActionLabel, AuditResourceLabels, AuditResources } 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" @@ -8,9 +8,15 @@ import { cn } from "@maple/ui/lib/utils" import { AUDIT_RANGE_PRESETS, type AuditDateRange, + type AuditLogFilters, + auditLogKindOf, hasAuditLogFilters, parseAuditLogSearch, } from "../../../components/settings/audit-log-filters" +import { compactSearchInput } from "@maple/views/ui/compact-search-input" +import { removableChip } from "@maple/views/ui/removable-chip" +import type { Message as SelectMessage } from "@maple/views/ui/select" +import * as CustomRangeView from "../../kit/custom-range-view" import { auditResourceDisplay } from "../../../components/settings/audit-log-resource" import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" import { Submodel } from "foldkit" @@ -35,11 +41,11 @@ 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 { ANYONE } from "./update" +import { ANY, ANYONE } from "./update" import { auditDetail, 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 } from "effect" +import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder @@ -58,6 +64,11 @@ const ACTOR_FILTERS = [ { value: "agent", label: "Agents" }, { value: "system", label: "System" }, ] as const +const KIND_OPTIONS = [ + { value: "changes", label: "Changes" }, + { value: "access", label: "Data access" }, + { value: "all", label: "All" }, +] as const const OUTCOME_FILTERS = [ { value: "all", label: "All" }, { value: "allowed", label: "Allowed" }, @@ -122,6 +133,9 @@ const rangeLabel = (range: AuditDateRange, timeZone: string): string => { // TOOLBAR +const RANGE_OPTION_CLASS = + "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken" + const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): Html => { const label = rangeLabel(range, timeZone) const activeKey = range.kind === "all" ? "all" : range.kind === "preset" ? range.preset : null @@ -129,13 +143,18 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): { key: "all", label: "All time" }, ...Arr.map(AUDIT_RANGE_PRESETS, (preset) => ({ key: preset.value, label: preset.label })), ] + const optionClass = (isActive: boolean) => + cn( + RANGE_OPTION_CLASS, + isActive ? typographyClass({ tone: "foreground", weight: "medium" }) : "text-foreground-soft", + ) return h.submodel({ slotId: "audit-date-range", model: model.dateRange, view: Popover.view, toParentMessage: (message) => Message.GotDateRangeMessage({ message }), viewInputs: { - className: "w-48 p-0", + className: model.customRange === null ? "w-48 p-0" : "w-auto p-0", toView: ({ trigger, withTrigger }) => h.div( [h.Class("contents")], @@ -153,45 +172,109 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): ), ), ), - // TODO(settings-audit): "Custom range…" opens `CustomRangePicker`, not ported (shared with the - // time-range picker's custom tab); it is drawn without its picker. toContent: () => [ - vstack(h, { className: "py-1.5" }, [ - ...Arr.map(options, (option) => - pressable( - h, - { - onClick: Message.ClickedRangePreset({ value: option.key }), - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken", - option.key === activeKey - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-foreground-soft", + model.customRange !== null + ? h.submodel({ + slotId: "audit-custom-range", + model: model.customRange, + view: CustomRangeView.view, + viewInputs: {}, + toParentMessage: (message) => Message.GotCustomRangeMessage({ message }), + }) + : vstack(h, { className: "py-1.5" }, [ + ...Arr.map(options, (option) => + pressable( + h, + { + onClick: Message.ClickedRangePreset({ value: option.key }), + className: optionClass(option.key === activeKey), + attributes: [h.Key(option.key)], + }, + [option.label], ), - attributes: [h.Key(option.key)], - }, - [option.label], - ), - ), - separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), - pressable( - h, - { - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - range.kind === "custom" - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-muted-foreground", ), - }, - ["Custom range…"], - ), - ]), + separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), + pressable( + h, + { + onClick: Message.ClickedCustomRange({ timeZone }), + className: optionClass(range.kind === "custom"), + }, + ["Custom range…"], + ), + ]), ], }, }) } +/** A filter select with an "any" first item and the picked label shown in the trigger. */ +const filterSelect = ( + h: H, + options: Readonly<{ + slotId: string + model: Model["resourceTypeSelect"] + value: string | undefined + anyLabel: string + ariaLabel: string + items: ReadonlyArray + toMessage: (message: SelectMessage) => Message + }>, +): Html => { + const label = + options.value === undefined + ? options.anyLabel + : Option.match( + Arr.findFirst(options.items, (item) => item.value === options.value), + { onNone: () => options.value ?? options.anyLabel, onSome: (item) => item.label }, + ) + return h.submodel({ + slotId: options.slotId, + model: { ...options.model, value: options.value ?? ANY }, + view: SelectView.view, + toParentMessage: options.toMessage, + viewInputs: { + entries: [ + { _tag: "Item", item: { value: ANY, label: options.anyLabel } }, + ...Arr.map(options.items, (item): SelectView.SelectEntry => ({ _tag: "Item", item })), + ], + ariaLabel: options.ariaLabel, + size: "sm", + className: "w-auto min-w-36 max-w-64", + renderValue: () => [ + typography( + h, + { truncate: true, tone: options.value === undefined ? "muted" : "foreground" }, + [label], + ), + ], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +const byLabel = Order.mapInput(Order.String, (item: SelectView.SelectOption) => item.label) + +const RESOURCE_TYPE_ITEMS: ReadonlyArray = Arr.sort( + Arr.map(Rec.toEntries(AuditResourceLabels), ([value, label]) => ({ value, label })), + byLabel, +) + +const actionItems = (resourceType: string): ReadonlyArray => + Option.match( + Arr.findFirst(Rec.toEntries(AuditResources), ([key]) => key === resourceType), + { + onNone: () => [], + onSome: ([, resource]) => { + const verbs: ReadonlyArray = resource.verbs + return Arr.map(verbs, (verb) => { + const action = `${resourceType}.${verb}` + return { value: action, label: auditActionLabel(action) ?? action } + }) + }, + }, + ) + const personFilter = ( h: H, model: Model, @@ -437,14 +520,75 @@ const listBody = (h: H, model: Model, inputs: ViewInputs, isFiltered: boolean): ) } +/** Filters that have no control of their own, shown as removable chips (a looked-up id, an action). */ +const filterChips = (h: H, model: Model, filters: AuditLogFilters): ReadonlyArray => { + const memberName = (userId: string) => + Option.match( + Arr.findFirst(model.members, (member) => member.value === userId), + { onNone: () => userId, onSome: (member) => member.label }, + ) + const chips: ReadonlyArray<{ key: string; label: string } | null> = [ + filters.resourceId === undefined + ? null + : { key: "resourceId", label: `Resource ${filters.resourceId}` }, + filters.requestId === undefined ? null : { key: "requestId", label: `Request ${filters.requestId}` }, + // With a resource type picked, the action has its own select. + filters.action === undefined || filters.resourceType !== undefined + ? null + : { key: "action", label: auditActionLabel(filters.action) ?? filters.action }, + filters.affectedUserId === undefined || model.members.length > 0 + ? null + : { key: "affectedUserId", label: `Affected ${memberName(filters.affectedUserId)}` }, + ] + return Arr.flatMap(chips, (chip) => + chip === null + ? [] + : [ + removableChip(h, { + label: chip.label, + onRemove: Message.RemovedFilter({ key: chip.key }), + attributes: [h.Key(chip.key)], + }), + ], + ) +} + +const lookupField = (h: H, model: Model): ReadonlyArray => [ + compactSearchInput(h, { + value: model.lookup, + placeholder: "Paste an ID or action, then Enter", + ariaLabel: "Look up an ID or action", + onInput: (value) => Message.ChangedLookup({ value }), + onEnter: Message.SubmittedLookup(), + onClear: Message.ClearedLookup(), + clearLabel: "Clear lookup", + className: "h-8 w-full @lg:w-72", + }), + ...(model.isLookupUnknown + ? [ + typography(h, { size: "2xs", tone: "muted", attributes: [h.Role("status")] }, [ + "Not something the log filters by. Paste a user, API key, agent, resource or request ID, or an action like dashboard.updated.", + ]), + ] + : []), +] + +const KIND_DESCRIPTIONS = { + changes: "Changes and refused attempts. Data reads are under Data access.", + access: "Reads of telemetry and replays, MCP tool calls, SQL statements and API requests.", + all: "Every entry, changes and data access together.", +} as const + export const description = "Every change, refused attempt and data read from the dashboard, API, MCP and chat. Select an entry for its full record." export const view = Submodel.defineView((model, inputs, h) => { const filters = parseAuditLogSearch(inputs.search) + const kind = auditLogKindOf(filters) const isFailed = model.failure !== null const isWaiting = model.isWaiting && !model.isQuiet const view = model.view + const chips = filterChips(h, model, filters) return settingsSection( h, { @@ -454,10 +598,20 @@ export const view = Submodel.defineView((model, inpu framed: false, }, [ - vstack(h, { gap: "2" }, [ - hstack(h, { gap: "2", wrap: true }, [ - dateRange(h, model, filters.range, inputs.timeZone), - personFilter(h, model, filters.actorId, inputs.apiKeys), + vstack(h, { gap: "3", className: "@container" }, [ + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true, align: "center" }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Show", + value: kind, + onChange: (value) => Message.ClickedKind({ value }), + options: KIND_OPTIONS, + }), + typography(h, { size: "xs", tone: "muted", className: "hidden @2xl:block" }, [ + KIND_DESCRIPTIONS[kind], + ]), + ]), // The labelled Reload every page header uses, beside the range it re-reads. refreshButton(h, { label: "Reload", @@ -465,22 +619,67 @@ export const view = Submodel.defineView((model, inpu onRefresh: Message.ClickedRefresh(), }), ]), - hstack(h, { gap: "3", wrap: true }, [ - segmentedSelect(h, { - size: "sm", - ariaLabel: "Actor type", - value: filters.actorType ?? "all", - onChange: (value) => Message.ClickedActorType({ value }), - options: ACTOR_FILTERS, - }), - segmentedSelect(h, { - size: "sm", - ariaLabel: "Outcome", - value: filters.outcome ?? "all", - onChange: (value) => Message.ClickedOutcome({ value }), - options: OUTCOME_FILTERS, + hstack(h, { gap: "2", wrap: true, align: "center" }, [ + dateRange(h, model, filters.range, inputs.timeZone), + personFilter(h, model, filters.actorId, inputs.apiKeys), + filterSelect(h, { + slotId: "audit-resource-type", + model: model.resourceTypeSelect, + value: filters.resourceType, + anyLabel: "Any resource", + ariaLabel: "Resource type", + items: RESOURCE_TYPE_ITEMS, + toMessage: (message) => Message.GotResourceTypeSelectMessage({ message }), }), + ...(filters.resourceType === undefined + ? [] + : [ + filterSelect(h, { + slotId: "audit-action", + model: model.actionSelect, + value: filters.action, + anyLabel: "Any action", + ariaLabel: "Action", + items: actionItems(filters.resourceType), + toMessage: (message) => Message.GotActionSelectMessage({ message }), + }), + ]), + ...(model.members.length === 0 + ? [] + : [ + filterSelect(h, { + slotId: "audit-affected", + model: model.affectedSelect, + value: filters.affectedUserId, + anyLabel: "Any affected user", + ariaLabel: "Affected user", + items: model.members, + toMessage: (message) => Message.GotAffectedSelectMessage({ message }), + }), + ]), + ]), + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Actor type", + // Five options outgrow a phone; the group scrolls instead of the page. + className: "max-w-full overflow-x-auto", + value: filters.actorType ?? "all", + onChange: (value) => Message.ClickedActorType({ value }), + options: ACTOR_FILTERS, + }), + segmentedSelect(h, { + size: "sm", + ariaLabel: "Outcome", + value: filters.outcome ?? "all", + onChange: (value) => Message.ClickedOutcome({ value }), + options: OUTCOME_FILTERS, + }), + ]), + vstack(h, { gap: "1", className: "w-full @lg:w-auto" }, lookupField(h, model)), ]), + ...(chips.length === 0 ? [] : [hstack(h, { gap: "1.5", wrap: true }, chips)]), ]), panel( h, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 84b89168c0..a192475a6d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -317,6 +317,13 @@ export const SettingsSearch = Schema.Struct({ actor: Schema.optional(Schema.String), actor_type: Schema.optional(Schema.String), outcome: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + resource_type: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resource: Schema.optional(Schema.String), + affected: Schema.optional(Schema.String), + request: Schema.optional(Schema.String), + entry: Schema.optional(Schema.String), }) export const SignInSearch = Schema.Struct({ diff --git a/packages/backend/src/services/audit/AuditLogService.test.ts b/packages/backend/src/services/audit/AuditLogService.test.ts index 576d8727a0..0f9702014c 100644 --- a/packages/backend/src/services/audit/AuditLogService.test.ts +++ b/packages/backend/src/services/audit/AuditLogService.test.ts @@ -481,6 +481,65 @@ describe("AuditLogService.layerMemory", () => { }).pipe(Effect.provide(AuditLogService.layerMemory)), ) + it.effect("the changes kind keeps denied access; the access kind lists only access", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + resourceId: DASHBOARD_ID, + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.read", + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.sql_executed", + outcome: "denied", + denialReason: "write statement", + }) + const actions = (rows: ReadonlyArray<{ readonly action: string }>) => + Arr.map(rows, (row) => row.action) + expect(actions(yield* audit.list(ORG, { actionKind: "changes", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "dashboard.created", + ]) + expect(actions(yield* audit.list(ORG, { actionKind: "access", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "telemetry.read", + ]) + }).pipe(Effect.provide(AuditLogService.layerMemory)), + ) + + it.effect("compiles the changes kind to an exclusion that keeps denied entries", () => + Effect.gen(function* () { + const warehouse = recordingWarehouse() + yield* Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.list(ORG, { actionKind: "changes", limit: 5, offset: 0 }) + yield* audit.list(ORG, { actionKind: "access", limit: 5, offset: 0 }) + }).pipe( + Effect.provide( + Layer.effect(AuditLogService, AuditLogService.make).pipe(Layer.provide(warehouse.layer)), + ), + ) + const [changes, access] = warehouse.sql + expect(changes).toContain("NOT ((endsWith(audit_log.Action, '.read')") + expect(changes).toContain("OR audit_log.Outcome = 'denied')") + expect(access).toContain("endsWith(audit_log.Action, '.sql_executed')") + expect(access).not.toContain("NOT (endsWith(") + }), + ) + describe("recordHttpAudit attribution", () => { const tenant = new CurrentTenant.TenantSchema({ orgId: ORG, diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index 4b9fb69a0c..8afac5f199 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -93,6 +93,11 @@ export interface AuditLogListFilters { readonly untilMs?: number /** Leave out actions ending in any of these, before the limit; see {@link AUDIT_ACCESS_ACTION_SUFFIXES}. */ readonly excludeActionSuffixes?: ReadonlyArray + /** + * `changes`: no data access (see {@link AUDIT_ACCESS_ACTION_SUFFIXES}) unless it was denied. + * `access`: only data access. + */ + readonly actionKind?: "changes" | "access" readonly limit: number readonly offset: number } @@ -175,6 +180,29 @@ const neverFail = ), ) +const isAccessAction = (action: string) => + Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The suffix exclusion a listing applies: the caller's own, or the one `actionKind` implies. */ +const actionSuffixOpts = (filters: AuditLogListFilters) => { + if (filters.actionKind === "changes") + return { + excludeActionSuffixes: [ + ...(filters.excludeActionSuffixes ?? []), + ...AUDIT_ACCESS_ACTION_SUFFIXES, + ], + keepDeniedExcluded: true, + } + return { + ...(filters.excludeActionSuffixes !== undefined + ? { excludeActionSuffixes: filters.excludeActionSuffixes } + : undefined), + ...(filters.actionKind === "access" + ? { onlyActionSuffixes: AUDIT_ACCESS_ACTION_SUFFIXES } + : undefined), + } +} + /** Which optional filters bind, and the parameter values behind them. */ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { const since = filters.sinceMs === undefined ? undefined : DateTime.makeUnsafe(filters.sinceMs) @@ -193,9 +221,7 @@ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { requestId: filters.requestId !== undefined, since: since !== undefined, until: until !== undefined, - ...(filters.excludeActionSuffixes !== undefined - ? { excludeActionSuffixes: filters.excludeActionSuffixes } - : undefined), + ...actionSuffixOpts(filters), limit: filters.limit, offset: filters.offset, } @@ -376,7 +402,11 @@ export function makeMemoryAuditLog(): AuditLogServiceApi { (filters.untilMs === undefined || entry.occurredAt.getTime() <= filters.untilMs) && !Arr.some(filters.excludeActionSuffixes ?? [], (suffix) => Str.endsWith(suffix)(entry.action), - ), + ) && + (filters.actionKind !== "changes" || + !isAccessAction(entry.action) || + entry.outcome === "denied") && + (filters.actionKind !== "access" || isAccessAction(entry.action)), ) .sort((a, b) => { const byTime = b.occurredAt.getTime() - a.occurredAt.getTime() diff --git a/packages/domain/src/http/v2/audit-log.ts b/packages/domain/src/http/v2/audit-log.ts index 41d8d24449..3eec2afbbe 100644 --- a/packages/domain/src/http/v2/audit-log.ts +++ b/packages/domain/src/http/v2/audit-log.ts @@ -145,9 +145,22 @@ export const V2AuditLogEntry = Schema.Struct({ }) export type V2AuditLogEntry = Schema.Schema.Type +/** Which part of the log a listing covers: changes, data access (reads, tool calls, SQL), or both. */ +export const V2AuditLogKind = Schema.Literals(["all", "changes", "access"]).annotate({ + identifier: "AuditLogKind", + title: "Audit log kind", +}) +export type V2AuditLogKind = Schema.Schema.Type + /** Audit-log list query: standard pagination plus actor/action/resource/outcome/time filters. */ export const V2AuditLogQuery = Schema.Struct({ ...ListQuery.fields, + kind: Schema.optional( + V2AuditLogKind.annotate({ + description: + "`changes` leaves out data access (`*.read`, `*.called`, `*.sql_executed`, `*.request`) unless it was denied; `access` returns only data access; `all` (the default) returns both.", + }), + ), actor_type: Schema.optional( AuditActorType.annotate({ description: "Only return entries performed by this kind of actor.", diff --git a/packages/query-engine/src/ch/queries/audit-log.ts b/packages/query-engine/src/ch/queries/audit-log.ts index 69b305ade1..6321fadbeb 100644 --- a/packages/query-engine/src/ch/queries/audit-log.ts +++ b/packages/query-engine/src/ch/queries/audit-log.ts @@ -27,12 +27,19 @@ export interface AuditLogEntriesOpts { * limit. Fixed by the caller's code, never request input: they are inlined. */ readonly excludeActionSuffixes?: ReadonlyArray + /** Keep a denied entry even when its action is excluded: a refused read is still worth seeing. */ + readonly keepDeniedExcluded?: boolean + /** Only actions ending in one of these. Inlined, like `excludeActionSuffixes`. */ + readonly onlyActionSuffixes?: ReadonlyArray readonly limit: number readonly offset: number } const endsWith = defineCondFn<[CH.Expr, string]>("endsWith") +const endsWithAny = (action: CH.Expr, suffixes: ReadonlyArray) => + CH.or(...Arr.map(suffixes, (suffix) => endsWith(action, suffix))) + /** * One org's audit log, newest first, offset-paginated. Pinned to the managed * route: the table is written through `ingest` and does not exist in a BYO @@ -84,9 +91,12 @@ export function auditLogEntriesQuery(opts: AuditLogEntriesOpts) { CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), opts.excludeActionSuffixes !== undefined && opts.excludeActionSuffixes.length > 0 - ? CH.not( - CH.or(...Arr.map(opts.excludeActionSuffixes, (suffix) => endsWith($.Action, suffix))), - ) + ? opts.keepDeniedExcluded === true + ? CH.or(CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)), $.Outcome.eq("denied")) + : CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)) + : undefined, + opts.onlyActionSuffixes !== undefined && opts.onlyActionSuffixes.length > 0 + ? endsWithAny($.Action, opts.onlyActionSuffixes) : undefined, ]) .orderBy(["occurredAt", "desc"], ["id", "desc"]) diff --git a/packages/views/src/ui/compact-search-input.ts b/packages/views/src/ui/compact-search-input.ts index e9ffc05a02..c37021aaad 100644 --- a/packages/views/src/ui/compact-search-input.ts +++ b/packages/views/src/ui/compact-search-input.ts @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "./icon" @@ -11,6 +12,8 @@ export type CompactSearchInputOptions = Readonly<{ ariaLabel: string onInput: (value: string) => M onClear: M + /** Enter in the field: for a field that applies its value rather than filtering as you type. */ + onEnter?: M clearLabel?: string className?: string }> @@ -31,6 +34,15 @@ export const compactSearchInput = (h: HtmlBuilder, options: CompactSearchI h.Type("search"), h.Value(options.value), h.OnInput(options.onInput), + ...(options.onEnter === undefined + ? [] + : [ + h.OnKeyDownPreventDefault((key) => + key === "Enter" && options.onEnter !== undefined + ? Option.some(options.onEnter) + : Option.none(), + ), + ]), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), h.AriaLabel(options.ariaLabel), h.Class(