mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +00:00
feat(server): audit log — schema, helper, admin UI, hooks for login/pair/firmware
This commit is contained in:
parent
d1fd128ea0
commit
3ec2f3bf85
10 changed files with 309 additions and 2 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
CameraNewPage,
|
CameraNewPage,
|
||||||
CameraEditPage,
|
CameraEditPage,
|
||||||
CameraDiscoverPage,
|
CameraDiscoverPage,
|
||||||
|
AuditLogPage,
|
||||||
CameraDiscoverResultsPage,
|
CameraDiscoverResultsPage,
|
||||||
EntitiesPage,
|
EntitiesPage,
|
||||||
EntityNewPage,
|
EntityNewPage,
|
||||||
|
|
@ -37,6 +38,7 @@ import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||||
import { generateBundle } from "../../shared/bundle.js";
|
import { generateBundle } from "../../shared/bundle.js";
|
||||||
import { captureSnapshot } from "../../shared/snapshot.js";
|
import { captureSnapshot } from "../../shared/snapshot.js";
|
||||||
import { stripSecrets } from "../../shared/strip-secrets.js";
|
import { stripSecrets } from "../../shared/strip-secrets.js";
|
||||||
|
import { audit } from "../../shared/audit.js";
|
||||||
|
|
||||||
interface DiscoverAddStream {
|
interface DiscoverAddStream {
|
||||||
profile_name: string;
|
profile_name: string;
|
||||||
|
|
@ -269,6 +271,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 301, headers: { location: "/admin/" } });
|
return new Response(null, { status: 301, headers: { location: "/admin/" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Audit log ------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/audit", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const url = new URL(event.req.url);
|
||||||
|
const filterAction = url.searchParams.get("action")?.trim() || undefined;
|
||||||
|
const filterActorType = url.searchParams.get("actor_type")?.trim() || undefined;
|
||||||
|
const entries = deps.repo.listAudit({
|
||||||
|
limit: 300,
|
||||||
|
action_prefix: filterAction,
|
||||||
|
actor_type: filterActorType as any || undefined,
|
||||||
|
});
|
||||||
|
return htmlPage(AuditLogPage({ user: user.username, entries, filterAction, filterActorType }));
|
||||||
|
});
|
||||||
|
|
||||||
// ---- System Health --------------------------------------------------------
|
// ---- System Health --------------------------------------------------------
|
||||||
|
|
||||||
app.get("/admin/health", (event) => {
|
app.get("/admin/health", (event) => {
|
||||||
|
|
@ -621,12 +638,17 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
|
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
||||||
code,
|
code,
|
||||||
nameOverride,
|
nameOverride,
|
||||||
initialLabels,
|
initialLabels,
|
||||||
replaceKioskId,
|
replaceKioskId,
|
||||||
});
|
});
|
||||||
|
audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", {
|
||||||
|
resource_type: "kiosk",
|
||||||
|
resource_id: result.kioskId,
|
||||||
|
metadata: { name: result.kioskName, code, replaced: !!replaceKioskId },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const kiosks = deps.repo.listKiosks();
|
const kiosks = deps.repo.listKiosks();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { type H3, readBody, getCookie, getQuery, getRequestHeader } from "h3";
|
||||||
import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-response.js";
|
import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
||||||
|
import { audit } from "../../shared/audit.js";
|
||||||
|
|
||||||
|
|
||||||
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
@ -45,6 +46,12 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
|
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
|
||||||
}
|
}
|
||||||
deps.repo.updateUser(user.id, patch);
|
deps.repo.updateUser(user.id, patch);
|
||||||
|
audit(deps.repo, event as any, "user.login", {
|
||||||
|
result: "failed",
|
||||||
|
actor_type: "system",
|
||||||
|
actor_label: username,
|
||||||
|
metadata: { failed_count: count, locked: count >= deps.auth.config.loginLockoutThreshold },
|
||||||
|
});
|
||||||
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +61,13 @@ export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
||||||
last_login_at: new Date().toISOString(),
|
last_login_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audit(deps.repo, event as any, "user.login", {
|
||||||
|
actor_type: "user",
|
||||||
|
actor_id: user.id,
|
||||||
|
actor_label: user.username,
|
||||||
|
metadata: { totp_pending: user.totp_enabled },
|
||||||
|
});
|
||||||
|
|
||||||
const totpPending = user.totp_enabled;
|
const totpPending = user.totp_enabled;
|
||||||
const { cookieValue } = await deps.auth.createSession({
|
const { cookieValue } = await deps.auth.createSession({
|
||||||
user,
|
user,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
KioskFirmwarePanel,
|
KioskFirmwarePanel,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
import { getCoordinator } from "../../shared/coordinator-registry.js";
|
import { getCoordinator } from "../../shared/coordinator-registry.js";
|
||||||
|
import { audit } from "../../shared/audit.js";
|
||||||
import type { FirmwareChannel } from "../../shared/types.js";
|
import type { FirmwareChannel } from "../../shared/types.js";
|
||||||
|
|
||||||
const ALLOWED_CHANNELS: ReadonlySet<FirmwareChannel> = new Set(["stable", "beta", "dev"]);
|
const ALLOWED_CHANNELS: ReadonlySet<FirmwareChannel> = new Set(["stable", "beta", "dev"]);
|
||||||
|
|
@ -68,7 +69,7 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const { sha256, signature } = deps.firmware.signBlob(buf);
|
const { sha256, signature } = deps.firmware.signBlob(buf);
|
||||||
const artifactPath = await deps.firmware.storeBlob(buf, sha256);
|
const artifactPath = await deps.firmware.storeBlob(buf, sha256);
|
||||||
|
|
||||||
deps.repo.createFirmwareRelease({
|
const release = deps.repo.createFirmwareRelease({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
version,
|
version,
|
||||||
channel: channelRaw as FirmwareChannel,
|
channel: channelRaw as FirmwareChannel,
|
||||||
|
|
@ -80,6 +81,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
release_notes: releaseNotes,
|
release_notes: releaseNotes,
|
||||||
uploaded_by: user.id,
|
uploaded_by: user.id,
|
||||||
});
|
});
|
||||||
|
audit(deps.repo, event as any, "firmware.upload", {
|
||||||
|
resource_type: "firmware_release",
|
||||||
|
resource_id: release.id,
|
||||||
|
metadata: { version, channel: channelRaw, arch, sha256, size: buf.length },
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/firmware" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/firmware" } });
|
||||||
});
|
});
|
||||||
|
|
@ -136,6 +142,10 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
app.post("/admin/firmware/:id/yank", (event) => {
|
app.post("/admin/firmware/:id/yank", (event) => {
|
||||||
const id = String(getRouterParam(event, "id"));
|
const id = String(getRouterParam(event, "id"));
|
||||||
deps.repo.yankFirmwareRelease(id);
|
deps.repo.yankFirmwareRelease(id);
|
||||||
|
audit(deps.repo, event as any, "firmware.yank", {
|
||||||
|
resource_type: "firmware_release",
|
||||||
|
resource_id: id,
|
||||||
|
});
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/firmware" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/firmware" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -207,6 +217,11 @@ export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void {
|
||||||
created_by: user.id ?? null,
|
created_by: user.id ?? null,
|
||||||
});
|
});
|
||||||
deps.repo.updateFirmwareRolloutState(rollout.id, "active");
|
deps.repo.updateFirmwareRolloutState(rollout.id, "active");
|
||||||
|
audit(deps.repo, event as any, "firmware.rollout.create", {
|
||||||
|
resource_type: "firmware_rollout",
|
||||||
|
resource_id: rollout.id,
|
||||||
|
metadata: { release_id: releaseId, percentage, target_count: targets.length },
|
||||||
|
});
|
||||||
// Bump every targeted kiosk to check now (best-effort over WS).
|
// Bump every targeted kiosk to check now (best-effort over WS).
|
||||||
const coord = getCoordinator();
|
const coord = getCoordinator();
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
import type {
|
import type {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeyScope,
|
ApiKeyScope,
|
||||||
|
AuditActorType,
|
||||||
|
AuditEntry,
|
||||||
|
AuditResult,
|
||||||
Camera,
|
Camera,
|
||||||
CameraStream,
|
CameraStream,
|
||||||
CameraType,
|
CameraType,
|
||||||
|
|
@ -266,6 +269,22 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rowToAuditEntry(r: Row): AuditEntry {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
ts: s(r["ts"]),
|
||||||
|
actor_type: s(r["actor_type"]) as AuditActorType,
|
||||||
|
actor_id: nn(r["actor_id"]),
|
||||||
|
actor_label: sn(r["actor_label"]),
|
||||||
|
action: s(r["action"]),
|
||||||
|
resource_type: sn(r["resource_type"]),
|
||||||
|
resource_id: sn(r["resource_id"]),
|
||||||
|
ip: sn(r["ip"]),
|
||||||
|
metadata: j<Record<string, unknown>>(r["metadata"], {}),
|
||||||
|
result: s(r["result"]) as AuditResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function rowToFirmwareRelease(r: Row): FirmwareRelease {
|
export function rowToFirmwareRelease(r: Row): FirmwareRelease {
|
||||||
return {
|
return {
|
||||||
id: s(r["id"]),
|
id: s(r["id"]),
|
||||||
|
|
|
||||||
|
|
@ -760,4 +760,25 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER");
|
addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER");
|
||||||
addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT");
|
addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Audit log -----------------------------------------------------------
|
||||||
|
// Append-only record of security-relevant actions: logins, API key use,
|
||||||
|
// kiosk pair/replace, firmware upload/yank/rollout, admin CRUD of any
|
||||||
|
// resource. Read-only via admin UI, filterable.
|
||||||
|
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
actor_type TEXT NOT NULL CHECK(actor_type IN ('user', 'api_key', 'system', 'kiosk')),
|
||||||
|
actor_id INTEGER,
|
||||||
|
actor_label TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result TEXT NOT NULL DEFAULT 'ok' CHECK(result IN ('ok', 'failed'))
|
||||||
|
) STRICT`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import { randomBytes } from "node:crypto";
|
||||||
import type {
|
import type {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeyScope,
|
ApiKeyScope,
|
||||||
|
AuditActorType,
|
||||||
|
AuditEntry,
|
||||||
|
AuditResult,
|
||||||
Camera,
|
Camera,
|
||||||
CameraStream,
|
CameraStream,
|
||||||
CameraType,
|
CameraType,
|
||||||
|
|
@ -47,6 +50,7 @@ import type {
|
||||||
} from "../../shared/types.js";
|
} from "../../shared/types.js";
|
||||||
import {
|
import {
|
||||||
rowToApiKey,
|
rowToApiKey,
|
||||||
|
rowToAuditEntry,
|
||||||
rowToCamera,
|
rowToCamera,
|
||||||
rowToCameraStream,
|
rowToCameraStream,
|
||||||
rowToDisplay,
|
rowToDisplay,
|
||||||
|
|
@ -1070,6 +1074,61 @@ export class Repository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// audit_log
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
insertAudit(input: {
|
||||||
|
actor_type: AuditActorType;
|
||||||
|
actor_id: number | null;
|
||||||
|
actor_label: string | null;
|
||||||
|
action: string;
|
||||||
|
resource_type: string | null;
|
||||||
|
resource_id: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
result: AuditResult;
|
||||||
|
}): void {
|
||||||
|
this.prep(
|
||||||
|
`INSERT INTO audit_log
|
||||||
|
(actor_type, actor_id, actor_label, action, resource_type,
|
||||||
|
resource_id, ip, metadata, result)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.actor_type,
|
||||||
|
input.actor_id,
|
||||||
|
input.actor_label,
|
||||||
|
input.action,
|
||||||
|
input.resource_type,
|
||||||
|
input.resource_id,
|
||||||
|
input.ip,
|
||||||
|
J(input.metadata),
|
||||||
|
input.result,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAudit(opts: {
|
||||||
|
limit?: number;
|
||||||
|
actor_type?: AuditActorType;
|
||||||
|
action_prefix?: string;
|
||||||
|
} = {}): AuditEntry[] {
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
|
||||||
|
const where: string[] = [];
|
||||||
|
const args: unknown[] = [];
|
||||||
|
if (opts.actor_type) {
|
||||||
|
where.push("actor_type = ?");
|
||||||
|
args.push(opts.actor_type);
|
||||||
|
}
|
||||||
|
if (opts.action_prefix) {
|
||||||
|
where.push("action LIKE ?");
|
||||||
|
args.push(`${opts.action_prefix}%`);
|
||||||
|
}
|
||||||
|
const sql = `SELECT * FROM audit_log ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY ts DESC LIMIT ?`;
|
||||||
|
args.push(limit);
|
||||||
|
const rs = this.db.prepare(sql).all(...(args as any[]));
|
||||||
|
return rs.map((r) => rowToAuditEntry(r as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// firmware_releases + firmware_rollouts
|
// firmware_releases + firmware_rollouts
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
75
server/src/shared/audit.ts
Normal file
75
server/src/shared/audit.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* Audit logging helper — single inline call from route handlers.
|
||||||
|
*
|
||||||
|
* audit(repo, event, "user.login", { result: "ok" });
|
||||||
|
* audit(repo, event, "firmware.upload", { resource_id: rel.id, metadata: { version, channel } });
|
||||||
|
*
|
||||||
|
* Pulls actor + ip out of the h3 event context. Never throws — logging
|
||||||
|
* failure must not break the caller's request.
|
||||||
|
*/
|
||||||
|
import type { Repository } from "../plugins/service-store/repository.js";
|
||||||
|
import type { AuditActorType, AuditResult } from "./types.js";
|
||||||
|
|
||||||
|
interface AuditCtx {
|
||||||
|
context?: {
|
||||||
|
user?: { id?: number; username?: string };
|
||||||
|
apiKeyPrefix?: string;
|
||||||
|
session?: unknown;
|
||||||
|
};
|
||||||
|
req?: { headers: { get(name: string): string | null } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditInput {
|
||||||
|
resource_type?: string;
|
||||||
|
resource_id?: string | number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
result?: AuditResult;
|
||||||
|
/** Override actor (e.g. when system performs action on behalf of nobody). */
|
||||||
|
actor_type?: AuditActorType;
|
||||||
|
actor_id?: number | null;
|
||||||
|
actor_label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audit(
|
||||||
|
repo: Repository,
|
||||||
|
event: AuditCtx | null,
|
||||||
|
action: string,
|
||||||
|
input: AuditInput = {},
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const ctx = event?.context;
|
||||||
|
let actor_type: AuditActorType = input.actor_type ?? "system";
|
||||||
|
let actor_id: number | null = input.actor_id ?? null;
|
||||||
|
let actor_label: string | null = input.actor_label ?? null;
|
||||||
|
|
||||||
|
if (!input.actor_type && ctx) {
|
||||||
|
if (ctx.apiKeyPrefix) {
|
||||||
|
actor_type = "api_key";
|
||||||
|
actor_label = ctx.apiKeyPrefix;
|
||||||
|
} else if (ctx.user) {
|
||||||
|
actor_type = "user";
|
||||||
|
actor_id = ctx.user.id ?? null;
|
||||||
|
actor_label = ctx.user.username ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = event?.req?.headers;
|
||||||
|
const ip = headers?.get("x-real-ip")
|
||||||
|
?? headers?.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
repo.insertAudit({
|
||||||
|
actor_type,
|
||||||
|
actor_id,
|
||||||
|
actor_label,
|
||||||
|
action,
|
||||||
|
resource_type: input.resource_type ?? null,
|
||||||
|
resource_id: input.resource_id != null ? String(input.resource_id) : null,
|
||||||
|
ip,
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
|
result: input.result ?? "ok",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* never throw from audit */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -222,6 +222,23 @@ export interface Kiosk {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuditActorType = "user" | "api_key" | "system" | "kiosk";
|
||||||
|
export type AuditResult = "ok" | "failed";
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
ts: string;
|
||||||
|
actor_type: AuditActorType;
|
||||||
|
actor_id: number | null;
|
||||||
|
actor_label: string | null;
|
||||||
|
action: string;
|
||||||
|
resource_type: string | null;
|
||||||
|
resource_id: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
result: AuditResult;
|
||||||
|
}
|
||||||
|
|
||||||
export type FirmwareChannel = "stable" | "beta" | "dev";
|
export type FirmwareChannel = "stable" | "beta" | "dev";
|
||||||
export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete";
|
export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { js } from "jsx-htmx";
|
import { js } from "jsx-htmx";
|
||||||
import { Layout } from "./layout.js";
|
import { Layout } from "./layout.js";
|
||||||
import type {
|
import type {
|
||||||
|
AuditEntry,
|
||||||
Camera,
|
Camera,
|
||||||
Display,
|
Display,
|
||||||
Entity,
|
Entity,
|
||||||
|
|
@ -2981,3 +2982,66 @@ export function FirmwareRolloutsPage(props: FirmwareRolloutsPageProps) {
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Audit log -------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AuditLogPageProps {
|
||||||
|
user: string;
|
||||||
|
entries: AuditEntry[];
|
||||||
|
filterAction?: string;
|
||||||
|
filterActorType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogPage(props: AuditLogPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout title="Audit log" user={props.user} activeNav="audit">
|
||||||
|
<p style="color:#666; margin-bottom:1rem">
|
||||||
|
Append-only record of admin + kiosk + system actions. Most recent first.
|
||||||
|
</p>
|
||||||
|
<form method="get" action="/admin/audit" style="display:flex; gap:0.5rem; margin-bottom:1rem">
|
||||||
|
<input type="text" name="action" placeholder="action prefix (e.g. firmware.)"
|
||||||
|
value={props.filterAction ?? ""} class="form-input" />
|
||||||
|
<select name="actor_type" class="form-input" style="max-width:200px">
|
||||||
|
<option value="" selected={!props.filterActorType}>any actor</option>
|
||||||
|
{(["user", "api_key", "kiosk", "system"] as const).map((t) => (
|
||||||
|
<option value={t} selected={props.filterActorType === t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn">Filter</button>
|
||||||
|
</form>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>IP</th><th>Result</th><th>Metadata</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.entries.length === 0 ? (
|
||||||
|
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No entries.</td></tr>
|
||||||
|
) : (
|
||||||
|
props.entries.map((e) => (
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:0.8rem; white-space:nowrap">{formatTime(e.ts)}</td>
|
||||||
|
<td style="font-size:0.85rem">
|
||||||
|
<span class="badge badge-gray">{e.actor_type}</span>
|
||||||
|
{e.actor_label && <span style="margin-left:0.25rem">{e.actor_label}</span>}
|
||||||
|
</td>
|
||||||
|
<td style="font-family:monospace; font-size:0.8rem">{e.action}</td>
|
||||||
|
<td style="font-size:0.85rem">{e.resource_type ? `${e.resource_type}#${e.resource_id ?? ""}` : ""}</td>
|
||||||
|
<td style="font-size:0.8rem">{e.ip ?? ""}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${e.result === "ok" ? "badge-green" : "badge-red"}`}>{e.result}</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.75rem; font-family:monospace; max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap" title={JSON.stringify(e.metadata)}>
|
||||||
|
{Object.keys(e.metadata).length === 0 ? "" : JSON.stringify(e.metadata)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
|
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||||
<hr />
|
<hr />
|
||||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue