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
@@ -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 <unix>:<id>, 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 "<unix>:<id>" — 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Use(s.requireAuth)
|
r.Use(s.requireAuth)
|
||||||
r.Get("/me", s.handleMe)
|
r.Get("/me", s.handleMe)
|
||||||
r.Patch("/me", s.handleUpdateProfile)
|
r.Patch("/me", s.handleUpdateProfile)
|
||||||
|
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||||
r.Post("/logout", s.handleLogout)
|
r.Post("/logout", s.handleLogout)
|
||||||
r.Post("/password/change", s.handleChangePassword)
|
r.Post("/password/change", s.handleChangePassword)
|
||||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
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.Delete("/", s.handleDisconnectGmail)
|
||||||
r.Post("/authorize", s.handleGmailAuthorize)
|
r.Post("/authorize", s.handleGmailAuthorize)
|
||||||
r.Post("/sync", s.handleGmailSyncNow)
|
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) {
|
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.Group(func(r chi.Router) {
|
||||||
r.Use(s.requireSession, s.requireAdmin)
|
r.Use(s.requireSession, s.requireAdmin)
|
||||||
r.Get("/errors", s.handleListErrors)
|
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.Get("/users", s.handleListUsers)
|
||||||
r.Post("/users", s.handleCreateUser)
|
r.Post("/users", s.handleCreateUser)
|
||||||
r.Patch("/users/{userID}", s.handleUpdateUser)
|
r.Patch("/users/{userID}", s.handleUpdateUser)
|
||||||
|
|||||||
+141
-4
@@ -5,13 +5,19 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ID string
|
ID string
|
||||||
ProjectID 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
|
UserID string
|
||||||
UserName string
|
UserName string
|
||||||
Event string
|
Event string
|
||||||
@@ -19,6 +25,23 @@ type Activity struct {
|
|||||||
CreatedAt time.Time
|
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.
|
// RecordActivity appends to a project's history.
|
||||||
//
|
//
|
||||||
// Every mutation writes one, which is what makes the activity log complete rather
|
// 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,
|
_, err := db.ExecContext(ctx,
|
||||||
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
|
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
|||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
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, `
|
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
|
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||||
FROM activity a JOIN users u ON u.id = a.user_id
|
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 = ?
|
WHERE a.project_id = ?
|
||||||
ORDER BY a.created_at DESC, a.id DESC
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
LIMIT ?`, projectID, limit)
|
LIMIT ?`, projectID, limit)
|
||||||
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
|||||||
return out, rows.Err()
|
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
|
// 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
|
// 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.
|
// of ordinary use, on hardware that is somebody's spare machine.
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -160,6 +160,9 @@ export const api = {
|
|||||||
logout: () => post('/auth/logout'),
|
logout: () => post('/auth/logout'),
|
||||||
me: () => get('/auth/me'),
|
me: () => get('/auth/me'),
|
||||||
updateProfile: (data) => patch('/auth/me', data),
|
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),
|
signup: (data) => post('/auth/signup', data),
|
||||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||||
@@ -180,6 +183,33 @@ export const api = {
|
|||||||
revokeInvite: (id) => del(`/invites/${id}`),
|
revokeInvite: (id) => del(`/invites/${id}`),
|
||||||
listErrors: () => get('/errors'),
|
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'),
|
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
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 ?? '';
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* The settings surface.
|
* 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
|
* each other, and a person opening "API-tokens" should not have to walk past
|
||||||
* their own password to get there.
|
* their own password to get there.
|
||||||
*
|
*
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
...(app.user?.is_admin
|
...(app.user?.is_admin
|
||||||
? [
|
? [
|
||||||
{ href: '/indstillinger/brugere', label: 'Brugere' },
|
{ href: '/indstillinger/brugere', label: 'Brugere' },
|
||||||
|
{ href: '/indstillinger/historik', label: 'Historik' },
|
||||||
{ href: '/indstillinger/fejl', label: 'Fejl' }
|
{ href: '/indstillinger/fejl', label: 'Fejl' }
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -65,8 +66,8 @@
|
|||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A scrolling strip rather than a wrapping row: six labels wrap to two lines on
|
/* A scrolling strip rather than a wrapping row: this many labels wrap to two
|
||||||
a phone, and a two-line tab bar looks like a mistake. */
|
lines on a phone, and a two-line tab bar looks like a mistake. */
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s1);
|
gap: var(--s1);
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Historik: what has been done on this instance, across every project.
|
||||||
|
*
|
||||||
|
* The other half of Fejl. That page is what broke; this is what was done, and an
|
||||||
|
* administrator had nowhere to ask the second question: the per-project log
|
||||||
|
* takes one project id, and an administrator is not necessarily a member of the
|
||||||
|
* project they need to look into.
|
||||||
|
*
|
||||||
|
* Paged with the cursor the server hands back rather than a page number. The log
|
||||||
|
* is written to constantly and read rarely, so a second page fetched a minute
|
||||||
|
* after the first would otherwise be shifted by everything written in between —
|
||||||
|
* one row shown twice and the row behind it skipped, which is the one failure an
|
||||||
|
* audit log must not have.
|
||||||
|
*/
|
||||||
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { eventName, eventDetail } from '$lib/events.js';
|
||||||
|
|
||||||
|
let entries = $state([]);
|
||||||
|
let cursor = $state(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// The filters. Events come from the server rather than from a list here: the
|
||||||
|
// names are an internal vocabulary, and a select built from what actually
|
||||||
|
// occurred cannot offer an option that returns nothing.
|
||||||
|
let events = $state([]);
|
||||||
|
let people = $state([]);
|
||||||
|
let projects = $state([]);
|
||||||
|
let filterUser = $state('');
|
||||||
|
let filterProject = $state('');
|
||||||
|
let filterEvent = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
Promise.all([api.auditEvents(), api.listUsers(), api.listProjects()])
|
||||||
|
.then(([e, u, p]) => {
|
||||||
|
events = e.events;
|
||||||
|
people = u.users;
|
||||||
|
projects = p.projects;
|
||||||
|
})
|
||||||
|
.catch((error) => app.toast(humanMessage(error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reloads from the top whenever a filter changes. Reading the three filters here
|
||||||
|
// is what subscribes the effect to them.
|
||||||
|
$effect(() => {
|
||||||
|
const query = {
|
||||||
|
user_id: filterUser,
|
||||||
|
project_id: filterProject,
|
||||||
|
event: filterEvent,
|
||||||
|
limit: 50
|
||||||
|
};
|
||||||
|
let cancelled = false;
|
||||||
|
loading = true;
|
||||||
|
api
|
||||||
|
.auditLog(query)
|
||||||
|
.then((r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
entries = r.activity;
|
||||||
|
cursor = r.next_cursor ?? null;
|
||||||
|
loaded = true;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) app.toast(humanMessage(error));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) loading = false;
|
||||||
|
});
|
||||||
|
// A filter changed while a page was in flight: the late answer belongs to the
|
||||||
|
// old filter and must not land on top of the new one.
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function more() {
|
||||||
|
if (!cursor || loading) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const r = await api.auditLog({
|
||||||
|
before: cursor,
|
||||||
|
user_id: filterUser,
|
||||||
|
project_id: filterProject,
|
||||||
|
event: filterEvent,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
entries = [...entries, ...r.activity];
|
||||||
|
cursor = r.next_cursor ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
app.toast(humanMessage(error));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const then = new Date(iso);
|
||||||
|
const seconds = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (seconds < 60) return 'lige nu';
|
||||||
|
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||||
|
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||||
|
return then.toLocaleString('da-DK', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = $derived(Boolean(filterUser || filterProject || filterEvent));
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
filterUser = '';
|
||||||
|
filterProject = '';
|
||||||
|
filterEvent = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header>
|
||||||
|
<h2>Historik</h2>
|
||||||
|
<p class="hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<label>
|
||||||
|
<span>Person</span>
|
||||||
|
<select bind:value={filterUser}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each people as person (person.id)}
|
||||||
|
<option value={person.id}>{person.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Projekt</span>
|
||||||
|
<select bind:value={filterProject}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each projects as project (project.id)}
|
||||||
|
<option value={project.id}>{project.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Hændelse</span>
|
||||||
|
<select bind:value={filterEvent}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{#each events as event (event.event)}
|
||||||
|
<option value={event.event}>{eventName(event.event)} ({event.count})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if filtered}
|
||||||
|
<button class="secondary" onclick={clearFilters}>Ryd</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if entries.length}
|
||||||
|
<ul class="rows">
|
||||||
|
{#each entries as entry (entry.id)}
|
||||||
|
<li>
|
||||||
|
<div class="what">
|
||||||
|
<span class="primary-line">
|
||||||
|
<span class="who" class:gone={!entry.user_name}
|
||||||
|
>{entry.user_name || 'En slettet konto'}</span
|
||||||
|
>
|
||||||
|
<span class="event"
|
||||||
|
>{eventName(entry.event)}{#if eventDetail(entry)}
|
||||||
|
<span class="detail">{eventDetail(entry)}</span>{/if}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="secondary">{entry.project_name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="when">{when(entry.at)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if cursor}
|
||||||
|
<div class="row">
|
||||||
|
<button class="secondary" onclick={more} disabled={loading}>
|
||||||
|
{loading ? 'Henter…' : 'Hent flere'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if loaded}
|
||||||
|
<p class="hint">
|
||||||
|
{filtered
|
||||||
|
? 'Ingen hændelser matcher de valgte filtre.'
|
||||||
|
: 'Der er ikke registreret noget endnu.'}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The filters wrap rather than scroll: three selects on a phone are three lines,
|
||||||
|
and a select that has scrolled halfway out of view is a filter you cannot see
|
||||||
|
the state of. */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.when {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { COLORS, colorVar } from '$lib/colors.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 { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||||
import TaskList from '$lib/components/TaskList.svelte';
|
import TaskList from '$lib/components/TaskList.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import BoardView from '$lib/components/BoardView.svelte';
|
import BoardView from '$lib/components/BoardView.svelte';
|
||||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
|
|
||||||
let project = $state(null);
|
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) {
|
function when(iso) {
|
||||||
const then = new Date(iso);
|
const then = new Date(iso);
|
||||||
const seconds = Math.round((Date.now() - then) / 1000);
|
const seconds = Math.round((Date.now() - then) / 1000);
|
||||||
@@ -359,10 +322,9 @@
|
|||||||
what a screen reader announces on arrival. So the heading stays and
|
what a screen reader announces on arrival. So the heading stays and
|
||||||
becomes editable on click. -->
|
becomes editable on click. -->
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input
|
<input
|
||||||
class="title"
|
class="title"
|
||||||
autofocus
|
use:focusOnMount
|
||||||
value={project.name}
|
value={project.name}
|
||||||
aria-label="Projektets navn"
|
aria-label="Projektets navn"
|
||||||
onblur={(e) => rename(e.currentTarget)}
|
onblur={(e) => rename(e.currentTarget)}
|
||||||
@@ -432,10 +394,14 @@
|
|||||||
<ul class="log">
|
<ul class="log">
|
||||||
{#each activity as entry (entry.id)}
|
{#each activity as entry (entry.id)}
|
||||||
<li>
|
<li>
|
||||||
<span class="who">{entry.user_name}</span>
|
<!-- Empty when the account has been deleted: the record of what was
|
||||||
|
done outlives whoever did it, so the row stays and the name goes. -->
|
||||||
|
<span class="who" class:gone={!entry.user_name}
|
||||||
|
>{entry.user_name || 'En slettet konto'}</span
|
||||||
|
>
|
||||||
<span class="what">
|
<span class="what">
|
||||||
{EVENTS[entry.event] ?? entry.event}{#if detail(entry)}
|
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||||
<span class="detail">{detail(entry)}</span>{/if}
|
<span class="detail">{eventDetail(entry)}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="when">{when(entry.created_at)}</span>
|
<span class="when">{when(entry.created_at)}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -536,7 +502,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === 'board'}
|
{#if mode === 'board'}
|
||||||
<BoardView {project} {sections} {canEdit} />
|
<BoardView
|
||||||
|
{project}
|
||||||
|
{sections}
|
||||||
|
{canEdit}
|
||||||
|
onsectionadded={(section) => (sections = [...sections, section])}
|
||||||
|
/>
|
||||||
{:else if mode === 'calendar'}
|
{:else if mode === 'calendar'}
|
||||||
<CalendarView projectId={project.id} />
|
<CalendarView projectId={project.id} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -568,10 +539,9 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
{#if renamingSection === section.id}
|
{#if renamingSection === section.id}
|
||||||
<form onsubmit={(e) => renameSection(e, section)}>
|
<form onsubmit={(e) => renameSection(e, section)}>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input
|
<input
|
||||||
bind:value={sectionName}
|
bind:value={sectionName}
|
||||||
autofocus
|
use:focusOnMount
|
||||||
aria-label="Sektionens navn"
|
aria-label="Sektionens navn"
|
||||||
onblur={() => (renamingSection = null)}
|
onblur={() => (renamingSection = null)}
|
||||||
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
||||||
@@ -604,10 +574,9 @@
|
|||||||
<section class="add-section">
|
<section class="add-section">
|
||||||
{#if addingSection}
|
{#if addingSection}
|
||||||
<form onsubmit={addSection}>
|
<form onsubmit={addSection}>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input
|
<input
|
||||||
bind:value={sectionName}
|
bind:value={sectionName}
|
||||||
autofocus
|
use:focusOnMount
|
||||||
placeholder="Sektionens navn"
|
placeholder="Sektionens navn"
|
||||||
aria-label="Ny sektion"
|
aria-label="Ny sektion"
|
||||||
onblur={() => !sectionName.trim() && (addingSection = false)}
|
onblur={() => !sectionName.trim() && (addingSection = false)}
|
||||||
@@ -1000,6 +969,14 @@
|
|||||||
flex: none;
|
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 {
|
.what {
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user