feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done, and nobody could ask it: `ListActivity` takes one project id, and an administrator is not necessarily a member of the project they need to look into. Paged with a keyset cursor rather than an offset. The log is written to constantly and read rarely, so a page fetched a minute after the one before it would be shifted by everything written in between — one row shown twice and the row behind it skipped, which is the single failure an audit log must not have. Both halves of the cursor matter: the log is written in whole seconds, so a timestamp alone cannot separate two rows written in the same one. Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it to `created_by`. A log whose rows vanish with the account they describe answers "what has happened here" with everything except the part somebody had a reason to remove. `project_id` keeps cascading on purpose: the trash is the undo, and past it the deletion was deliberate. The event names moved to `web/src/lib/events.js`, because two surfaces read the same log now and two copies of nineteen strings is two places for them to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ebdc8eacf2
commit
85f7b2488d
@@ -160,6 +160,9 @@ export const api = {
|
||||
logout: () => post('/auth/logout'),
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||
@@ -180,6 +183,33 @@ export const api = {
|
||||
revokeInvite: (id) => del(`/invites/${id}`),
|
||||
listErrors: () => get('/errors'),
|
||||
|
||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||
// The OAuth client itself, which is the instance's registration with Google
|
||||
// rather than anybody's mailbox.
|
||||
gmailClient: () => get('/gmail/client'),
|
||||
setGmailClient: (data) => put('/gmail/client', data),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
// A link rather than a fetch: the browser's own download handles a file of
|
||||
// this size, and a blob in memory of the whole database does not.
|
||||
backupURL: (id) => `/api/v1/backups/${id}`,
|
||||
|
||||
// The instance-wide history, which is the other half of the error log: that
|
||||
// one is what broke, this is what was done. Paged with the cursor the server
|
||||
// hands back rather than an offset — see the OpenAPI note on /activity.
|
||||
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (before) q.set('before', before);
|
||||
if (user_id) q.set('user_id', user_id);
|
||||
if (project_id) q.set('project_id', project_id);
|
||||
if (event) q.set('event', event);
|
||||
if (limit) q.set('limit', limit);
|
||||
const query = q.toString();
|
||||
return get(`/activity${query ? `?${query}` : ''}`);
|
||||
},
|
||||
auditEvents: () => get('/activity/events'),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* What each recorded activity event is called.
|
||||
*
|
||||
* The keys are the server's and they are stable; this is the only place they
|
||||
* become Danish. It lives in its own module because two surfaces read the same log
|
||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
||||
*
|
||||
* An unknown key falls through to the key itself rather than being hidden: a build
|
||||
* that records something this table has not learned yet should still show that it
|
||||
* happened.
|
||||
*/
|
||||
export const EVENTS = {
|
||||
'project.created': 'oprettede projektet',
|
||||
'project.updated': 'ændrede projektet',
|
||||
'project.deleted': 'slettede projektet',
|
||||
'project.imported': 'importerede projektet',
|
||||
'section.created': 'oprettede en sektion',
|
||||
'section.updated': 'ændrede en sektion',
|
||||
'section.deleted': 'slettede en sektion',
|
||||
'member.invited': 'inviterede',
|
||||
'member.added': 'tilføjede',
|
||||
'member.removed': 'fjernede',
|
||||
'member.role_changed': 'ændrede rollen for',
|
||||
'task.created': 'oprettede en opgave',
|
||||
'task.updated': 'ændrede en opgave',
|
||||
'task.completed': 'lukkede en opgave',
|
||||
'task.reopened': 'genåbnede en opgave',
|
||||
'task.moved': 'flyttede en opgave',
|
||||
'task.deleted': 'slettede en opgave',
|
||||
'task.split': 'delte en opgave op',
|
||||
'comment.created': 'skrev en kommentar'
|
||||
};
|
||||
|
||||
/** The name of an event, or the raw key when it is one this build does not know. */
|
||||
export function eventName(event) {
|
||||
return EVENTS[event] ?? event;
|
||||
}
|
||||
|
||||
/** The bit of context an entry carries, when it has one worth reading. */
|
||||
export function eventDetail(entry) {
|
||||
const p = entry.payload ?? {};
|
||||
return p.name ?? p.email ?? p.role ?? '';
|
||||
}
|
||||
Reference in New Issue
Block a user