Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d9c8e097 | ||
|
|
22cfdbede0 |
No files matched your search
@@ -18,6 +18,8 @@ All modules from the original plan are built:
|
|||||||
|
|
||||||
- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
|
- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
|
||||||
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
||||||
|
(changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
|
||||||
|
automatic trimming — attributed to "system"; settings changes show what changed, but never credentials)
|
||||||
- **Dashboard** — an overview of every system this app tracks, all sharing
|
- **Dashboard** — an overview of every system this app tracks, all sharing
|
||||||
one widget-card design (label + status badge, a small stat row, then its
|
one widget-card design (label + status badge, a small stat row, then its
|
||||||
own breakdown): DNS (domain/record counts per provider, cached records by
|
own breakdown): DNS (domain/record counts per provider, cached records by
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as client from "openid-client";
|
import * as client from "openid-client";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { getOidcConfig } from "./oidc.js";
|
import { getOidcConfig } from "./oidc.js";
|
||||||
import { upsertUserFromLogin } from "./users.js";
|
import { upsertUserFromLogin } from "./users.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { users } from "../db/schema.js";
|
||||||
|
import { recordAudit } from "../services/audit.js";
|
||||||
|
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
||||||
@@ -63,7 +67,28 @@ authRouter.get("/callback", async (req, res, next) => {
|
|||||||
// fall back to ID token claims already captured above
|
// fall back to ID token claims already captured above
|
||||||
}
|
}
|
||||||
|
|
||||||
await upsertUserFromLogin({ sub: claims.sub, email, name });
|
const { user, created } = await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||||
|
|
||||||
|
// Access to the app is itself something worth being able to look back on: who got an account (and the very first
|
||||||
|
// one becomes admin), and every sign-in with where it came from.
|
||||||
|
if (created) {
|
||||||
|
await recordAudit({
|
||||||
|
actor: user,
|
||||||
|
category: "user",
|
||||||
|
action: "create",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: user.id,
|
||||||
|
detail: { name: user.name ?? user.email ?? user.oidcSub, role: user.role, firstUser: user.role === "admin" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await recordAudit({
|
||||||
|
actor: user,
|
||||||
|
category: "session",
|
||||||
|
action: "login",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: user.id,
|
||||||
|
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||||
|
});
|
||||||
|
|
||||||
delete req.session.pendingAuth;
|
delete req.session.pendingAuth;
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
@@ -85,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
|
|||||||
|
|
||||||
authRouter.get("/logout", async (req, res, next) => {
|
authRouter.get("/logout", async (req, res, next) => {
|
||||||
const idToken = req.session.user?.idToken;
|
const idToken = req.session.user?.idToken;
|
||||||
|
|
||||||
|
// Recorded first, and never allowed to get in the way of signing out.
|
||||||
|
if (req.session.user) {
|
||||||
|
try {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.oidcSub, req.session.user.sub)).limit(1);
|
||||||
|
if (user) {
|
||||||
|
await recordAudit({
|
||||||
|
actor: user,
|
||||||
|
category: "session",
|
||||||
|
action: "logout",
|
||||||
|
targetType: "user",
|
||||||
|
targetId: user.id,
|
||||||
|
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[auth] couldn't record sign-out:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getOidcConfig();
|
const config = await getOidcConfig();
|
||||||
let endSessionUrl: URL | undefined;
|
let endSessionUrl: URL | undefined;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
|
|||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}) {
|
}): Promise<{ user: typeof users.$inferSelect; created: boolean }> {
|
||||||
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, existing.id))
|
.where(eq(users.id, existing.id))
|
||||||
.returning();
|
.returning();
|
||||||
return updated;
|
return { user: updated, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||||
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
|
|||||||
lastLoginAt: now,
|
lastLoginAt: now,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return created;
|
return { user: created, created: true };
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
|
|||||||
|
|
||||||
auditLogRouter.get("/", asyncHandler(async (req, res) => {
|
auditLogRouter.get("/", asyncHandler(async (req, res) => {
|
||||||
const limit = Math.min(Number(req.query.limit ?? 200), 500);
|
const limit = Math.min(Number(req.query.limit ?? 200), 500);
|
||||||
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
|
// createdAt only has one-second resolution, so entries made within the same second are ordered by id.
|
||||||
|
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt), desc(auditLog.id)).limit(limit);
|
||||||
res.json({ entries: rows });
|
res.json({ entries: rows });
|
||||||
}));
|
}));
|
||||||
@@ -60,6 +60,14 @@ domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (re
|
|||||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||||
const row = await checkDomain(id);
|
const row = await checkDomain(id);
|
||||||
if (!row) return res.status(404).json({ error: "not_found" });
|
if (!row) return res.status(404).json({ error: "not_found" });
|
||||||
|
await recordAudit({
|
||||||
|
actor: req.currentUser!,
|
||||||
|
category: "domain",
|
||||||
|
action: "check",
|
||||||
|
targetType: "domain",
|
||||||
|
targetId: id,
|
||||||
|
detail: { name: row.name, error: row.lastCheckError },
|
||||||
|
});
|
||||||
const { healthChecks } = await getSettings();
|
const { healthChecks } = await getSettings();
|
||||||
res.json({ domain: present(row, healthChecks.domainWarnDays) });
|
res.json({ domain: present(row, healthChecks.domainWarnDays) });
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -139,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
|||||||
let credentialId = existing.credentialId;
|
let credentialId = existing.credentialId;
|
||||||
let configJson = existing.config;
|
let configJson = existing.config;
|
||||||
let baseUrl = existing.baseUrl;
|
let baseUrl = existing.baseUrl;
|
||||||
|
// For the audit entry: what this edit actually changed. Names only for settings (operators can read the audit
|
||||||
|
// log but not an integration's config), and never anything about the credentials beyond "they were replaced".
|
||||||
|
let credentialsReplaced = false;
|
||||||
|
let fieldsChanged: string[] = [];
|
||||||
|
|
||||||
if (parsed.data.config) {
|
if (parsed.data.config) {
|
||||||
const loaded = await loadIntegrationConfig(id);
|
const loaded = await loadIntegrationConfig(id);
|
||||||
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
|
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
|
||||||
|
credentialsReplaced = Object.keys(secretFields).length > 0;
|
||||||
|
fieldsChanged = Object.keys(nonSecretFields).filter(
|
||||||
|
(key) => String(loaded?.config[key] ?? "") !== String(nonSecretFields[key] ?? ""),
|
||||||
|
);
|
||||||
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
|
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
|
||||||
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
|
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
|
||||||
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
||||||
@@ -188,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
|||||||
action: "update",
|
action: "update",
|
||||||
targetType: "integration",
|
targetType: "integration",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
detail: { name: updated.name },
|
detail: {
|
||||||
|
name: updated.name,
|
||||||
|
...(existing.name !== updated.name ? { renamedFrom: existing.name } : {}),
|
||||||
|
...(existing.enabled !== updated.enabled ? { enabled: updated.enabled } : {}),
|
||||||
|
credentialsReplaced,
|
||||||
|
fieldsChanged,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
|
|||||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||||
if (!server) return res.status(404).json({ error: "not_found" });
|
if (!server) return res.status(404).json({ error: "not_found" });
|
||||||
|
|
||||||
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
|
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
|
||||||
@@ -352,7 +352,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
|
|||||||
action: "add_link",
|
action: "add_link",
|
||||||
targetType: "server",
|
targetType: "server",
|
||||||
targetId: serverId,
|
targetId: serverId,
|
||||||
detail: { label: created.label, url: created.url },
|
detail: { name: server.name, label: created.label, url: created.url },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
|
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
|
||||||
@@ -375,13 +375,14 @@ serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(
|
|||||||
.returning();
|
.returning();
|
||||||
if (!updated) return res.status(404).json({ error: "not_found" });
|
if (!updated) return res.status(404).json({ error: "not_found" });
|
||||||
|
|
||||||
|
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
actor: req.currentUser!,
|
actor: req.currentUser!,
|
||||||
category: "server",
|
category: "server",
|
||||||
action: "update_link",
|
action: "update_link",
|
||||||
targetType: "server",
|
targetType: "server",
|
||||||
targetId: serverId,
|
targetId: serverId,
|
||||||
detail: { label: updated.label, url: updated.url },
|
detail: { name: owner?.name, label: updated.label, url: updated.url },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
|
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
|
||||||
@@ -398,13 +399,14 @@ serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler
|
|||||||
.returning();
|
.returning();
|
||||||
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
|
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
|
||||||
|
|
||||||
|
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
|
||||||
await recordAudit({
|
await recordAudit({
|
||||||
actor: req.currentUser!,
|
actor: req.currentUser!,
|
||||||
category: "server",
|
category: "server",
|
||||||
action: "remove_link",
|
action: "remove_link",
|
||||||
targetType: "server",
|
targetType: "server",
|
||||||
targetId: serverId,
|
targetId: serverId,
|
||||||
detail: { label: deleted[0].label },
|
detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||||
import { recordAudit } from "../services/audit.js";
|
import { recordAudit } from "../services/audit.js";
|
||||||
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
import { getSettings, updateSettings } from "../services/settingsStore.js";
|
||||||
|
import { describeSettingsChanges } from "../services/settingsDiff.js";
|
||||||
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
|
||||||
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
|
||||||
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
|
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
|
||||||
@@ -106,6 +107,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
|||||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const before = await getSettings();
|
||||||
const updated = await updateSettings(parsed.data);
|
const updated = await updateSettings(parsed.data);
|
||||||
|
|
||||||
if (parsed.data.notifications) {
|
if (parsed.data.notifications) {
|
||||||
@@ -127,7 +129,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
|
|||||||
category: "settings",
|
category: "settings",
|
||||||
action: "update",
|
action: "update",
|
||||||
targetType: "settings",
|
targetType: "settings",
|
||||||
detail: { sections: Object.keys(parsed.data) },
|
detail: { sections: Object.keys(parsed.data), changes: describeSettingsChanges(before, parsed.data) },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ settings: updated });
|
res.json({ settings: updated });
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
|
|||||||
|
|
||||||
type CurrentUser = typeof users.$inferSelect;
|
type CurrentUser = typeof users.$inferSelect;
|
||||||
|
|
||||||
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */
|
/** Who an automatic, no-one-clicked-anything entry is attributed to. */
|
||||||
|
export const SYSTEM_ACTOR_LABEL = "system";
|
||||||
|
|
||||||
|
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. Leave `actor` out for something the app did by itself. */
|
||||||
export async function recordAudit(params: {
|
export async function recordAudit(params: {
|
||||||
actor: CurrentUser;
|
actor?: CurrentUser;
|
||||||
category: string;
|
category: string;
|
||||||
action: string;
|
action: string;
|
||||||
targetType?: string;
|
targetType?: string;
|
||||||
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
|
|||||||
detail?: unknown;
|
detail?: unknown;
|
||||||
}) {
|
}) {
|
||||||
await db.insert(auditLog).values({
|
await db.insert(auditLog).values({
|
||||||
actorUserId: params.actor.id,
|
actorUserId: params.actor?.id,
|
||||||
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
|
actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
|
||||||
category: params.category,
|
category: params.category,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
targetType: params.targetType,
|
targetType: params.targetType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||||
import { purgeOldLogs } from "./logRetention.js";
|
import { purgeOldLogs } from "./logRetention.js";
|
||||||
|
import { recordAudit } from "./audit.js";
|
||||||
|
|
||||||
const LAST_RUN_FLAG = "logRetentionLastRunAt";
|
const LAST_RUN_FLAG = "logRetentionLastRunAt";
|
||||||
|
|
||||||
@@ -9,6 +10,12 @@ async function runPurge(): Promise<void> {
|
|||||||
const result = await purgeOldLogs(logRetention.retentionDays);
|
const result = await purgeOldLogs(logRetention.retentionDays);
|
||||||
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
|
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
|
||||||
if (result.diagDeleted || result.auditDeleted) {
|
if (result.diagDeleted || result.auditDeleted) {
|
||||||
|
// Trimming the audit log is itself something to be able to look back on — attributed to the system, since no one asked for it.
|
||||||
|
await recordAudit({
|
||||||
|
category: "settings",
|
||||||
|
action: "purge_logs",
|
||||||
|
detail: { automatic: true, retentionDays: logRetention.retentionDays, ...result },
|
||||||
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
|
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* What a settings update actually changed, in a form fit for the audit log.
|
||||||
|
*
|
||||||
|
* The audit log can be read by operators, but Settings can't — so values only go in for sections an operator could
|
||||||
|
* already see in the app. The notification channels (Gotify, ntfy, SMTP, webhook) hold credentials, and even their
|
||||||
|
* addresses can act as one (a webhook URL carries its own token; a public ntfy topic is the only thing protecting
|
||||||
|
* it), so for those only the names of the fields that changed are recorded, never what they changed to or from.
|
||||||
|
*/
|
||||||
|
const NAMES_ONLY_SECTIONS = new Set(["gotify", "ntfy", "smtp", "webhook"]);
|
||||||
|
|
||||||
|
/** Anything longer than this isn't a useful thing to read in a table cell. */
|
||||||
|
const MAX_VALUE_JSON = 300;
|
||||||
|
|
||||||
|
export type SettingsChange = { from: unknown; to: unknown } | "(changed)";
|
||||||
|
|
||||||
|
function same(a: unknown, b: unknown): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(value: unknown): unknown {
|
||||||
|
return value !== undefined && JSON.stringify(value)?.length > MAX_VALUE_JSON ? "(too long to show)" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares each section in `patch` with what was stored before. Only fields that really differ are listed, and a
|
||||||
|
* section where nothing differed is left out entirely.
|
||||||
|
*/
|
||||||
|
export function describeSettingsChanges(before: object, patch: object): Record<string, Record<string, SettingsChange>> {
|
||||||
|
const out: Record<string, Record<string, SettingsChange>> = {};
|
||||||
|
for (const [section, incoming] of Object.entries(patch)) {
|
||||||
|
if (incoming === null || typeof incoming !== "object") continue;
|
||||||
|
const previous = ((before as Record<string, unknown>)[section] ?? {}) as Record<string, unknown>;
|
||||||
|
const changes: Record<string, SettingsChange> = {};
|
||||||
|
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
|
||||||
|
if (same(previous[key], value)) continue;
|
||||||
|
changes[key] = NAMES_ONLY_SECTIONS.has(section) ? "(changed)" : { from: capture(previous[key]), to: capture(value) };
|
||||||
|
}
|
||||||
|
if (Object.keys(changes).length > 0) out[section] = changes;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type AuditLogEntry } from "../api/client";
|
import { api, type AuditLogEntry } from "../api/client";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||||
import { useSortable } from "../hooks/useSortable";
|
import { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { usePagination } from "../hooks/usePagination";
|
import { usePagination } from "../hooks/usePagination";
|
||||||
@@ -23,6 +23,22 @@ function targetLabel(e: AuditLogEntry): string {
|
|||||||
return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`;
|
return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What was done, beyond the target — the link's label and URL, a scan's address and range, and so on. The name is already in the Target column. */
|
||||||
|
function detailSummary(e: AuditLogEntry): string {
|
||||||
|
if (!e.detail) return "";
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(e.detail);
|
||||||
|
} catch {
|
||||||
|
return e.detail;
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return String(parsed);
|
||||||
|
return Object.entries(parsed as Record<string, unknown>)
|
||||||
|
.filter(([key, value]) => key !== "name" && value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && value.length === 0))
|
||||||
|
.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuditLog() {
|
export default function AuditLog() {
|
||||||
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
|
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -41,8 +57,8 @@ export default function AuditLog() {
|
|||||||
if (!sorted) return;
|
if (!sorted) return;
|
||||||
downloadCsv(
|
downloadCsv(
|
||||||
"audit-log.csv",
|
"audit-log.csv",
|
||||||
["When", "Actor", "Category", "Action", "Target"],
|
["When", "Actor", "Category", "Action", "Target", "Details"],
|
||||||
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]),
|
sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e), detailSummary(e)]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,23 +81,27 @@ export default function AuditLog() {
|
|||||||
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
|
||||||
|
<th>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pageItems?.map((e) => (
|
{pageItems?.map((e) => (
|
||||||
<tr key={e.id}>
|
<tr key={e.id}>
|
||||||
<td>{formatDateTime(new Date(e.createdAt))}</td>
|
<td>{formatDateTime(parseDbTimestamp(e.createdAt))}</td>
|
||||||
<td>{e.actorLabel ?? "—"}</td>
|
<td>{e.actorLabel ?? "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge bg-blue-lt">{e.category}</span>
|
<span className="badge bg-blue-lt">{e.category}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{e.action}</td>
|
<td>{e.action}</td>
|
||||||
<td>{targetLabel(e)}</td>
|
<td>{targetLabel(e)}</td>
|
||||||
|
<td className="text-secondary small text-break" style={{ maxWidth: 420 }}>
|
||||||
|
{detailSummary(e) || "—"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sorted?.length === 0 && (
|
{sorted?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-secondary text-center">
|
<td colSpan={6} className="text-secondary text-center">
|
||||||
No activity recorded yet.
|
No activity recorded yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client";
|
import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||||
import { formatAgo } from "../utils/duration";
|
import { formatAgo } from "../utils/duration";
|
||||||
import { readableError } from "../utils/errors";
|
import { readableError } from "../utils/errors";
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ Range (a network like 192.168.16.0/20, or a single address):`,
|
|||||||
<div className="text-secondary small">
|
<div className="text-secondary small">
|
||||||
{i.reason ? `${i.reason} · ` : ""}
|
{i.reason ? `${i.reason} · ` : ""}
|
||||||
{i.createdBy ? `${i.createdBy}, ` : ""}
|
{i.createdBy ? `${i.createdBy}, ` : ""}
|
||||||
{formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))}
|
{formatDateTime(parseDbTimestamp(i.createdAt))}
|
||||||
{!i.stillPresent && " · no longer occurring"}
|
{!i.stillPresent && " · no longer occurring"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type DiagLogEntry } from "../api/client";
|
import { api, type DiagLogEntry } from "../api/client";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime, parseDbTimestamp } from "../utils/date";
|
||||||
import { useSortable } from "../hooks/useSortable";
|
import { useSortable } from "../hooks/useSortable";
|
||||||
import SortableTh from "../components/SortableTh";
|
import SortableTh from "../components/SortableTh";
|
||||||
import { downloadCsv } from "../utils/csv";
|
import { downloadCsv } from "../utils/csv";
|
||||||
@@ -84,7 +84,7 @@ export default function DiagLog() {
|
|||||||
downloadCsv(
|
downloadCsv(
|
||||||
"diagnostic-log.csv",
|
"diagnostic-log.csv",
|
||||||
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"],
|
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"],
|
||||||
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
|
sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export default function DiagLog() {
|
|||||||
{sorted?.map((e) => (
|
{sorted?.map((e) => (
|
||||||
<tr key={e.id}>
|
<tr key={e.id}>
|
||||||
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
|
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
|
||||||
{formatDateTime(new Date(e.createdAt))}
|
{formatDateTime(parseDbTimestamp(e.createdAt))}
|
||||||
</td>
|
</td>
|
||||||
<td>{SOURCE_LABELS[e.source] ?? e.source}</td>
|
<td>{SOURCE_LABELS[e.source] ?? e.source}</td>
|
||||||
<td className="text-secondary">{e.operation}</td>
|
<td className="text-secondary">{e.operation}</td>
|
||||||
|
|||||||
@@ -163,7 +163,10 @@ export default function Privacy() {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Audit log</td>
|
<td>Audit log</td>
|
||||||
<td>Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.</td>
|
<td>
|
||||||
|
Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.
|
||||||
|
Also each time you sign in or out — with the IP address you came from — and when your account was first created.
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{retention?.enabled
|
{retention?.enabled
|
||||||
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
|
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
|
||||||
@@ -288,7 +291,7 @@ export default function Privacy() {
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<ul className="mb-0">
|
<ul className="mb-0">
|
||||||
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
|
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
|
||||||
<li className="mb-2">Operators and admins: also the audit log — who did what.</li>
|
<li className="mb-2">Operators and admins: also the audit log — who did what, and who signed in from where.</li>
|
||||||
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
|
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export function is24HourFormat(): boolean {
|
|||||||
return current.timeFormat === "24h";
|
return current.timeFormat === "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a timestamp the server stored. SQLite's own `current_timestamp` ("2026-10-02 20:27:55") is UTC but carries no
|
||||||
|
* zone marker, and `new Date()` would read that as local time — showing every entry shifted by the viewer's UTC offset.
|
||||||
|
* Timestamps the app wrote itself are ISO strings with a zone and parse as they are.
|
||||||
|
*/
|
||||||
|
export function parseDbTimestamp(value: string): Date {
|
||||||
|
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
|
||||||
|
}
|
||||||
|
|
||||||
function pad(n: number): string {
|
function pad(n: number): string {
|
||||||
return String(n).padStart(2, "0");
|
return String(n).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user