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
+176
View File
@@ -856,6 +856,140 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/users:
get:
tags: [auth]
summary: Everybody on the instance, and the invites not yet accepted.
description: |
Administrators only. Each user carries what deleting them would destroy,
so the confirmation can name real numbers without a second request in the
middle of a decision.
Session only: an API token cannot enumerate the instance's membership.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
users:
type: array
items: { $ref: "#/components/schemas/AdminUser" }
invites:
type: array
items: { $ref: "#/components/schemas/PendingInvite" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [auth]
summary: Invite somebody to the instance.
description: |
There is no open registration and no account created with a password
somebody else chose: this issues an invite with no project attached, and
the link ends with a password only its owner has seen. The same link and
the same `/auth/signup` as a project invite — an invite whose project is
empty simply grants no membership when it is accepted.
No name is asked for. The signup form asks the person themselves, and a
name an administrator guesses is a name somebody has to correct.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
"201":
description: Created
content:
application/json:
schema:
type: object
properties:
link:
type: string
description: |
Returned so the interface can offer to copy it. On an
instance with no mail server that is the only way the invite
reaches anybody.
emailed: { type: boolean }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: That address already has an account.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/users/{userID}:
parameters:
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [auth]
summary: Make somebody an administrator, or stop them being one.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
is_admin: { type: boolean }
responses:
"204": { description: Changed. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: |
The last administrator cannot be demoted — `code` is `last_admin`.
An instance with no administrator has no way back: there is no
console, and setup refuses to run once an account exists.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
delete:
tags: [auth]
summary: Remove an account and everything the foreign keys take with it.
description: |
The only hard delete in this API. Everything else is soft-deleted with a
trash behind it; this is not recoverable.
What goes is wider than it looks: their projects, every task in those
projects, **and every task they wrote anywhere** — `tasks.created_by`
cascades, so retiring a colleague's account removes their contributions
from shared projects too. Tasks merely assigned to them are unassigned
and survive.
Refused for your own account, and for the last administrator.
responses:
"204": { description: Deleted. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: Your own account, or the last administrator.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/api/v1/invites/{inviteID}:
parameters:
- { name: inviteID, in: path, required: true, schema: { type: string } }
delete:
tags: [auth]
summary: Withdraw an invite that has not been accepted.
description: |
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 working before it expires.
responses:
"204": { description: Withdrawn. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/project-groups:
get:
tags: [projects]
@@ -2270,6 +2404,48 @@ components:
type: array
items: { type: string }
AdminUser:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
name: { type: string }
avatar_color: { type: string }
is_admin: { type: boolean }
self:
type: boolean
description: The caller, so the interface can grey out what nobody should do to their own account here.
created_at: { type: string, format: date-time }
last_seen_at:
type: string
format: date-time
description: |
Absent when they have never signed in — which is what an invite that
was accepted and then forgotten looks like. Absent rather than zero,
because 1970 in an interface reads as a bug.
project_count:
type: integer
description: Projects they own, excluding the Inbox every account has.
task_count:
type: integer
description: |
Everything a delete would destroy: tasks in projects they own, plus
every task they wrote anywhere. Not the same as "their tasks", and
the difference is the whole point of showing it.
PendingInvite:
type: object
properties:
id: { type: string }
email: { type: string, format: email }
project_name:
type: string
description: Absent for an invite to the instance itself rather than to a project.
role: { type: string, enum: [owner, editor, viewer] }
invited_by: { type: string }
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Session:
type: object
properties: