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
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:
@@ -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>
|
||||
Reference in New Issue
Block a user