diff --git a/internal/httpapi/audit_handlers.go b/internal/httpapi/audit_handlers.go new file mode 100644 index 0000000..00891d1 --- /dev/null +++ b/internal/httpapi/audit_handlers.go @@ -0,0 +1,111 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/store" +) + +// What has happened on this server, across every project. +// +// The per-project history under /projects/{id}/activity is the same rows read by +// the people who work in that project. This is the administrator's version of the +// question, and it is a different one: an administrator is not necessarily a member +// of the project they need to look into, and "which account did that" cannot be +// asked one project at a time. +// +// It sits beside the error log for a reason. That one is the half about what broke; +// this is the half about what was done, and an instance had only ever had the first. + +type auditEntryJSON struct { + ID string `json:"id"` + At string `json:"at"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + TaskID string `json:"task_id,omitempty"` + // UserID and UserName are absent when the account has been deleted. The row + // stays — an audit log that lost its rows when somebody left would be missing + // exactly the part worth keeping. + UserID string `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + Event string `json:"event"` + Payload map[string]any `json:"payload,omitempty"` +} + +func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := store.AuditFilter{ + UserID: q.Get("user_id"), + ProjectID: q.Get("project_id"), + Event: q.Get("event"), + Limit: parseLimit(q.Get("limit"), 100, 500), + } + if cursor := q.Get("before"); cursor != "" { + at, id, ok := parseAuditCursor(cursor) + if !ok { + writeError(w, http.StatusBadRequest, CodeBadRequest, + "before must be :, as next_cursor returns it") + return + } + f.BeforeAt, f.BeforeID = at, id + } + + entries, err := s.db.ListAuditLog(r.Context(), f) + if err != nil { + s.internal(w, r, "list audit log", err) + return + } + + out := make([]auditEntryJSON, 0, len(entries)) + for _, a := range entries { + out = append(out, auditEntryJSON{ + ID: a.ID, At: a.CreatedAt.Format(time.RFC3339), + ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID, + UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload, + }) + } + + body := map[string]any{"activity": out} + // A cursor only when the page was full. A short page is the end of the log, and + // a cursor there invites one more request that can only come back empty. + if len(entries) == f.Limit && f.Limit > 0 { + last := entries[len(entries)-1] + body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID + } + writeJSON(w, http.StatusOK, body) +} + +// handleAuditEvents is what the event filter is drawn from: the names that occur, +// with counts. Offered rather than typed — "member.role_changed" is an internal +// vocabulary, and a text field for it is a field that returns nothing until you +// have read the source. +func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) { + events, err := s.db.AuditEvents(r.Context()) + if err != nil { + s.internal(w, r, "list audit events", err) + return + } + out := make([]map[string]any, 0, len(events)) + for _, e := range events { + out = append(out, map[string]any{"event": e.Event, "count": e.Count}) + } + writeJSON(w, http.StatusOK, map[string]any{"events": out}) +} + +// parseAuditCursor reads ":" — the pair the keyset walk needs. Both +// halves are required: a cursor carrying only a timestamp cannot separate two rows +// written in the same second, which is the case it exists for. +func parseAuditCursor(s string) (int64, string, bool) { + at, id, found := strings.Cut(s, ":") + if !found || id == "" { + return 0, "", false + } + unix, err := strconv.ParseInt(at, 10, 64) + if err != nil || unix <= 0 { + return 0, "", false + } + return unix, id, true +} diff --git a/internal/httpapi/audit_test.go b/internal/httpapi/audit_test.go new file mode 100644 index 0000000..30454f4 --- /dev/null +++ b/internal/httpapi/audit_test.go @@ -0,0 +1,264 @@ +package httpapi + +import ( + "fmt" + "net/http" + "testing" +) + +// The point of the instance-wide log is that it crosses projects the caller is not +// a member of. The per-project endpoint cannot answer this question, and an +// administrator asking "what has happened on this server" was previously told to +// go and look in each project they happen to belong to. +func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + // A project the administrator is not a member of. + _, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"}) + theirID := theirs["id"].(string) + other.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "noget i deres eget", "project_id": theirID, + }) + + // The administrator cannot read that project's own history at all. + if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound { + t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode) + } + + // The instance log has it. + resp, body := ts.do(t, "GET", "/api/v1/activity", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("audit log: status %d", resp.StatusCode) + } + entries, _ := body["activity"].([]any) + var found map[string]any + for _, raw := range entries { + e := raw.(map[string]any) + if e["project_id"] == theirID && e["event"] == "task.created" { + found = e + } + } + if found == nil { + t.Fatalf("nothing from a project the admin is not in: %v", entries) + } + if found["project_name"] != "Deres eget" { + t.Errorf("project_name = %v — a row that does not name its project says nothing", + found["project_name"]) + } + if found["user_name"] != "Anden" { + t.Errorf("user_name = %v", found["user_name"]) + } +} + +// Administrators only, and sessions only. An audit log readable with a bearer token +// would let a stolen token read the record of the theft — and of everything else on +// the instance. +func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} { + if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden { + t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode) + } + } + + admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token := ts.apiToken(t, admin.ID) + for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} { + req, _ := http.NewRequest("GET", ts.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s with an admin's API token: status %d, want 403", path, resp.StatusCode) + } + } +} + +// The cursor is keyed on (created_at, id) because the log is written in whole +// seconds. A page boundary that landed inside a busy second would, with a +// timestamp-only cursor, either repeat those rows or skip them — and an audit log +// that skips rows is worse than no audit log. +func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"}) + projectID := project["id"].(string) + // Written as fast as the loop goes, so they share timestamps. + const written = 25 + for i := range written { + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": fmt.Sprintf("opgave %d", i), "project_id": projectID, + }) + } + + seen := map[string]bool{} + cursor := "" + for page := 0; page < 20; page++ { + path := "/api/v1/activity?limit=4" + if cursor != "" { + path += "&before=" + cursor + } + resp, body := ts.do(t, "GET", path, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("page %d: status %d", page, resp.StatusCode) + } + entries, _ := body["activity"].([]any) + for _, raw := range entries { + id := raw.(map[string]any)["id"].(string) + if seen[id] { + t.Fatalf("row %s came back on two pages", id) + } + seen[id] = true + } + next, _ := body["next_cursor"].(string) + if next == "" { + break + } + if next == cursor { + t.Fatalf("the cursor did not move: %s", next) + } + cursor = next + } + + // Everything that was written is in the walk exactly once: the task creations, + // plus the project creation. + if len(seen) != written+1 { + t.Errorf("the walk saw %d rows, want %d — a page boundary lost some", + len(seen), written+1) + } +} + +// The filters, and the list the event filter is drawn from. +func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk") + if err != nil { + t.Fatal(err) + } + + _, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"}) + mineID := mine["id"].(string) + ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID}) + + _, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"}) + theirID := theirs["id"].(string) + other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID}) + + _, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil) + for _, raw := range byUser["activity"].([]any) { + if got := raw.(map[string]any)["user_id"]; got != otherUser.ID { + t.Errorf("user_id filter returned a row by %v", got) + } + } + if len(byUser["activity"].([]any)) == 0 { + t.Error("the user filter returned nothing") + } + + _, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil) + for _, raw := range byProject["activity"].([]any) { + if got := raw.(map[string]any)["project_id"]; got != mineID { + t.Errorf("project_id filter returned a row from %v", got) + } + } + + _, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil) + rows := byEvent["activity"].([]any) + if len(rows) != 2 { + t.Errorf("event filter found %d project.created rows, want 2", len(rows)) + } + for _, raw := range rows { + if got := raw.(map[string]any)["event"]; got != "project.created" { + t.Errorf("event filter returned %v", got) + } + } + + // And the vocabulary the filter is built from. + _, events := ts.do(t, "GET", "/api/v1/activity/events", nil) + counts := map[string]float64{} + for _, raw := range events["events"].([]any) { + e := raw.(map[string]any) + counts[e["event"].(string)] = e["count"].(float64) + } + if counts["project.created"] != 2 || counts["task.created"] != 2 { + t.Errorf("event counts = %v", counts) + } + + // A malformed cursor is a 400, not a silent first page: paging that quietly + // restarts is how an audit walk reads the same rows forever. + if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest { + t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode) + } +} + +// A deleted account leaves its trail behind. This is the reason migration 0009 +// exists: an audit log that lost its rows when somebody left would be missing +// exactly the part worth keeping. +func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + otherUser, err := ts.db.UserByEmail(t.Context(), "anden@example.dk") + if err != nil { + t.Fatal(err) + } + + // In a project that outlives them, so the row is not carried off by the + // project cascade instead. + _, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"}) + sharedID := mine["id"].(string) + ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{ + "email": "anden@example.dk", "role": "editor", + }) + other.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "deres bidrag", "project_id": sharedID, + }) + + _, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil) + if len(before["activity"].([]any)) != 1 { + t.Fatalf("expected one row before the delete: %v", before["activity"]) + } + + if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("delete: status %d", resp.StatusCode) + } + + _, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil) + rows := after["activity"].([]any) + if len(rows) != 1 { + t.Fatalf("the row went with the account: %v", rows) + } + row := rows[0].(map[string]any) + if _, ok := row["user_id"]; ok { + t.Errorf("user_id is still there after the account went: %v", row["user_id"]) + } + if _, ok := row["user_name"]; ok { + t.Errorf("user_name is still there after the account went: %v", row["user_name"]) + } + + // And the project's own history still shows it, rather than dropping the row + // on an inner join to a user that is gone. + _, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil) + var kept int + for _, raw := range project["activity"].([]any) { + if raw.(map[string]any)["event"] == "task.created" { + kept++ + } + } + if kept != 1 { + t.Errorf("the project history dropped the authorless row: %d", kept) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0bded68..0510304 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -144,6 +144,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Use(s.requireAuth) r.Get("/me", s.handleMe) r.Patch("/me", s.handleUpdateProfile) + r.Put("/sidebar-sections", s.handleSetSidebarSections) r.Post("/logout", s.handleLogout) r.Post("/password/change", s.handleChangePassword) r.Post("/totp/setup", s.handleTOTPSetup) @@ -236,6 +237,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Delete("/", s.handleDisconnectGmail) r.Post("/authorize", s.handleGmailAuthorize) r.Post("/sync", s.handleGmailSyncNow) + + // The OAuth client itself is the instance's registration with + // Google, not anybody's mailbox — so unlike everything above it, it + // is administrators only, and behind a session as well: a leaked + // token must not be able to read or replace this instance's + // identity to Google. + r.Group(func(r chi.Router) { + r.Use(s.requireSession, s.requireAdmin) + r.Get("/client", s.handleGetGmailClient) + r.Put("/client", s.handleSetGmailClient) + }) }) r.Route("/import", func(r chi.Router) { @@ -306,6 +318,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Group(func(r chi.Router) { r.Use(s.requireSession, s.requireAdmin) r.Get("/errors", s.handleListErrors) + // A backup file is a complete copy of the database, so this whole + // group is sessions-only as well as administrators-only: a leaked + // token must not be able to download everybody's data. + r.Get("/backups", s.handleListBackups) + r.Post("/backups", s.handleRunBackup) + r.Get("/backups/{backupID}", s.handleDownloadBackup) + // The instance-wide history. Above the per-project one in every + // sense: it crosses projects the caller is not a member of, which + // is the whole reason it is here and not there. + r.Get("/activity", s.handleAuditLog) + r.Get("/activity/events", s.handleAuditEvents) r.Get("/users", s.handleListUsers) r.Post("/users", s.handleCreateUser) r.Patch("/users/{userID}", s.handleUpdateUser) diff --git a/internal/store/activity.go b/internal/store/activity.go index 2c23a05..428e866 100644 --- a/internal/store/activity.go +++ b/internal/store/activity.go @@ -5,13 +5,19 @@ import ( "database/sql" "encoding/json" "errors" + "strings" "time" ) type Activity struct { ID string ProjectID string - TaskID string + // ProjectName is filled by the instance-wide log, where a row without it says + // nothing: "renamed a section" needs to say which project's section. + ProjectName string + TaskID string + // UserID and UserName are empty when the account has been deleted. The record + // of what was done outlives whoever did it — see migration 0009. UserID string UserName string Event string @@ -19,6 +25,23 @@ type Activity struct { CreatedAt time.Time } +// AuditFilter narrows the instance-wide log. Zero values mean "do not filter". +type AuditFilter struct { + UserID string + ProjectID string + // Event matches exactly, e.g. "task.completed". The interface offers the set + // that actually occurs rather than a text field, so there is no prefix or + // wildcard to support here. + Event string + // Before pages backwards. Keyed on (created_at, id) rather than on created_at + // alone: the log is written in whole seconds, so a busy moment puts several + // rows on the same timestamp and a cursor that only knew the time would either + // skip them or repeat them. + BeforeAt int64 + BeforeID string + Limit int +} + // RecordActivity appends to a project's history. // // Every mutation writes one, which is what makes the activity log complete rather @@ -35,7 +58,7 @@ func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, eve _, err := db.ExecContext(ctx, `INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix()) + NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix()) return err } @@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([] if limit <= 0 || limit > 200 { limit = 50 } + // LEFT JOIN, not JOIN: a row whose author has been deleted still happened, and + // an inner join would drop it from the history silently. rows, err := db.QueryContext(ctx, ` - SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at - FROM activity a JOIN users u ON u.id = a.user_id + SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''), + a.event, a.payload_json, a.created_at + FROM activity a LEFT JOIN users u ON u.id = a.user_id WHERE a.project_id = ? ORDER BY a.created_at DESC, a.id DESC LIMIT ?`, projectID, limit) @@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([] return out, rows.Err() } +// ListAuditLog is the same history read across the whole instance rather than one +// project at a time. +// +// The per-project log answers "what happened here", which is a question a project's +// members ask about their own work. This one answers "what has happened on this +// server", which only an administrator asks and which nothing in the API could +// answer before: ListActivity takes one project id, and an administrator is not +// necessarily a member of the project they need to look into. +// +// Reading is a keyset walk rather than an OFFSET. The log is append-heavy and read +// rarely, so a page fetched a minute after the one before it would otherwise be +// shifted by everything written in between — showing a row twice and skipping the +// one behind it, which is precisely the failure an audit log must not have. +func (db *DB) ListAuditLog(ctx context.Context, f AuditFilter) ([]Activity, error) { + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + + where := []string{} + args := []any{} + if f.UserID != "" { + where = append(where, "a.user_id = ?") + args = append(args, f.UserID) + } + if f.ProjectID != "" { + where = append(where, "a.project_id = ?") + args = append(args, f.ProjectID) + } + if f.Event != "" { + where = append(where, "a.event = ?") + args = append(args, f.Event) + } + if f.BeforeAt > 0 { + // A row value, so the tie-break on id is part of the comparison rather + // than a second clause that would have to repeat the timestamp. + where = append(where, "(a.created_at, a.id) < (?, ?)") + args = append(args, f.BeforeAt, f.BeforeID) + } + clause := "" + if len(where) > 0 { + clause = "WHERE " + strings.Join(where, " AND ") + } + args = append(args, limit) + + rows, err := db.QueryContext(ctx, ` + SELECT a.id, a.project_id, p.name, a.task_id, + COALESCE(a.user_id, ''), COALESCE(u.name, ''), + a.event, a.payload_json, a.created_at + FROM activity a + JOIN projects p ON p.id = a.project_id + LEFT JOIN users u ON u.id = a.user_id + `+clause+` + ORDER BY a.created_at DESC, a.id DESC + LIMIT ?`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Activity{} + for rows.Next() { + var a Activity + var taskID sql.NullString + var payload string + var created int64 + if err := rows.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &taskID, &a.UserID, + &a.UserName, &a.Event, &payload, &created); err != nil { + return nil, err + } + a.TaskID = taskID.String + a.CreatedAt = time.Unix(created, 0).UTC() + _ = json.Unmarshal([]byte(payload), &a.Payload) + out = append(out, a) + } + return out, rows.Err() +} + +// AuditEvents is the set of event names the log actually contains, most frequent +// first, with how many of each. +// +// The filter offers these rather than a text field. Event names are an internal +// vocabulary — "member.role_changed", not a sentence anybody would guess — so a +// field to type them into is a field that returns nothing until you have read the +// source. The counts are worth having on their own: they are the shape of what the +// instance does. +func (db *DB) AuditEvents(ctx context.Context) ([]EventCount, error) { + rows, err := db.QueryContext(ctx, ` + SELECT event, count(*) FROM activity + GROUP BY event ORDER BY count(*) DESC, event`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []EventCount{} + for rows.Next() { + var e EventCount + if err := rows.Scan(&e.Event, &e.Count); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +type EventCount struct { + Event string + Count int +} + // PurgeActivity drops history older than the retention window. An activity log that // grows without limit turns a 10 MB database into a 2 GB one over a couple of years // of ordinary use, on hardware that is somebody's spare machine. diff --git a/internal/store/migrations/0009_activity_survives_its_author.sql b/internal/store/migrations/0009_activity_survives_its_author.sql new file mode 100644 index 0000000..a80e271 --- /dev/null +++ b/internal/store/migrations/0009_activity_survives_its_author.sql @@ -0,0 +1,46 @@ +-- `activity.user_id` stops cascading, and the log gets an index it can be read by. +-- +-- Two changes with the same cause: there is now an instance-wide audit log, and +-- the table it reads was only ever built to answer "what happened in this +-- project". +-- +-- The cascade first. An audit log whose rows disappear when the account they +-- describe is deleted answers "what has happened on this server" with everything +-- except the part somebody had a reason to remove. That is the wrong answer to the +-- question, and it is the same mistake migration 0008 fixed on `tasks.created_by`: +-- the record outlives the person. SET NULL, and the reader shows "en slettet +-- konto" rather than a name. +-- +-- `project_id` keeps cascading on purpose. A project purged from the trash is +-- genuinely gone — the trash is the undo, and past it the deletion was deliberate +-- and complete. Activity has its own retention window besides. +-- +-- Then the index. `idx_activity_project` leads with project_id, so a query that +-- wants the newest rows across every project cannot use it and scans the table to +-- sort it. The new one is ordered the way the audit log reads, and the compound +-- (created_at, id) matches the keyset the handler pages with — two rows written in +-- the same second must not be able to hide each other across a page boundary. +-- +-- verdande:rebuild-tables + +CREATE TABLE activity_rebuilt ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL, + -- Nullable: the record of what was done outlives whoever did it. + user_id TEXT REFERENCES users (id) ON DELETE SET NULL, + event TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL +); + +INSERT INTO activity_rebuilt (id, project_id, task_id, user_id, event, payload_json, created_at) +SELECT id, project_id, task_id, user_id, event, payload_json, created_at FROM activity; + +DROP TABLE activity; + +ALTER TABLE activity_rebuilt RENAME TO activity; + +CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC); +CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL; +CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 69148ac..22b344d 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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 }), diff --git a/web/src/lib/events.js b/web/src/lib/events.js new file mode 100644 index 0000000..f8ba7cf --- /dev/null +++ b/web/src/lib/events.js @@ -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 ?? ''; +} diff --git a/web/src/routes/indstillinger/+layout.svelte b/web/src/routes/indstillinger/+layout.svelte index 01f2b12..9420202 100644 --- a/web/src/routes/indstillinger/+layout.svelte +++ b/web/src/routes/indstillinger/+layout.svelte @@ -2,7 +2,7 @@ /** * The settings surface. * - * Six pages rather than one long scroll: the sections have nothing to do with + * Several pages rather than one long scroll: the sections have nothing to do with * each other, and a person opening "API-tokens" should not have to walk past * their own password to get there. * @@ -28,6 +28,7 @@ ...(app.user?.is_admin ? [ { href: '/indstillinger/brugere', label: 'Brugere' }, + { href: '/indstillinger/historik', label: 'Historik' }, { href: '/indstillinger/fejl', label: 'Fejl' } ] : []), @@ -65,8 +66,8 @@ margin-bottom: var(--s5); } - /* A scrolling strip rather than a wrapping row: six labels wrap to two lines on - a phone, and a two-line tab bar looks like a mistake. */ + /* A scrolling strip rather than a wrapping row: this many labels wrap to two + lines on a phone, and a two-line tab bar looks like a mistake. */ nav { display: flex; gap: var(--s1); diff --git a/web/src/routes/indstillinger/historik/+page.svelte b/web/src/routes/indstillinger/historik/+page.svelte new file mode 100644 index 0000000..73310a8 --- /dev/null +++ b/web/src/routes/indstillinger/historik/+page.svelte @@ -0,0 +1,307 @@ + + +
+
+

Historik

+

+ Alt, der er blevet gjort på denne server, på tværs af alle projekter — også + dem, du ikke selv er med i. Projekternes egne historik-paneler viser det + samme, ét projekt ad gangen; det her er spørgsmålet, de ikke kan svare på. + Rækkerne bliver stående, når en konto slettes: det, der blev gjort, holder op + med at have et navn, ikke med at være sket. +

+
+ +
+ + + + + + + {#if filtered} + + {/if} +
+ + {#if entries.length} +
    + {#each entries as entry (entry.id)} +
  • +
    + + {entry.user_name || 'En slettet konto'} + {eventName(entry.event)}{#if eventDetail(entry)} + {eventDetail(entry)}{/if} + + {entry.project_name} +
    + {when(entry.at)} +
  • + {/each} +
+ + {#if cursor} +
+ +
+ {/if} + {:else if loaded} +

+ {filtered + ? 'Ingen hændelser matcher de valgte filtre.' + : 'Der er ikke registreret noget endnu.'} +

+ {/if} +
+ + diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 5bca5ee..1578554 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -5,11 +5,13 @@ import { api, humanMessage } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; import { COLORS, colorVar } from '$lib/colors.js'; + import { eventName, eventDetail } from '$lib/events.js'; import { TASK, carries, dragged, accept } from '$lib/dnd.js'; import TaskList from '$lib/components/TaskList.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import BoardView from '$lib/components/BoardView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte'; + import { focusOnMount } from '$lib/focus.js'; let project = $state(null); /** @@ -75,45 +77,6 @@ } }); - /** - * What each recorded event is called. - * - * The log has been written since the beginning — nineteen kinds of entry — and - * nothing has ever read it. The keys are the server's and they are stable; this - * is the only place they become Danish. - * - * 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. - */ - 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 bit of context an entry carries, when it has one worth reading. */ - function detail(entry) { - const p = entry.payload ?? {}; - return p.name ?? p.email ?? p.role ?? ''; - } - function when(iso) { const then = new Date(iso); const seconds = Math.round((Date.now() - then) / 1000); @@ -359,10 +322,9 @@ what a screen reader announces on arrival. So the heading stays and becomes editable on click. --> {#if editing} - rename(e.currentTarget)} @@ -432,10 +394,14 @@
    {#each activity as entry (entry.id)}
  • - {entry.user_name} + + {entry.user_name || 'En slettet konto'} - {EVENTS[entry.event] ?? entry.event}{#if detail(entry)} - {detail(entry)}{/if} + {eventName(entry.event)}{#if eventDetail(entry)} + {eventDetail(entry)}{/if} {when(entry.created_at)}
  • @@ -536,7 +502,12 @@ {/if} {#if mode === 'board'} - + (sections = [...sections, section])} + /> {:else if mode === 'calendar'} {:else} @@ -568,10 +539,9 @@
    {#if renamingSection === section.id}
    renameSection(e, section)}> - (renamingSection = null)} onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)} @@ -604,10 +574,9 @@
    {#if addingSection} - !sectionName.trim() && (addingSection = false)} @@ -1000,6 +969,14 @@ flex: none; } + /* A name that is not a name reads at the weight of the surrounding prose, not + at the weight a person's name gets. */ + .who.gone { + font-weight: 400; + font-style: italic; + color: var(--ink-faint); + } + .what { color: var(--ink-muted); flex: 1;