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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/components/shell/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MessagesSquare,
MonitorDown,
PenLine,
ScrollText,
Send,
ShieldCheck,
Tags,
Expand All @@ -35,7 +36,16 @@ import type { Permission } from "@/server/permissions"
/** Every navigable dashboard route path, as TanStack `Link`'s `to` accepts it. */
export type DashboardPath = Extract<keyof FileRoutesByTo, `/dashboard${string}`>

export type ServiceId = "overview" | "telegram" | "whatsapp" | "m365" | "web" | "esign" | "reports" | "account"
export type ServiceId =
| "overview"
| "telegram"
| "whatsapp"
| "m365"
| "web"
| "esign"
| "reports"
| "audit"
| "account"

export type Section = {
id: string
Expand Down Expand Up @@ -91,6 +101,7 @@ export type PageMatch =
| { kind: "esign-setup" }
| { kind: "reports-open" }
| { kind: "reports-closed" }
| { kind: "audit-log" }
| { kind: "not-found" }

export type PageKind = PageMatch["kind"]
Expand Down Expand Up @@ -300,8 +311,25 @@ const reports: Service = {
],
}

const audit: Service = {
id: "audit",
title: "Audit log",
short: "Audit",
icon: ScrollText,
path: "/dashboard/audit-log",
sections: [
{
id: "audit-log",
title: "Audit log",
icon: ScrollText,
path: "/dashboard/audit-log",
permission: "audit:read",
},
],
}

/** Rail order: Overview (the logo), then the services with sections, then Account. */
export const services: Service[] = [overview, telegram, whatsapp, m365, web, esign, reports, account]
export const services: Service[] = [overview, telegram, whatsapp, m365, web, esign, reports, audit, account]

/** The services that own a panel, in rail order. */
export const panelServices: Service[] = services.filter((service) => service.sections.length > 0)
Expand Down Expand Up @@ -379,6 +407,8 @@ export function matchPath(pathname: string): PageMatch {
if (second === "resolved") return { kind: "reports-closed" }
}

if (head === "audit-log" && second === undefined) return { kind: "audit-log" }

return { kind: "not-found" }
}

Expand Down Expand Up @@ -419,6 +449,8 @@ export function serviceFor(match: PageMatch): Service {
case "reports-open":
case "reports-closed":
return reports
case "audit-log":
return audit
}
}

Expand Down Expand Up @@ -471,6 +503,8 @@ export function sectionFor(match: PageMatch): Section | null {
return section(reports, "open")
case "reports-closed":
return section(reports, "closed")
case "audit-log":
return section(audit, "audit-log")
}
}

Expand Down
151 changes: 151 additions & 0 deletions src/features/audit-log/audit-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { StatusTone } from "@/components/primitives/status-badge"
import type { AuditArea, AuditEvent, AuditOutcome } from "@/lib/api/types"

export const AREA_LABEL = {
tg: "Telegram",
wa: "WhatsApp",
web: "Web",
groups: "Labels",
azure: "Microsoft 365",
esign: "Signatures",
auth: "Accounts",
} satisfies Record<AuditArea, string>

export const OUTCOME = {
success: { label: "Done", tone: "success" },
failure: { label: "Failed", tone: "danger" },
denied: { label: "Denied", tone: "warning" },
} satisfies Record<AuditOutcome, { label: string; tone: StatusTone }>

const ACTIONS = new Map([
["tg.moderation.ban", "Banned"],
["tg.moderation.unban", "Unbanned"],
["tg.moderation.kick", "Kicked"],
["tg.moderation.mute", "Muted"],
["tg.moderation.unmute", "Unmuted"],
["tg.moderation.ban_all", "Banned from every group"],
["tg.moderation.unban_all", "Unbanned from every group"],
["tg.grants.create", "Created grant"],
["tg.grants.interrupt", "Interrupted grant"],
["tg.groups.create", "Synced groups"],
["tg.groups.delete", "Removed group"],
["tg.groups.setHide", "Changed group visibility"],
["tg.groups.leaveChat", "Left group"],
["tg.permissions.addRole", "Added role"],
["tg.permissions.removeRole", "Removed role"],
["tg.permissions.addGroup", "Made group admin"],
["tg.permissions.removeGroup", "Removed group admin"],
["wa.groups.add", "Added group"],
["wa.groups.modify", "Edited group"],
["wa.groups.delete", "Deleted group"],
["wa.groups.setHide", "Changed group visibility"],
["web.faqs.addFaqs", "Added FAQ"],
["web.faqs.addFaqsCategory", "Added FAQ category"],
["web.faqs.editFaqs", "Edited FAQ"],
["web.faqs.editFaqsCategory", "Edited FAQ category"],
["web.faqs.deleteFaqs", "Deleted FAQ"],
["web.faqs.deleteFaqsCategory", "Deleted FAQ category"],
["web.projects.addProject", "Added project"],
["web.projects.editProject", "Edited project"],
["web.projects.reorderProjects", "Reordered projects"],
["web.projects.deleteProject", "Deleted project"],
["web.associations.addAssociation", "Added association"],
["web.associations.editAssociation", "Edited association"],
["web.associations.editAssociationLinks", "Edited association links"],
["web.associations.deleteAssociation", "Deleted association"],
["web.guides_matricole.addGuide", "Added freshman guide"],
["web.guides_matricole.deleteGuide", "Deleted freshman guide"],
["web.reports.create", "Reported group"],
["web.reports.resolve", "Resolved reports"],
["web.reports.dismiss", "Dismissed reports"],
["groups.labels.create", "Created label"],
["groups.labels.modify", "Edited label"],
["groups.labels.delete", "Deleted label"],
["groups.labels.tagGroup", "Labelled group"],
["groups.labels.untagGroup", "Unlabelled group"],
["azure.members.create", "Created member"],
["azure.members.setAssocNumber", "Set member number"],
["azure.groups.addMember", "Added group member"],
["azure.groups.removeMember", "Removed group member"],
["esign.signers.resetIdentity", "Reset signer's CIE"],
["esign.drive.setAccess", "Changed drive access"],
["esign.documents.upload", "Uploaded document"],
["esign.documents.fromDrive", "Picked document from drive"],
["esign.requests.create", "Created signature request"],
["esign.requests.cancel", "Cancelled signature request"],
["esign.requests.delete", "Deleted signature request"],
["esign.requests.decline", "Declined signature request"],
["esign.requests.uploadSigned", "Uploaded signed document"],
["esign.requests.retryDelivery", "Retried delivery"],
["esign.jobs.create", "Started signature"],
["esign.jobs.cancel", "Cancelled signature"],
["auth.updateProfilePic", "Changed profile picture"],
])

const TARGET_TYPES = new Map([
["tg.user", "Telegram user"],
["tg.group", "Telegram group"],
["wa.group", "WhatsApp group"],
["web.faq", "FAQ"],
["web.faqCategory", "FAQ category"],
["web.project", "Project"],
["web.projects", "Projects"],
["web.association", "Association"],
["web.guide", "Freshman guide"],
["web.report", "Report"],
["groups.label", "Label"],
["azure.member", "Member"],
["azure.group", "Microsoft 365 group"],
["esign.signer", "Signer"],
["esign.drive", "Drive"],
["esign.document", "Document"],
["esign.request", "Signature request"],
["auth.user", "Account"],
])

/** "Edited FAQ"; actions without a name ("tg.groupLabels.create", the legacy mount) read from their path. */
export function actionLabel(action: string) {
const known = ACTIONS.get(action) ?? ACTIONS.get(action.replace(/^tg\.groupLabels\./, "groups.labels."))
if (known) return known
const words = (action.split(".").at(-1) ?? action)
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.toLowerCase()
return words.charAt(0).toUpperCase() + words.slice(1)
}

function isArea(value: string): value is AuditArea {
return Object.hasOwn(AREA_LABEL, value)
}

/** Where the change was made: "Telegram" for `tg.grants.create`. */
export function areaLabel(action: string) {
const area = action.split(".")[0] ?? action
return isArea(area) ? AREA_LABEL[area] : area

Check failure on line 124 in src/features/audit-log/audit-labels.ts

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ tg: string; wa: string; web: string; groups: string; azure: string; esign: string; auth: string; }'.
}

export function targetTypeLabel(type: string) {
return TARGET_TYPES.get(type) ?? type
}

/** The target's name, else its type and ID ("Telegram user 5021007919"); null when the event names no target. */
export function targetName(event: AuditEvent) {
if (event.targetType === null) return null
return event.targetLabel ?? `${targetTypeLabel(event.targetType)} ${event.targetId ?? ""}`.trim()
}

/** Who acted: their name, else what identifies them. */
export function actorName(event: AuditEvent) {
if (event.actorName) return event.actorName
if (event.actorKind === "service") return event.actorClient ?? "A service"
if (event.actorTgId !== null) return `User ${event.actorTgId}`
return event.actorSub ?? (event.actorKind === "legacy" ? "Unidentified legacy caller" : "Unknown")
}

/** How they acted: through which client, or the legacy path. */
export function actorVia(event: AuditEvent) {
if (event.actorKind === "legacy") return "Legacy call without a token"
if (event.actorKind === "telegram") return `On Telegram, through ${event.actorClient ?? "the bot"}`
if (event.actorKind === "service") return "Service"
return event.actorClient === "admin-dashboard" ? "Dashboard" : (event.actorClient ?? null)
}
Loading
Loading