Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/audit-log-filters.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lab/audit-log-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export const AUDIT_ENTRIES: ReadonlyArray<V2AuditLogEntry> = 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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/page/alerts/hub/view-empty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ const templateTile = <M>(
),
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]),
],
)
}
Expand Down
196 changes: 194 additions & 2 deletions apps/web/src/page/settings/audit-log/audit-log.scene.test.ts
Original file line number Diff line number Diff line change
@@ -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" }

Expand Down Expand Up @@ -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<typeof loadedWith>[0]) => ({
update: (model: ReturnType<typeof init>["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())
})
})
})
3 changes: 3 additions & 0 deletions apps/web/src/page/settings/audit-log/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
93 changes: 76 additions & 17 deletions apps/web/src/page/settings/audit-log/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type AuditLogFilters,
auditLogKindOf,
auditLogSearchFrom,
linkedAuditEntryId,
parseAuditLogSearch,
parseAuditLookup,
} from "../../../components/settings/audit-log-filters"
Expand Down Expand Up @@ -216,7 +217,10 @@ const dedupeById = (existing: ReadonlyArray<V2AuditLogEntry>, 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,
}),
})
Expand Down Expand Up @@ -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<Return>(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 =
Comment thread
Makisuo marked this conversation as resolved.
Resolved
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 },
}),
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading