Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.

`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.

Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.

#2939 §5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:19:41 -04:00
co-authored by Claude Opus 5.5
parent 043c87a8dc
commit 39b1ebae96
14 changed files with 509 additions and 21 deletions
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { formatShortDateTime } from "../notes/datetime";
// Admin: what has happened to accounts on this instance, newest first (#2939 §5).
// Kept for `audit_retention_days` (Settings → Security).
interface AuditEvent {
id: string;
at: string;
event: string;
email: string | null;
address: string | null;
detail: string | null;
}
// One phrase per event name in `audit.py`. A new event needs a line here.
const WORDS: Record<string, string> = {
sign_in: "Signed in",
sign_in_failed: "Sign-in failed",
registered: "Account created",
registration_refused: "Sign-up refused",
reset_requested: "Reset email asked for",
reset_link_made: "Reset link made",
password_reset: "Password reset",
reset_refused: "Reset link refused",
password_changed: "Password changed",
password_change_refused: "Password change refused",
signed_out_elsewhere: "Signed out elsewhere",
device_linked: "Device linked",
device_unlinked: "Device unlinked",
invite_created: "Invite made",
invite_revoked: "Invite revoked",
};
// Shown in red: something was tried and refused.
const REFUSALS = new Set([
"sign_in_failed",
"registration_refused",
"reset_refused",
"password_change_refused",
]);
const events = ref<AuditEvent[]>([]);
const more = ref(false);
const loading = ref(true);
const loadingMore = ref(false);
const error = ref("");
async function page(before?: string) {
const query = before ? `?before=${encodeURIComponent(before)}` : "";
return api.get<{ events: AuditEvent[]; more: boolean }>(`/api/accounts/activity${query}`);
}
async function load() {
error.value = "";
try {
const res = await page();
events.value = res.events;
more.value = res.more;
} catch (e) {
error.value = errorMessage(e, "Couldn't load activity.");
} finally {
loading.value = false;
}
}
async function loadMore() {
const last = events.value[events.value.length - 1];
if (!last) return;
loadingMore.value = true;
try {
const res = await page(last.at);
events.value.push(...res.events);
more.value = res.more;
} catch (e) {
error.value = errorMessage(e, "Couldn't load older activity.");
} finally {
loadingMore.value = false;
}
}
function where(e: AuditEvent): string {
return [formatShortDateTime(e.at), e.address, e.detail].filter(Boolean).join(" · ");
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Activity</h2>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
<p v-else-if="!events.length" class="text-sm text-neutral-400">Nothing yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="e in events"
:key="e.id"
class="rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
<span :class="REFUSALS.has(e.event) ? 'text-red-600 dark:text-red-400' : ''">
{{ WORDS[e.event] ?? e.event }}
</span>
<span v-if="e.email" class="font-normal text-neutral-500"> · {{ e.email }}</span>
</p>
<p class="truncate text-xs text-neutral-400">{{ where(e) }}</p>
</li>
</ul>
<button
v-if="more"
type="button"
class="self-start rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-neutral-700 hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800"
:disabled="loadingMore"
@click="loadMore"
>
Show older
</button>
</section>
</template>
+2
View File
@@ -8,6 +8,7 @@ import BaseButton from "../components/BaseButton.vue";
import PageHeader from "../components/PageHeader.vue";
import InviteList from "../components/InviteList.vue";
import AccountList from "../components/AccountList.vue";
import ActivityList from "../components/ActivityList.vue";
import GroupList from "../components/GroupList.vue";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
@@ -187,5 +188,6 @@ onMounted(load);
<InviteList v-if="items.length" class="mt-10" />
<AccountList v-if="items.length" class="mt-10" />
<GroupList v-if="items.length" class="mt-10" />
<ActivityList v-if="items.length" class="mt-10" />
</div>
</template>