feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s

The README has promised "internal users are created by an admin" since the
beginning and there was no such thing. There are exactly two ways an
account has ever come into existence: the first admin at setup, and
somebody accepting an invite to a project. So the only way to add a person
was to share a project with them first.

The store has been ready the whole time. CreateInvite already documents
that "projectID may be empty, which invites somebody to the instance
rather than to a particular project", and AcceptInvite already has the
branch that grants no membership when there is none. requireAdmin has
existed in the middleware and was used nowhere. This wires those three
together and puts a page on them.

An invite rather than an account with a password the administrator picks.
A password typed here would be known to two people before it was ever
used, and would then have to travel to the second one by some channel
nobody has thought about. No name is asked for either — the signup form
asks the person, and a name an administrator guesses is a name somebody
has to correct.

Two refusals keep the instance administrable: the last administrator
cannot be demoted or deleted, and nobody can delete the account they are
signed in as. Both are enforced on the server; the interface simply does
not draw the buttons on your own row, because a button whose only purpose
is to be refused wastes a click to teach a rule. The last-admin refusal
gets its own error code, because "that already exists" is not what
happened and this is the one refusal a person needs explained.

Deleting an account is the only hard delete in this database, and building
the confirmation is what turned up how wide it goes. `projects.owner_id`
cascades, which is expected. But `tasks.created_by` cascades too — so
removing a colleague deletes every task they ever wrote, including in
projects somebody else owns. The count in the list says so, and the
confirmation says it in words. Changing that to ON DELETE SET NULL, so the
work stays and loses its author, means rebuilding the tasks table and its
FTS triggers: worth doing, not worth doing quietly while adding a screen.
It is written down in the store instead.

Pending invites are listed and can be withdrawn. Only the token's hash is
stored, so a link sent to the wrong address cannot be looked up and
resent — withdrawing it is the only way to stop it before it expires.

Six API tests for the guards and the counts, and two smoke tests: the
whole loop from invite to a second browser creating the account and
appearing in the list as somebody who is not an administrator, and the
absence of buttons on your own row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 21:10:46 +02:00
co-authored by Claude Opus 5
parent a8b453843d
commit e8ebd0fa78
12 changed files with 1297 additions and 6 deletions
+111
View File
@@ -163,6 +163,117 @@ func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person,
return people, rows.Err()
}
// UserSummary is a user as the administrator's list shows them: the account, plus
// the two numbers that say what deleting it would take with it and the one that
// says whether it is in use at all.
type UserSummary struct {
User
// LastSeenAt is the most recent request from any of their sessions, zero if
// they have never signed in — which is what an invite that was accepted and
// then forgotten looks like.
LastSeenAt time.Time
// ProjectCount excludes the Inbox: every account has one, so counting it would
// make "1 project" mean "nothing".
ProjectCount int
// TaskCount is everything a delete would destroy, which is not the same as
// "their tasks": it is the tasks in projects they own *plus* every task they
// wrote anywhere, because tasks.created_by cascades. A number that counted
// only the first would understate the damage in exactly the case that matters
// — a colleague leaving a shared project.
TaskCount int
}
// ListUsers returns every account on the instance. Administrators only — this is
// the whole membership of the server, which is not a list anybody else is owed.
func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.email, u.name, u.avatar_color, u.is_admin, u.created_at,
COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0),
(SELECT count(*) FROM projects p
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
WHERE t.deleted_at IS NULL
AND (p.owner_id = u.id OR t.created_by = u.id))
FROM users u
ORDER BY u.is_admin DESC, u.name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []UserSummary{}
for rows.Next() {
var s UserSummary
var isAdmin int
var created, lastSeen int64
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
&lastSeen, &s.ProjectCount, &s.TaskCount); err != nil {
return nil, err
}
s.IsAdmin = isAdmin == 1
s.CreatedAt = time.Unix(created, 0).UTC()
if lastSeen > 0 {
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
}
out = append(out, s)
}
return out, rows.Err()
}
// CountAdmins is what stops the last administrator from being deleted or demoted.
// An instance with no administrator has no way back: there is no console, and the
// setup route refuses to run once an account exists.
func (db *DB) CountAdmins(ctx context.Context) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE is_admin = 1`).Scan(&n)
return n, err
}
func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error {
res, err := db.ExecContext(ctx,
`UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`,
boolToInt(admin), time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteUser removes an account and everything that hangs off it.
//
// A hard delete, and the only one in this database. Every other delete here is a
// `deleted_at` with a trash behind it; this one relies on the foreign keys and
// there is no way back — which is why the handler sends the counts to the
// interface first.
//
// What goes is wider than it looks. `projects.owner_id` cascades, so their
// projects go and every task in them goes too. But `tasks.created_by` cascades as
// well, so **every task they ever wrote goes, including in projects somebody else
// owns**. Retiring a colleague's account therefore removes their contributions
// from shared projects rather than leaving them behind unattributed.
//
// That is the schema as it stands rather than a decision made here. Changing it —
// `created_by` as ON DELETE SET NULL, so the work stays and loses its author —
// means rebuilding the tasks table, which carries the FTS triggers with it. Worth
// doing; not worth doing quietly as part of adding a screen.
//
// What survives: tasks merely *assigned* to them, which are unassigned by
// `assignee_id ON DELETE SET NULL`, and completions, by the same rule on
// `completed_by`.
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int