mirror of https://github.com/requarks/wiki
parent
7cf47610d1
commit
d39f2eff0f
@ -0,0 +1,350 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { validate as uuidValidate } from 'uuid'
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify'
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { AUDIT_ACTIONS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../models/auditLog.ts'
|
||||
|
||||
/** Most rows one request may ask for, so that a filter matching everything cannot be a denial of service. */
|
||||
const MAX_PAGE_SIZE = 100
|
||||
|
||||
/** Most accounts one request may name, so the `userId` list cannot be an unbounded `IN`. */
|
||||
const MAX_USER_FILTER = 50
|
||||
|
||||
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
|
||||
function splitList(value?: string): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/** The filter half of the querystring, shared by the list and the export. */
|
||||
interface AuditQuery {
|
||||
userId?: string
|
||||
kind?: string
|
||||
action?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters as the model takes them, or the refusal to send back.
|
||||
*
|
||||
* The user IDs are validated rather than passed through: they go into an `IN` against a uuid column,
|
||||
* so one malformed value makes postgres raise and the request fail as a 500 rather than as the bad
|
||||
* request it is.
|
||||
*/
|
||||
function filtersFrom(
|
||||
query: AuditQuery,
|
||||
reply: FastifyReply
|
||||
): { userIds: string[]; kind?: string; action?: string; from?: string; to?: string } | null {
|
||||
const userIds = splitList(query.userId)
|
||||
if (userIds.length > MAX_USER_FILTER) {
|
||||
reply.badRequest(`At most ${MAX_USER_FILTER} users can be filtered for at once.`)
|
||||
return null
|
||||
}
|
||||
if (userIds.some((id) => !uuidValidate(id))) {
|
||||
reply.badRequest('The userId filter must be one user ID, or several separated by commas.')
|
||||
return null
|
||||
}
|
||||
return { userIds, kind: query.kind, action: query.action, from: query.from, to: query.to }
|
||||
}
|
||||
|
||||
/** The filter querystring both routes declare. */
|
||||
const filterProperties = {
|
||||
userId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Only entries these accounts made — one ID, or several comma-separated, OR-ed against each other. At most 50.\n\nAn account that has since been deleted can no longer be filtered for: its entries have no `userId` left. The name and email it had are still on `meta.actor`.'
|
||||
},
|
||||
kind: { $ref: 'AuditKind#' },
|
||||
action: { $ref: 'AuditAction#' },
|
||||
from: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Inclusive lower bound on the timestamp, RFC 3339.'
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Inclusive upper bound on the timestamp, RFC 3339.'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Audit Log API Routes
|
||||
*
|
||||
* Read-only from the outside: nothing here writes an entry. Entries are written by `helpers/audit.ts`
|
||||
* as a side effect of the routes that do the work, which is what keeps the log a record of what
|
||||
* happened rather than a table anybody can post to.
|
||||
*/
|
||||
async function routes(app: FastifyInstance) {
|
||||
/**
|
||||
* LIST AUDIT ENTRIES
|
||||
*/
|
||||
app.get<{
|
||||
Querystring: {
|
||||
userId?: string
|
||||
kind?: string
|
||||
action?: string
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
}>(
|
||||
'/',
|
||||
{
|
||||
config: {
|
||||
permissions: ['read:audit']
|
||||
},
|
||||
schema: {
|
||||
summary: 'List audit log entries',
|
||||
description:
|
||||
'Newest first, one page at a time. Every filter is optional and they are AND-ed together.\n\nWhat is recorded: every action that CHANGES something, plus successful logins. Reads are not — page views would outnumber everything else and bury the log — and neither are failed login attempts, which would otherwise let anybody outside fill this table on demand. Actions taken by the scheduler are absent by construction: an entry is only ever written from a request.',
|
||||
tags: ['Audit Log'],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...filterProperties,
|
||||
page: { type: 'integer', minimum: 1, default: 1 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 25 }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'One page of audit entries',
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: 'How many entries match the filters, across every page.'
|
||||
},
|
||||
entries: {
|
||||
type: 'array',
|
||||
items: { $ref: 'AuditEntry#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
reply.preventCache()
|
||||
const filters = filtersFrom(req.query, reply)
|
||||
if (!filters) {
|
||||
return reply
|
||||
}
|
||||
return WIKI.models.auditLog.list(filters, req.query.page ?? 1, req.query.limit ?? 25)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* EXPORT AUDIT ENTRIES
|
||||
*/
|
||||
app.get<{ Querystring: AuditQuery }>(
|
||||
'/export',
|
||||
{
|
||||
config: {
|
||||
permissions: ['read:audit']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Download the audit log as JSONL',
|
||||
description:
|
||||
'Every entry matching the filters, newest first — not just the page on screen — as newline-delimited JSON: one entry per line, the same shape the list returns. A format that streams, so a year of history is a download rather than a request that has to be held in memory at both ends, and that `jq` and every log pipeline read without unwrapping an envelope first.\n\nThe export itself is recorded in the log, which is the one read that is: taking a copy of who did what is worth knowing about.',
|
||||
tags: ['Audit Log'],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: filterProperties
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The matching entries, one JSON object per line',
|
||||
content: {
|
||||
'application/x-ndjson': {
|
||||
schema: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const filters = filtersFrom(req.query, reply)
|
||||
if (!filters) {
|
||||
return reply
|
||||
}
|
||||
|
||||
/*
|
||||
Recorded BEFORE the stream, deliberately. Everywhere else an entry is written once the work
|
||||
succeeded, but a download has no moment of success the server sees — the connection can drop
|
||||
halfway and the rows already sent are still gone. What is worth recording is that the export
|
||||
was asked for and authorized.
|
||||
*/
|
||||
await audit(req, 'admin', 'exportAuditLog', {
|
||||
filters: {
|
||||
userIds: filters.userIds,
|
||||
kind: filters.kind ?? null,
|
||||
action: filters.action ?? null,
|
||||
from: filters.from ?? null,
|
||||
to: filters.to ?? null
|
||||
}
|
||||
})
|
||||
|
||||
const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replace(/[:]/g, '-')
|
||||
reply.header('Content-Type', 'application/x-ndjson; charset=utf-8')
|
||||
reply.header('Content-Disposition', `attachment; filename="audit-log-${stamp}.jsonl"`)
|
||||
reply.preventCache()
|
||||
|
||||
// -> The entries this request just wrote are in the export too, which is correct: it is the log
|
||||
// as it stands at the moment it was asked for
|
||||
const entries = WIKI.models.auditLog.stream(filters)
|
||||
return reply.send(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
for await (const entry of entries) {
|
||||
yield `${JSON.stringify(entry)}\n`
|
||||
}
|
||||
})()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST AUDIT ACTIONS
|
||||
*/
|
||||
app.get(
|
||||
'/actions',
|
||||
{
|
||||
config: {
|
||||
permissions: ['read:audit']
|
||||
},
|
||||
schema: {
|
||||
summary: 'List every action the wiki records, by area',
|
||||
description:
|
||||
'What the filter offers, and the full set of translation keys: each action is shown as `admin.audit.actions.<action>`. Served from the code rather than from the table, so an action nothing has done yet is still offered.',
|
||||
tags: ['Audit Log'],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Action keys grouped by area',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { $ref: 'AuditKind#' },
|
||||
actions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
return AUDIT_KINDS.map((kind) => ({ kind, actions: [...AUDIT_ACTIONS[kind]] }))
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* GET AUDIT CONFIG
|
||||
*/
|
||||
app.get(
|
||||
'/config',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:system']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Get the audit log retention settings',
|
||||
description:
|
||||
'Reading the log needs `read:audit`; changing how long it is kept is a system setting and needs `manage:system` — shortening the retention destroys evidence, which is not the same authority as looking at it.',
|
||||
tags: ['Audit Log'],
|
||||
response: {
|
||||
200: { $ref: 'AuditConfig#' }
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
reply.preventCache()
|
||||
const [total, oldestEntry] = await Promise.all([
|
||||
WIKI.models.auditLog.total(),
|
||||
WIKI.models.auditLog.oldestEntry()
|
||||
])
|
||||
return {
|
||||
retentionDays: WIKI.models.auditLog.retentionDays(),
|
||||
total,
|
||||
oldestEntry
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* UPDATE AUDIT CONFIG
|
||||
*/
|
||||
app.put<{ Body: { retentionDays: number } }>(
|
||||
'/config',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:system']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Set how long audit log entries are kept',
|
||||
description: `Takes effect the next time the daily \`purgeAuditLog\` task runs; nothing is deleted by saving. The change is itself recorded in the log.
|
||||
|
||||
Either zero — keep everything for ever — or at least ${MIN_RETENTION_DAYS} days. Nothing between the two is accepted: retention is the one setting whose effect is to destroy this table, and the person who can change it is the person it exists to record, so a value short enough to outrun discovery is refused rather than offered.`,
|
||||
tags: ['Audit Log'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
retentionDays: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: 36500,
|
||||
description: `Days to keep. Zero keeps everything for ever; anything else is at least ${MIN_RETENTION_DAYS}.`
|
||||
}
|
||||
},
|
||||
required: ['retentionDays']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Retention updated',
|
||||
type: 'object',
|
||||
properties: {
|
||||
retentionDays: { type: 'integer' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
/*
|
||||
Checked here rather than in the JSON Schema: "zero or at least thirty" is expressible as an
|
||||
`anyOf`, but what comes back from one is a validation error naming neither branch, and this
|
||||
is a refusal whose reason is the whole point of it.
|
||||
*/
|
||||
if (req.body.retentionDays > 0 && req.body.retentionDays < MIN_RETENTION_DAYS) {
|
||||
return reply.badRequest(
|
||||
`Audit log retention must be at least ${MIN_RETENTION_DAYS} days, or zero to keep entries for ever.`
|
||||
)
|
||||
}
|
||||
|
||||
const previous = WIKI.models.auditLog.retentionDays()
|
||||
await WIKI.models.settings.updateConfig('audit', { retentionDays: req.body.retentionDays })
|
||||
WIKI.config.audit = { ...WIKI.config.audit, retentionDays: req.body.retentionDays }
|
||||
|
||||
await audit(req, 'admin', 'updateAuditConfig', {
|
||||
retentionDays: req.body.retentionDays,
|
||||
previousRetentionDays: previous
|
||||
})
|
||||
|
||||
return { retentionDays: req.body.retentionDays }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { AUDIT_ACTION_KEYS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../../models/auditLog.ts'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* AUDIT ENTRY - One recorded action
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AuditEntry',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
ts: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'RFC 3339 Date Time'
|
||||
},
|
||||
kind: {
|
||||
$ref: 'AuditKind#'
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
description:
|
||||
'What was done, camelCase. A translation key rather than a sentence — the interface looks it up as `admin.audit.actions.<action>`.'
|
||||
},
|
||||
clientIP: {
|
||||
type: 'string',
|
||||
description: 'Address the request came from.'
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description:
|
||||
'The account that acted, while it exists. Null once it is deleted, and null for an action nobody was signed in for — the name and email on `meta.actor` are what the entry is read by then.'
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'What the action touched, plus an `actor` block holding the email, display name and address the requester had at the time. Never carries a secret, and never the content of a change — a page edit records the `pageHistory` version its change produced instead.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AUDIT KIND - The areas an action can belong to
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AuditKind',
|
||||
type: 'string',
|
||||
enum: [...AUDIT_KINDS]
|
||||
})
|
||||
|
||||
/**
|
||||
* AUDIT ACTION - Every action key the wiki records
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AuditAction',
|
||||
type: 'string',
|
||||
enum: AUDIT_ACTION_KEYS
|
||||
})
|
||||
|
||||
/**
|
||||
* AUDIT CONFIG - How long entries are kept
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'AuditConfig',
|
||||
type: 'object',
|
||||
properties: {
|
||||
retentionDays: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: `How many days of log to keep. Entries older than this are deleted daily by the \`purgeAuditLog\` task. Zero keeps everything for ever, and anything else is at least ${MIN_RETENTION_DAYS} — see the PUT for why there is a floor.`
|
||||
},
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: 'How many entries the log currently holds.'
|
||||
},
|
||||
oldestEntry: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'When the oldest entry was recorded, or null when the log is empty.'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
CREATE TABLE "auditLog" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"ts" timestamp DEFAULT now() NOT NULL,
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"action" varchar(64) NOT NULL,
|
||||
"clientIP" varchar(45) DEFAULT '' NOT NULL,
|
||||
"meta" jsonb DEFAULT '{}' NOT NULL,
|
||||
"userId" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "auditLog_ts_idx" ON "auditLog" ("ts" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "auditLog_userId_idx" ON "auditLog" ("userId","ts" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "auditLog_kind_idx" ON "auditLog" ("kind","ts" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "auditLog_action_idx" ON "auditLog" ("action","ts" DESC NULLS LAST);--> statement-breakpoint
|
||||
ALTER TABLE "auditLog" ADD CONSTRAINT "auditLog_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,135 @@
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
import type { AuditAction, AuditActor, AuditKind } from '../models/auditLog.ts'
|
||||
|
||||
/**
|
||||
* Key names whose value never belongs in the audit log.
|
||||
*
|
||||
* This is a backstop, not the rule. The rule is that a route hands `audit()` the identity of what it
|
||||
* touched — an id, a path, a name — and not the payload it touched it with, which is why nearly every
|
||||
* call site passes a handful of scalars. What this catches is the case where somebody later widens
|
||||
* one of those calls to `req.body` and does not notice that the body carries a module's sensitive
|
||||
* prop.
|
||||
*
|
||||
* Two lists rather than one, because the useful patterns come in two lengths. A long name can be
|
||||
* matched anywhere in the key — `token` has to catch `continuationToken` — while a short one has to
|
||||
* match the whole key or it starts eating innocent fields: `pass` as a substring redacts `passkeyId`,
|
||||
* and `key` as a substring redacts every foreign key in here.
|
||||
*/
|
||||
const SENSITIVE_SUBSTRINGS = [
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'token',
|
||||
'credential',
|
||||
'privatekey',
|
||||
'apikey',
|
||||
'accesskey',
|
||||
'authorization',
|
||||
'authheader',
|
||||
'securitycode'
|
||||
]
|
||||
|
||||
/** Short, generic names that are only sensitive when they are the whole key. */
|
||||
const SENSITIVE_EXACT = new Set([
|
||||
'pass',
|
||||
'key',
|
||||
'salt',
|
||||
'hash',
|
||||
'otp',
|
||||
'session',
|
||||
'certs',
|
||||
'cookie'
|
||||
])
|
||||
|
||||
/** What a redacted value is replaced with, so that the shape of the meta is still readable. */
|
||||
const REDACTED = '[redacted]'
|
||||
|
||||
/** How deep `sanitizeMeta` walks before it stops descending. */
|
||||
const MAX_DEPTH = 6
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const lowered = key.toLowerCase()
|
||||
return (
|
||||
SENSITIVE_EXACT.has(lowered) ||
|
||||
SENSITIVE_SUBSTRINGS.some((pattern) => lowered.includes(pattern))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the value of any sensitive-looking key, however deeply nested.
|
||||
*
|
||||
* Returns a new structure; the caller's object is never modified. Anything that is not a plain object
|
||||
* or array is passed through as-is, so a `Date` or a `Buffer` reaching here stays what it was and is
|
||||
* left for `JSON.stringify` to deal with on its way into the jsonb column.
|
||||
*/
|
||||
export function sanitizeMeta(value: unknown, depth = 0): any {
|
||||
if (depth > MAX_DEPTH) {
|
||||
return REDACTED
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeMeta(entry, depth + 1))
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || !isPlainObject(value)) {
|
||||
return value
|
||||
}
|
||||
const cleaned: Record<string, any> = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
cleaned[key] = isSensitiveKey(key) ? REDACTED : sanitizeMeta(entry, depth + 1)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): boolean {
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/** Who is making this request, as the audit log records them. */
|
||||
function actorFromRequest(req: FastifyRequest): AuditActor {
|
||||
const user = req.session?.authenticated ? req.session.user : null
|
||||
return {
|
||||
id: user?.id ?? null,
|
||||
name: user?.name ?? null,
|
||||
email: user?.email ?? null,
|
||||
ip: req.ip
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one action in the audit log, as the request that made it.
|
||||
*
|
||||
* Called from API route handlers, AFTER the action has succeeded — an audit log says what happened,
|
||||
* and a row written before the work would claim something that a later `throw` never did. Being
|
||||
* called from a route is also what keeps the scheduler out of the log: a page the git sync imports
|
||||
* reaches the same model method by a path that never comes through here.
|
||||
*
|
||||
* This is how nearly everything is recorded. The exceptions are the auth events whose actor is not
|
||||
* on the session yet — a login, a registration, a password reset from an emailed link — where the
|
||||
* account is only identified deep inside `models/users.ts` by a credential or a token; those call
|
||||
* `auditLog.record` directly, with the account they have just resolved. A logout is the mirror image:
|
||||
* the session is destroyed before the entry is written, so it too passes its own actor.
|
||||
*
|
||||
* Awaiting it is optional and mostly pointless — `auditLog.record` swallows its own failures — but
|
||||
* every call site does, so that a route's last statement is not a floating promise the response can
|
||||
* outrun.
|
||||
*
|
||||
* A request authenticated by an API key rather than a session is recorded with no user: a key acts
|
||||
* with the permissions of its groups, not as a person, and `meta.apiKeyId` is what identifies it.
|
||||
*
|
||||
* @param kind Which area of the wiki the action belongs to
|
||||
* @param action What was done, as a key from `AUDIT_ACTIONS`
|
||||
* @param meta The identity of what was touched. Never a payload, and never a secret.
|
||||
*/
|
||||
export async function audit(
|
||||
req: FastifyRequest,
|
||||
kind: AuditKind,
|
||||
action: AuditAction,
|
||||
meta: Record<string, any> = {}
|
||||
): Promise<void> {
|
||||
await WIKI.models.auditLog.record({
|
||||
kind,
|
||||
action,
|
||||
actor: actorFromRequest(req),
|
||||
meta: req.apiKey ? { ...meta, apiKeyId: req.apiKey.id } : meta
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,415 @@
|
||||
import { and, count, desc, eq, gte, inArray, lte, lt, or, sql } from 'drizzle-orm'
|
||||
import { auditLog as auditLogTable } from '../db/schema.ts'
|
||||
import { sanitizeMeta } from '../helpers/audit.ts'
|
||||
|
||||
/**
|
||||
* The areas of the wiki an action can belong to.
|
||||
*
|
||||
* Closed in practice but stored as a varchar, so naming another one is a code change rather than a
|
||||
* migration. The admin area's filter is built from this list, and `admin.audit.kinds.<kind>` is the
|
||||
* translation of each.
|
||||
*/
|
||||
export const AUDIT_KINDS = ['page', 'asset', 'auth', 'profile', 'admin'] as const
|
||||
export type AuditKind = (typeof AUDIT_KINDS)[number]
|
||||
|
||||
/**
|
||||
* Every action the wiki records, by area.
|
||||
*
|
||||
* This is the authority: the admin area's action filter is built from it, `admin.audit.actions.<key>`
|
||||
* is how each one is translated, and the documentation site lists it. A route recording an action
|
||||
* that is not here is a bug — nothing enforces it at runtime (an audit write must never fail a
|
||||
* request), but `npm run typecheck` does, since `AuditAction` is the union of these.
|
||||
*
|
||||
* Keys are camelCase and unique ACROSS areas, so that one translation key means one thing. Where two
|
||||
* areas would otherwise collide the more specific one is qualified — `forcedPasswordChange` is the
|
||||
* one a login demands, `changePassword` the one a user makes from their own profile.
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
page: [
|
||||
'createPage',
|
||||
'updatePage',
|
||||
'movePage',
|
||||
'deletePage',
|
||||
'renderPage',
|
||||
'unlockPage',
|
||||
'watchPage',
|
||||
'unwatchPage',
|
||||
'submitPageEdit',
|
||||
'approvePageEdit',
|
||||
'rejectPageEdit',
|
||||
'createFolder',
|
||||
'updateFolder',
|
||||
'moveFolder',
|
||||
'duplicateFolder',
|
||||
'setFolderColor',
|
||||
'deleteFolder'
|
||||
],
|
||||
asset: ['uploadAsset', 'updateAsset', 'deleteAsset'],
|
||||
auth: [
|
||||
'login',
|
||||
'logout',
|
||||
'register',
|
||||
'verifyEmail',
|
||||
'requestPasswordReset',
|
||||
'resetPassword',
|
||||
'forcedPasswordChange'
|
||||
],
|
||||
profile: [
|
||||
'updateProfile',
|
||||
'updateAvatar',
|
||||
'deleteAvatar',
|
||||
'updateEditorSettings',
|
||||
'changePassword',
|
||||
'togglePasswordLogin',
|
||||
'enableTfa',
|
||||
'disableTfa',
|
||||
'registerPasskey',
|
||||
'deletePasskey'
|
||||
],
|
||||
admin: [
|
||||
'createApiKey',
|
||||
'revokeApiKey',
|
||||
'createApprovalRule',
|
||||
'updateApprovalRule',
|
||||
'deleteApprovalRule',
|
||||
'createAuthStrategy',
|
||||
'updateAuthStrategy',
|
||||
'deleteAuthStrategy',
|
||||
'updateBlock',
|
||||
'deleteBlock',
|
||||
'createGroup',
|
||||
'updateGroup',
|
||||
'deleteGroup',
|
||||
'assignUserToGroup',
|
||||
'unassignUserFromGroup',
|
||||
'createHook',
|
||||
'updateHook',
|
||||
'deleteHook',
|
||||
'addIconSet',
|
||||
'updateIconSet',
|
||||
'deleteIconSet',
|
||||
'refreshIconSets',
|
||||
'materializeIcons',
|
||||
'flushIconCache',
|
||||
'fetchLocales',
|
||||
'installLocale',
|
||||
'updateLocale',
|
||||
'updateMailConfig',
|
||||
'sendTestEmail',
|
||||
'updatePageNavigation',
|
||||
'runScheduledTask',
|
||||
'cancelJob',
|
||||
'retryJob',
|
||||
'createSite',
|
||||
'updateSite',
|
||||
'deleteSite',
|
||||
'updateSiteImage',
|
||||
'deleteSiteImage',
|
||||
'updateStorage',
|
||||
'runStorageAction',
|
||||
'updateFlags',
|
||||
'updateSecurity',
|
||||
'updateSearchConfig',
|
||||
'rebuildSearchIndex',
|
||||
'installExtension',
|
||||
'updateApiState',
|
||||
'updateMetricsState',
|
||||
'disconnectWebsockets',
|
||||
'flushCache',
|
||||
'regenerateCertificates',
|
||||
'purgeApiKeys',
|
||||
'invalidateSessions',
|
||||
'purgePageHistory',
|
||||
'purgeSampleContent',
|
||||
'checkForUpdate',
|
||||
'createUser',
|
||||
'updateUser',
|
||||
'resetUserPassword',
|
||||
'sendWelcomeEmail',
|
||||
'deleteUser',
|
||||
'updateUserDefaults',
|
||||
'updateAuditConfig',
|
||||
'exportAuditLog'
|
||||
]
|
||||
} as const satisfies Record<AuditKind, readonly string[]>
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[AuditKind][number]
|
||||
|
||||
/** Every action key, flat and sorted — what the admin area's filter offers. */
|
||||
export const AUDIT_ACTION_KEYS: string[] = Object.values(AUDIT_ACTIONS).flat().toSorted()
|
||||
|
||||
/**
|
||||
* Who did it, as the row keeps it.
|
||||
*
|
||||
* The name and email are a copy taken at the time rather than a reference, because `userId` is set to
|
||||
* null when the account is deleted and the row has to stay readable afterwards. Null for all three on
|
||||
* an action nobody was signed in for — a registration, a password reset from an emailed link.
|
||||
*/
|
||||
export interface AuditActor {
|
||||
id: string | null
|
||||
name: string | null
|
||||
email: string | null
|
||||
ip: string
|
||||
}
|
||||
|
||||
/** One row as the API answers with it. */
|
||||
export interface AuditEntry {
|
||||
id: string
|
||||
ts: Date
|
||||
kind: string
|
||||
action: string
|
||||
clientIP: string
|
||||
meta: Record<string, any>
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
/** One page of the log, with the total matching the filters. */
|
||||
export interface AuditLogPage {
|
||||
total: number
|
||||
entries: AuditEntry[]
|
||||
}
|
||||
|
||||
/** What `list()` narrows by. Every field is optional; together they are AND-ed. */
|
||||
export interface AuditLogFilters {
|
||||
/**
|
||||
* Accounts to narrow to, OR-ed against each other — "what did any of these people do".
|
||||
*
|
||||
* A list rather than one id because the question an audit log gets asked is usually about a group
|
||||
* of people, and because the picker behind it selects a set. Empty means everybody.
|
||||
*/
|
||||
userIds?: string[]
|
||||
kind?: string
|
||||
action?: string
|
||||
/** Inclusive lower bound on `ts`, as an ISO instant. */
|
||||
from?: string
|
||||
/** Inclusive upper bound on `ts`, as an ISO instant. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
/** How long rows are kept, in days. Zero means forever. */
|
||||
export const DEFAULT_RETENTION_DAYS = 90
|
||||
|
||||
/**
|
||||
* The shortest retention that may be configured, in days. Zero — keep for ever — is the only value
|
||||
* below it.
|
||||
*
|
||||
* A floor rather than a preference, because retention is the one setting whose whole effect is to
|
||||
* destroy this table, and the person who can change it is the person the table exists to record. A
|
||||
* `manage:system` holder who could set it to a day would have a way to act, wait, and have the
|
||||
* record of what they did purged before anybody had reason to look — so the floor is what makes the
|
||||
* log outlive the window in which its subject would want it gone.
|
||||
*
|
||||
* Thirty days is not a claim that a month is enough; it is the point below which the setting stops
|
||||
* being a retention policy and starts being a way to cover tracks. Longer is a choice, shorter is
|
||||
* not offered.
|
||||
*/
|
||||
export const MIN_RETENTION_DAYS = 30
|
||||
|
||||
/**
|
||||
* Audit log model
|
||||
*
|
||||
* Rows are written by `helpers/audit.ts` from API route handlers and read by `api/auditLog.ts`.
|
||||
* Nothing else writes here — see the table's comment in `db/schema.ts` for why that boundary is the
|
||||
* feature rather than an implementation detail.
|
||||
*/
|
||||
class AuditLog {
|
||||
/**
|
||||
* Record one action.
|
||||
*
|
||||
* Never throws. An audit row is a record of a request that has already succeeded, and losing one is
|
||||
* not a reason to fail the request that was the point — the same trade `pageHistory.record` makes.
|
||||
* A failure is logged at error level rather than warn, because unlike a missing history entry a
|
||||
* missing audit entry is a gap in a record somebody is relying on.
|
||||
*/
|
||||
async record({
|
||||
kind,
|
||||
action,
|
||||
actor,
|
||||
meta = {}
|
||||
}: {
|
||||
kind: AuditKind
|
||||
action: AuditAction
|
||||
actor: AuditActor
|
||||
meta?: Record<string, any>
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await WIKI.db.insert(auditLogTable).values({
|
||||
kind,
|
||||
action,
|
||||
clientIP: actor.ip,
|
||||
userId: actor.id,
|
||||
/*
|
||||
Sanitized here rather than at each caller, so that every path in — the route helper and the
|
||||
handful of auth events that record themselves — goes through the same backstop.
|
||||
|
||||
`actor` is applied AFTER it, and last, so a route cannot overwrite it by accident with a
|
||||
meta key of its own and the copy of the account cannot be redacted by a key name.
|
||||
*/
|
||||
meta: {
|
||||
...sanitizeMeta(meta),
|
||||
actor: { name: actor.name, email: actor.email, ip: actor.ip }
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
WIKI.logger.error(`Failed to record audit entry ${kind}/${action}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters as one WHERE, or nothing at all when none were given.
|
||||
*
|
||||
* Shared by `list` and `stream` rather than written twice: an export that did not match the screen
|
||||
* it was taken from would be the worst kind of wrong, since nothing about the file would say so.
|
||||
*/
|
||||
whereFor(filters: AuditLogFilters) {
|
||||
const conditions = []
|
||||
if (filters.userIds && filters.userIds.length > 0) {
|
||||
// -> `inArray` even for one, so the single and the many cases are the same query
|
||||
conditions.push(inArray(auditLogTable.userId, filters.userIds))
|
||||
}
|
||||
if (filters.kind) {
|
||||
conditions.push(eq(auditLogTable.kind, filters.kind))
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(eq(auditLogTable.action, filters.action))
|
||||
}
|
||||
if (filters.from) {
|
||||
conditions.push(gte(auditLogTable.ts, new Date(filters.from)))
|
||||
}
|
||||
if (filters.to) {
|
||||
conditions.push(lte(auditLogTable.ts, new Date(filters.to)))
|
||||
}
|
||||
return conditions.length > 0 ? and(...conditions) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entry matching the filters, newest first, a batch at a time.
|
||||
*
|
||||
* A generator rather than an array because this backs the export, and the whole point of exporting
|
||||
* an audit log is that it is long: an instance with a year of history can hold millions of rows,
|
||||
* and materialising those to answer one request would take the wiki down with it. The caller
|
||||
* streams what this yields straight to the response.
|
||||
*
|
||||
* Paged by keyset — "older than the last row I sent" — rather than by OFFSET, which re-walks and
|
||||
* discards everything before it on each batch and turns one export into a quadratic scan. `id`
|
||||
* breaks ties, since two entries can share a timestamp and an unstable order would drop or repeat
|
||||
* rows across batch boundaries.
|
||||
*/
|
||||
async *stream(filters: AuditLogFilters, batchSize = 1000): AsyncGenerator<AuditEntry> {
|
||||
const where = this.whereFor(filters)
|
||||
let cursor: { ts: Date; id: string } | null = null
|
||||
|
||||
for (;;) {
|
||||
const keyset = cursor
|
||||
? or(
|
||||
lt(auditLogTable.ts, cursor.ts),
|
||||
and(eq(auditLogTable.ts, cursor.ts), lt(auditLogTable.id, cursor.id))
|
||||
)
|
||||
: undefined
|
||||
const rows = (await WIKI.db
|
||||
.select()
|
||||
.from(auditLogTable)
|
||||
.where(keyset ? and(where, keyset) : where)
|
||||
.orderBy(desc(auditLogTable.ts), desc(auditLogTable.id))
|
||||
.limit(batchSize)) as AuditEntry[]
|
||||
|
||||
for (const row of rows) {
|
||||
yield row
|
||||
}
|
||||
// -> A short batch is the last one; a full one may or may not be, so it costs one empty query
|
||||
if (rows.length < batchSize) {
|
||||
return
|
||||
}
|
||||
const last = rows[rows.length - 1]!
|
||||
cursor = { ts: last.ts, id: last.id }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the log, newest first.
|
||||
*
|
||||
* No join to `users`: every row already carries the name and email the account had at the time, and
|
||||
* that is what should be shown. Reading the current name instead would quietly rewrite history
|
||||
* every time somebody was renamed, and would show nothing at all once they were deleted.
|
||||
*
|
||||
* @param filters What to narrow by
|
||||
* @param page 1-based page number
|
||||
* @param limit Rows per page
|
||||
*/
|
||||
async list(filters: AuditLogFilters, page: number, limit: number): Promise<AuditLogPage> {
|
||||
const where = this.whereFor(filters)
|
||||
|
||||
const [totals, entries] = await Promise.all([
|
||||
WIKI.db.select({ total: count() }).from(auditLogTable).where(where),
|
||||
WIKI.db
|
||||
.select()
|
||||
.from(auditLogTable)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLogTable.ts))
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit)
|
||||
])
|
||||
|
||||
return {
|
||||
total: totals[0]?.total ?? 0,
|
||||
entries: entries as AuditEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long rows are kept, in days. Zero means forever.
|
||||
*
|
||||
* The floor is applied on the way OUT as well as on the way in. `api/auditLog.ts` refuses to store
|
||||
* anything under it, so a value below it can only have arrived another way — a hand-edited
|
||||
* `config.yml`, a direct write to the settings table — and honouring it there would leave the one
|
||||
* route round the rule that the rule exists to close. Anything between 1 and the floor is read as
|
||||
* the floor rather than as itself; a value that is not a whole number of days at all falls back to
|
||||
* the default.
|
||||
*/
|
||||
retentionDays(): number {
|
||||
const configured = WIKI.config.audit?.retentionDays
|
||||
if (!Number.isInteger(configured) || configured < 0) {
|
||||
return DEFAULT_RETENTION_DAYS
|
||||
}
|
||||
if (configured === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(configured, MIN_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rows older than the configured retention.
|
||||
*
|
||||
* @returns How many rows went, or 0 when retention is off
|
||||
*/
|
||||
async purge(): Promise<number> {
|
||||
const days = this.retentionDays()
|
||||
if (days < 1) {
|
||||
return 0
|
||||
}
|
||||
// -> `Instant` takes exact time units only, so days are expressed as hours
|
||||
const cutoff = Temporal.Now.instant().subtract({ hours: days * 24 })
|
||||
const deleted = await WIKI.db
|
||||
.delete(auditLogTable)
|
||||
.where(lt(auditLogTable.ts, new Date(cutoff.epochMilliseconds)))
|
||||
.returning({ id: auditLogTable.id })
|
||||
return deleted.length
|
||||
}
|
||||
|
||||
/** How many rows the log holds, for the retention card in the admin area. */
|
||||
async total(): Promise<number> {
|
||||
const rows = await WIKI.db.select({ total: count() }).from(auditLogTable)
|
||||
return rows[0]?.total ?? 0
|
||||
}
|
||||
|
||||
/** The instant of the oldest row, or null when the log is empty. */
|
||||
async oldestEntry(): Promise<string | null> {
|
||||
const rows = await WIKI.db
|
||||
.select({ ts: sql<Date>`min(${auditLogTable.ts})` })
|
||||
.from(auditLogTable)
|
||||
const oldest = rows[0]?.ts
|
||||
return oldest ? new Date(oldest).toISOString() : null
|
||||
}
|
||||
}
|
||||
|
||||
export const auditLog = new AuditLog()
|
||||
@ -0,0 +1,13 @@
|
||||
export async function task(): Promise<void> {
|
||||
WIKI.logger.info('Purging expired audit log entries...')
|
||||
|
||||
try {
|
||||
const purged = await WIKI.models.auditLog.purge()
|
||||
|
||||
WIKI.logger.info(`Purged ${purged} expired audit log entries: [ COMPLETED ]`)
|
||||
} catch (err: any) {
|
||||
WIKI.logger.error('Purging expired audit log entries: [ FAILED ]')
|
||||
WIKI.logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" max-width="700px" @hide="onDialogHide">
|
||||
<w-card style="min-width: 500px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-event-log.svg" size="sm" class="mr-2" />
|
||||
<span>{{ actionLabel }}</span>
|
||||
</w-card-section>
|
||||
<w-card-section>
|
||||
<w-list separator dense>
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<w-item-label caption>{{ t('admin.audit.field.timestamp') }}</w-item-label>
|
||||
<!--
|
||||
Named rather than left implied. The list shows these times in the reader's own zone
|
||||
without saying so, which is fine while scanning; on the record of a single event it
|
||||
matters, since two administrators reading the same entry from different zones would
|
||||
otherwise each take their own rendering for the moment it happened.
|
||||
-->
|
||||
<w-item-label>
|
||||
{{ userStore.formatDateTime(t, entry.ts) }}
|
||||
<span class="text-grey">({{ userStore.timezoneId() }})</span>
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<w-item-label caption>{{ t('admin.audit.field.user') }}</w-item-label>
|
||||
<!--
|
||||
From `meta.actor`, never from the users table: this is who the account was at the
|
||||
time. An entry whose account has since been deleted still reads correctly, and is
|
||||
marked as such rather than silently losing its name.
|
||||
-->
|
||||
<w-item-label>
|
||||
{{ entry.meta?.actor?.name || t('admin.audit.anonymous') }}
|
||||
<span class="text-grey" v-if="entry.meta?.actor?.email">
|
||||
<{{ entry.meta.actor.email }}>
|
||||
</span>
|
||||
</w-item-label>
|
||||
<w-item-label caption v-if="!entry.userId && entry.meta?.actor?.email">
|
||||
{{ t('admin.audit.accountGone') }}
|
||||
</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<w-item-label caption>{{ t('admin.audit.field.clientIP') }}</w-item-label>
|
||||
<w-item-label class="font-mono">{{ entry.clientIP || '---' }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<w-item-label caption>{{ t('admin.audit.field.action') }}</w-item-label>
|
||||
<w-item-label class="font-mono">{{ entry.kind }} / {{ entry.action }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
<div class="text-caption text-grey mt-4 mb-1">{{ t('admin.audit.field.meta') }}</div>
|
||||
<!--
|
||||
The whole of `meta`, as it is stored. A formatted dump rather than a field list because
|
||||
what an entry carries depends entirely on what it records — a page edit names a history
|
||||
version, a group change carries the permission set — and inventing a layout per action
|
||||
would be sixty layouts.
|
||||
-->
|
||||
<pre
|
||||
class="overflow-x-auto rounded bg-black/5 p-3 text-caption dark:bg-white/5"><code>{{ formattedMeta }}</code></pre>
|
||||
</w-card-section>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.close`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
entry: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// STORES
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
The action key doubles as its own translation key. An action added to the server without a string
|
||||
to go with it falls back to the key itself, which still says what happened.
|
||||
*/
|
||||
const actionLabel = computed(() =>
|
||||
t(`admin.audit.actions.${props.entry.action}`, props.entry.action)
|
||||
)
|
||||
|
||||
const formattedMeta = computed(() => JSON.stringify(props.entry.meta ?? {}, null, 2))
|
||||
</script>
|
||||
@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<w-menu
|
||||
class="translucent-menu"
|
||||
anchor="bottom right"
|
||||
self="top right"
|
||||
:offset="[0, 10]"
|
||||
ref="menuRef"
|
||||
@show="load">
|
||||
<w-card style="width: 620px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-event-log.svg" left size="sm" />
|
||||
<span>{{ t('admin.audit.retention') }}</span>
|
||||
</w-card-section>
|
||||
<w-card-section>
|
||||
<div class="text-body2 text-black/60 dark:text-white/70">
|
||||
{{ t('admin.audit.retentionHint') }}
|
||||
</div>
|
||||
<!--
|
||||
Said here rather than left for somebody to discover by being refused: the options below
|
||||
simply stop at 30 days, and a floor with no reason given reads as an oversight.
|
||||
-->
|
||||
<div class="text-body2 mt-2 text-black/60 dark:text-white/70">
|
||||
{{ t('admin.audit.retentionFloorHint') }}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<w-select
|
||||
outlined
|
||||
dense
|
||||
v-model="state.retentionDays"
|
||||
:options="retentionOptions"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('admin.audit.retentionPeriod')" />
|
||||
</div>
|
||||
<!--
|
||||
What is actually in the table, so that "keep 90 days" is chosen against a real number
|
||||
rather than in the abstract. Nothing is deleted by saving — the daily task is what applies
|
||||
it — and the copy says so.
|
||||
-->
|
||||
<div class="text-caption text-grey mt-4">
|
||||
<i18n-t keypath="admin.audit.retentionStats" tag="span">
|
||||
<template #count
|
||||
><strong>{{ state.total }}</strong></template
|
||||
>
|
||||
<template #oldest
|
||||
><strong>{{
|
||||
state.oldestEntry ? userStore.formatDateTime(t, state.oldestEntry) : '---'
|
||||
}}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</w-card-section>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="close" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.save`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
@click="save" />
|
||||
</w-card-actions>
|
||||
<w-inner-loading :showing="state.loading > 0" />
|
||||
</w-card>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// STORES
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// REFS
|
||||
|
||||
const menuRef = ref(null)
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
retentionDays: 90,
|
||||
total: 0,
|
||||
oldestEntry: null,
|
||||
loading: 0
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
Fixed periods rather than a free number: this is a compliance decision made once, and a typo in a
|
||||
days field silently throws away years of log. Zero is offered last and named for what it does.
|
||||
*/
|
||||
const retentionOptions = computed(() => [
|
||||
{ label: t('admin.audit.retention30'), value: 30 },
|
||||
{ label: t('admin.audit.retention90'), value: 90 },
|
||||
{ label: t('admin.audit.retention180'), value: 180 },
|
||||
{ label: t('admin.audit.retention365'), value: 365 },
|
||||
{ label: t('admin.audit.retention730'), value: 730 },
|
||||
{ label: t('admin.audit.retentionForever'), value: 0 }
|
||||
])
|
||||
|
||||
// METHODS
|
||||
|
||||
async function load() {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.get('audit/config').json()
|
||||
state.retentionDays = resp?.retentionDays ?? 90
|
||||
state.total = resp?.total ?? 0
|
||||
state.oldestEntry = resp?.oldestEntry ?? null
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.audit.retentionLoadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function save() {
|
||||
state.loading++
|
||||
try {
|
||||
await API_CLIENT.put('audit/config', {
|
||||
json: { retentionDays: state.retentionDays }
|
||||
}).json()
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('admin.audit.retentionSaveSuccess')
|
||||
})
|
||||
close()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.audit.retentionSaveFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
function close() {
|
||||
menuRef.value?.hide()
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,629 @@
|
||||
<template>
|
||||
<w-page class="admin-audit">
|
||||
<div class="flex flex-wrap items-center p-4">
|
||||
<div class="flex-none">
|
||||
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-event-log.svg" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 pl-4">
|
||||
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.audit.title') }}</div>
|
||||
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
|
||||
{{ t('admin.audit.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center">
|
||||
<!--
|
||||
Ahead of the utility buttons and fenced off from them: this is the one control here that
|
||||
takes something away with it, where the three beside it read documentation, reload the
|
||||
screen, or open a setting.
|
||||
-->
|
||||
<w-btn
|
||||
class="mr-2 acrylic-btn"
|
||||
flat
|
||||
icon="la:file-download"
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
:label="t(`admin.audit.export`)"
|
||||
:loading="state.exporting"
|
||||
@click="exportLog" />
|
||||
<w-separator vertical class="mr-2 h-6 self-center" />
|
||||
<w-btn
|
||||
class="mr-2 acrylic-btn"
|
||||
icon="la:question-circle"
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin/audit`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="mr-2 acrylic-btn"
|
||||
icon="la:redo-alt"
|
||||
flat
|
||||
color="secondary"
|
||||
:loading="state.loading > 0"
|
||||
:aria-label="t(`common.actions.refresh`)"
|
||||
@click="refresh">
|
||||
<w-tooltip>{{ t(`common.actions.refresh`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
<!--
|
||||
Reading the log and deciding how long it is kept are different authorities: shortening the
|
||||
retention destroys evidence. The endpoint behind this needs `manage:system`, so the button
|
||||
is only offered to somebody who holds it.
|
||||
-->
|
||||
<w-btn
|
||||
v-if="canManageRetention"
|
||||
unelevated
|
||||
icon="la:hourglass-half"
|
||||
color="secondary"
|
||||
:label="t(`admin.audit.retention`)">
|
||||
<audit-retention-menu />
|
||||
</w-btn>
|
||||
</div>
|
||||
</div>
|
||||
<w-separator inset />
|
||||
<div class="grid grid-cols-12 gap-4 p-4">
|
||||
<div class="col-span-12">
|
||||
<w-card>
|
||||
<!--
|
||||
Every child is now one control on one line, so they align on their own box rather than on
|
||||
a taller neighbour's.
|
||||
-->
|
||||
<w-card-section class="flex flex-wrap items-center gap-3">
|
||||
<!--
|
||||
The user filter is a picker rather than a dropdown: an instance can have more accounts
|
||||
than a select should ever hold, and the search dialog already exists for exactly this.
|
||||
It carries its own label inline, since a button has no floating-label chrome to put one
|
||||
in and a caption line above it would stand this column a line taller than the four
|
||||
beside it.
|
||||
|
||||
`my-2` matches what an outlined WInput/WSelect gives its own control (see
|
||||
`controlClasses` there) to leave room for the floated label. Without it a bare button
|
||||
in this row bottom-aligns 8px below the fields, which is exactly what it did.
|
||||
|
||||
The colour is per theme because `primary-light` is a 60% primary / 40% white mix: it
|
||||
reads at 6.9:1 on the dark card and 2.4:1 on the white one, where it is a pale blue on
|
||||
white. Full `primary` carries the light theme, as it does for every other flat button
|
||||
on a card.
|
||||
-->
|
||||
<div class="my-2 flex min-w-[220px] flex-1 items-center gap-2">
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
no-caps
|
||||
icon="la:user"
|
||||
:color="dark.isActive ? `primary-light` : `primary`"
|
||||
:label="userFilterLabel"
|
||||
@click="pickUser" />
|
||||
<w-btn
|
||||
v-if="state.filterUsers.length > 0"
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
dense
|
||||
icon="la:times"
|
||||
color="grey"
|
||||
:aria-label="t('common.actions.clear')"
|
||||
@click="clearUser" />
|
||||
</div>
|
||||
<div class="min-w-[160px] flex-1">
|
||||
<w-select
|
||||
outlined
|
||||
dense
|
||||
v-model="state.filterKind"
|
||||
:options="kindOptions"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('admin.audit.field.kind')" />
|
||||
</div>
|
||||
<div class="min-w-[220px] flex-1">
|
||||
<w-select
|
||||
outlined
|
||||
dense
|
||||
v-model="state.filterAction"
|
||||
:options="actionOptions"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('admin.audit.field.action')" />
|
||||
</div>
|
||||
<!--
|
||||
`datetime-local`, so the two bounds are entered in the reader's own clock and converted
|
||||
on the way out. The API takes instants; a wall-clock string would be ambiguous the
|
||||
moment two administrators sat in different time zones.
|
||||
-->
|
||||
<div class="min-w-[200px] flex-1">
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
type="datetime-local"
|
||||
v-model="state.filterFrom"
|
||||
:label="t('admin.audit.field.from')" />
|
||||
</div>
|
||||
<div class="min-w-[200px] flex-1">
|
||||
<w-input
|
||||
outlined
|
||||
dense
|
||||
type="datetime-local"
|
||||
v-model="state.filterTo"
|
||||
:label="t('admin.audit.field.to')" />
|
||||
</div>
|
||||
<!-- -> Same `my-2` as the user button above, for the same reason -->
|
||||
<div class="my-2 flex-none">
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
no-caps
|
||||
icon="la:eraser"
|
||||
color="grey"
|
||||
:label="t('admin.audit.clearFilters')"
|
||||
:disable="!hasFilters"
|
||||
@click="clearFilters" />
|
||||
</div>
|
||||
</w-card-section>
|
||||
</w-card>
|
||||
</div>
|
||||
<div class="col-span-12">
|
||||
<w-banner
|
||||
v-if="state.entries.length < 1 && state.loading < 1"
|
||||
rounded
|
||||
:class="dark.isActive ? `bg-dark-3 text-grey-4` : `bg-grey-2 text-grey-8`">
|
||||
{{ hasFilters ? t('admin.audit.noneMatching') : t('admin.audit.none') }}
|
||||
</w-banner>
|
||||
<w-card v-else>
|
||||
<w-table
|
||||
:rows="state.entries"
|
||||
:columns="headers"
|
||||
row-key="id"
|
||||
flat
|
||||
:loading="state.loading > 0">
|
||||
<template #body-cell-ts="props">
|
||||
<w-td :props="props">
|
||||
<div>{{ userStore.formatDateTime(t, props.value) }}</div>
|
||||
<small class="text-grey">{{ relativeDate(props.value) }}</small>
|
||||
</w-td>
|
||||
</template>
|
||||
<template #body-cell-user="props">
|
||||
<w-td :props="props">
|
||||
<!--
|
||||
Read off `meta.actor`, which is the copy taken when the entry was written. An
|
||||
account that has since been deleted keeps its name here, which is the whole reason
|
||||
that copy exists.
|
||||
-->
|
||||
<div>
|
||||
<strong>{{ props.row.meta?.actor?.name || t('admin.audit.anonymous') }}</strong>
|
||||
<w-icon
|
||||
v-if="!props.row.userId && props.row.meta?.actor?.email"
|
||||
class="ml-1"
|
||||
name="la:user-slash"
|
||||
color="grey"
|
||||
size="xs">
|
||||
<w-tooltip>{{ t('admin.audit.accountGone') }}</w-tooltip>
|
||||
</w-icon>
|
||||
</div>
|
||||
<small class="text-grey" v-if="props.row.meta?.actor?.email">
|
||||
{{ props.row.meta.actor.email }}
|
||||
</small>
|
||||
</w-td>
|
||||
</template>
|
||||
<template #body-cell-kind="props">
|
||||
<w-td :props="props">
|
||||
<w-chip
|
||||
square
|
||||
size="sm"
|
||||
dense
|
||||
:color="kindStyle(props.value).color"
|
||||
:text-color="kindStyle(props.value).textColor">
|
||||
{{ t(`admin.audit.kinds.${props.value}`, props.value) }}
|
||||
</w-chip>
|
||||
</w-td>
|
||||
</template>
|
||||
<template #body-cell-action="props">
|
||||
<w-td :props="props">
|
||||
<div>{{ t(`admin.audit.actions.${props.value}`, props.value) }}</div>
|
||||
<small class="text-grey font-mono">{{ props.value }}</small>
|
||||
</w-td>
|
||||
</template>
|
||||
<template #body-cell-clientIP="props">
|
||||
<w-td :props="props">
|
||||
<span class="font-mono">{{ props.value || '---' }}</span>
|
||||
</w-td>
|
||||
</template>
|
||||
<template #body-cell-details="props">
|
||||
<w-td :props="props">
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
no-caps
|
||||
icon="la:search-plus"
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
:label="t('admin.audit.viewDetails')"
|
||||
@click="showEntry(props.row)" />
|
||||
</w-td>
|
||||
</template>
|
||||
</w-table>
|
||||
</w-card>
|
||||
<div class="mt-6 flex items-center justify-center" v-if="state.totalPages > 1">
|
||||
<w-pagination
|
||||
v-model="state.currentPage"
|
||||
:max="state.totalPages"
|
||||
:max-pages="9"
|
||||
boundary-numbers
|
||||
direction-links />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</w-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { loading } from '@/composables/loading'
|
||||
import { dialog } from '@/composables/dialog'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import { fileSave } from 'browser-fs-access'
|
||||
|
||||
import { relativeDate } from '@/helpers/datetime'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
import AuditEntryDialog from '@/components/AuditEntryDialog.vue'
|
||||
import AuditRetentionMenu from '@/components/AuditRetentionMenu.vue'
|
||||
import UserSearchDialog from '@/components/UserSearchDialog.vue'
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
title: t('admin.audit.title')
|
||||
})
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
entries: [],
|
||||
/** Every action key the server records, by area — what the action filter is built from. */
|
||||
actionsByKind: [],
|
||||
loading: 0,
|
||||
exporting: false,
|
||||
/*
|
||||
A list, because the picker behind it selects a set — it has checkboxes. Taking only the first of
|
||||
what somebody chose and labelling the button with that one name told them the other selections
|
||||
had been applied when they had been thrown away.
|
||||
*/
|
||||
filterUsers: [],
|
||||
filterKind: null,
|
||||
filterAction: null,
|
||||
filterFrom: '',
|
||||
filterTo: '',
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
/*
|
||||
A computed, not a plain array: the locale strings are fetched after the app mounts
|
||||
(`App.vue` → `applyLocale`), so a `t()` called once during `setup()` can resolve before they land
|
||||
and leave the header row showing raw keys for the life of the page. Inside a computed it
|
||||
re-evaluates when `setLocaleMessage` fills them in.
|
||||
*/
|
||||
const headers = computed(() => [
|
||||
{
|
||||
label: t('admin.audit.field.timestamp'),
|
||||
align: 'left',
|
||||
field: 'ts',
|
||||
name: 'ts',
|
||||
sortable: false,
|
||||
style: 'width: 200px'
|
||||
},
|
||||
{
|
||||
label: t('admin.audit.field.user'),
|
||||
align: 'left',
|
||||
field: 'user',
|
||||
name: 'user',
|
||||
sortable: false,
|
||||
style: 'width: 260px'
|
||||
},
|
||||
{
|
||||
label: t('admin.audit.field.kind'),
|
||||
align: 'left',
|
||||
field: 'kind',
|
||||
name: 'kind',
|
||||
sortable: false,
|
||||
style: 'width: 110px'
|
||||
},
|
||||
{
|
||||
label: t('admin.audit.field.action'),
|
||||
align: 'left',
|
||||
field: 'action',
|
||||
name: 'action',
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
label: t('admin.audit.field.clientIP'),
|
||||
align: 'left',
|
||||
field: 'clientIP',
|
||||
name: 'clientIP',
|
||||
sortable: false,
|
||||
style: 'width: 160px'
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
align: 'right',
|
||||
field: 'details',
|
||||
name: 'details',
|
||||
sortable: false,
|
||||
style: 'width: 140px'
|
||||
}
|
||||
])
|
||||
|
||||
/*
|
||||
One colour per area, so a kind is recognisable before its label is read.
|
||||
|
||||
The text colour travels with it rather than being a fixed `white` on the chip: amber is light
|
||||
enough that white on it is around 1.9:1, so that one carries its own dark ink. Anything added here
|
||||
needs the same check — the badge is the only thing distinguishing five areas at a glance, and one
|
||||
that cannot be read is worse than no badge.
|
||||
*/
|
||||
const KIND_STYLES = {
|
||||
page: { color: 'blue', textColor: 'white' },
|
||||
asset: { color: 'teal', textColor: 'white' },
|
||||
auth: { color: 'amber', textColor: 'grey-10' },
|
||||
profile: { color: 'purple', textColor: 'white' },
|
||||
admin: { color: 'red', textColor: 'white' }
|
||||
}
|
||||
|
||||
const DEFAULT_KIND_STYLE = { color: 'grey', textColor: 'white' }
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const canManageRetention = computed(() => userStore.can('manage:system'))
|
||||
|
||||
const kindOptions = computed(() => [
|
||||
{ label: t('admin.audit.allKinds'), value: null },
|
||||
...state.actionsByKind.map(({ kind }) => ({
|
||||
label: t(`admin.audit.kinds.${kind}`, kind),
|
||||
value: kind
|
||||
}))
|
||||
])
|
||||
|
||||
/*
|
||||
Narrowed by the selected kind, since an action belongs to exactly one area: picking `page` and then
|
||||
`updateSite` would match nothing, and offering the pair is offering an empty result.
|
||||
*/
|
||||
const actionOptions = computed(() => {
|
||||
const groups = state.filterKind
|
||||
? state.actionsByKind.filter(({ kind }) => kind === state.filterKind)
|
||||
: state.actionsByKind
|
||||
return [
|
||||
{ label: t('admin.audit.allActions'), value: null },
|
||||
...groups
|
||||
.flatMap(({ actions }) => actions)
|
||||
.map((action) => ({ label: t(`admin.audit.actions.${action}`, action), value: action }))
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label))
|
||||
]
|
||||
})
|
||||
|
||||
/*
|
||||
One name when one account is picked, a count when several are — a row of names would outgrow the
|
||||
button and truncate, which is the same "shows you part of what you chose" problem as before.
|
||||
*/
|
||||
const userFilterLabel = computed(() => {
|
||||
if (state.filterUsers.length === 1) {
|
||||
return t('admin.audit.userFilter', { name: state.filterUsers[0].name })
|
||||
}
|
||||
if (state.filterUsers.length > 1) {
|
||||
return t('admin.audit.userFilterMany', { count: state.filterUsers.length })
|
||||
}
|
||||
return t('admin.audit.userFilter', { name: t('admin.audit.anyUser') })
|
||||
})
|
||||
|
||||
const hasFilters = computed(
|
||||
() =>
|
||||
state.filterUsers.length > 0 ||
|
||||
Boolean(state.filterKind) ||
|
||||
Boolean(state.filterAction) ||
|
||||
Boolean(state.filterFrom) ||
|
||||
Boolean(state.filterTo)
|
||||
)
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(
|
||||
() => [state.filterUsers, state.filterKind, state.filterAction, state.filterFrom, state.filterTo],
|
||||
() => {
|
||||
// -> Back to the first page: page 7 of the old filter is meaningless under the new one
|
||||
state.currentPage = 1
|
||||
load({ page: 1 })
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => state.currentPage,
|
||||
(newValue) => {
|
||||
load({ page: newValue })
|
||||
}
|
||||
)
|
||||
|
||||
/*
|
||||
An action that no longer belongs to the selected kind cannot match anything, so it is dropped
|
||||
rather than left showing a filter that returns nothing.
|
||||
*/
|
||||
watch(
|
||||
() => state.filterKind,
|
||||
(kind) => {
|
||||
if (!kind || !state.filterAction) {
|
||||
return
|
||||
}
|
||||
const group = state.actionsByKind.find((g) => g.kind === kind)
|
||||
if (!group?.actions.includes(state.filterAction)) {
|
||||
state.filterAction = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* A `datetime-local` value as an instant the API can compare against.
|
||||
*
|
||||
* The input gives a wall-clock string with no zone, which is the reader's own clock — `Date` parses
|
||||
* it in exactly that zone, so this is the conversion rather than a reinterpretation.
|
||||
*/
|
||||
function boundaryToInstant(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters as the API takes them.
|
||||
*
|
||||
* One function for the table and the export, so a download can never quietly cover a different set
|
||||
* of entries than the screen it was taken from.
|
||||
*/
|
||||
function filterParams() {
|
||||
const from = boundaryToInstant(state.filterFrom)
|
||||
const to = boundaryToInstant(state.filterTo)
|
||||
return {
|
||||
...(state.filterUsers.length > 0
|
||||
? { userId: state.filterUsers.map((u) => u.id).join(',') }
|
||||
: {}),
|
||||
...(state.filterKind ? { kind: state.filterKind } : {}),
|
||||
...(state.filterAction ? { action: state.filterAction } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download everything matching the current filters as JSONL.
|
||||
*
|
||||
* The whole result, not the page on screen — a filtered export that stopped at 25 rows would be a
|
||||
* quietly wrong file. The server streams it, so what is held here is the finished download rather
|
||||
* than the query behind it.
|
||||
*/
|
||||
async function exportLog() {
|
||||
state.exporting = true
|
||||
try {
|
||||
const blob = await API_CLIENT.get('audit/export', { searchParams: filterParams() }).blob()
|
||||
const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replaceAll(':', '-')
|
||||
await fileSave(blob, {
|
||||
fileName: `audit-log-${stamp}.jsonl`,
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
} catch (err) {
|
||||
// -> Dismissing the save picker is not a failure, as in the other exports on this app
|
||||
if (err.name !== 'AbortError') {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.audit.exportFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
state.exporting = false
|
||||
}
|
||||
|
||||
async function load({ page } = {}) {
|
||||
state.loading++
|
||||
loading.show()
|
||||
try {
|
||||
const resp = await API_CLIENT.get('audit', {
|
||||
searchParams: {
|
||||
...filterParams(),
|
||||
page: page ?? state.currentPage ?? 1,
|
||||
limit: state.pageSize
|
||||
}
|
||||
}).json()
|
||||
state.entries = resp?.entries ?? []
|
||||
state.totalPages = Math.max(1, Math.ceil((resp?.total ?? 0) / state.pageSize))
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.audit.loadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
loading.hide()
|
||||
state.loading--
|
||||
}
|
||||
|
||||
async function loadActions() {
|
||||
try {
|
||||
state.actionsByKind = await API_CLIENT.get('audit/actions').json()
|
||||
} catch (err) {
|
||||
// -> The log itself still reads; only the action filter is left empty
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('admin.audit.loadActionsFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
load()
|
||||
}
|
||||
|
||||
function kindStyle(kind) {
|
||||
return KIND_STYLES[kind] ?? DEFAULT_KIND_STYLE
|
||||
}
|
||||
|
||||
function pickUser() {
|
||||
dialog({
|
||||
component: UserSearchDialog,
|
||||
componentProps: {
|
||||
title: t('admin.audit.pickUserTitle')
|
||||
}
|
||||
}).onOk((users) => {
|
||||
// -> Replaces rather than adds: the dialog opens with nothing ticked, so what comes back is the
|
||||
// whole of what was just chosen
|
||||
state.filterUsers = users ?? []
|
||||
})
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
state.filterUsers = []
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
state.filterUsers = []
|
||||
state.filterKind = null
|
||||
state.filterAction = null
|
||||
state.filterFrom = ''
|
||||
state.filterTo = ''
|
||||
}
|
||||
|
||||
function showEntry(entry) {
|
||||
dialog({
|
||||
component: AuditEntryDialog,
|
||||
componentProps: { entry }
|
||||
})
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
loadActions()
|
||||
load({ page: 1 })
|
||||
})
|
||||
</script>
|
||||
Loading…
Reference in new issue