Files
verdande/internal/httpapi/users_handlers.go
T
Kristian WindandClaude Opus 5 ebdc8eacf2 feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every
task they had ever written — including in projects somebody else owns. The
interface said so in plain words before you pressed the button, which is a
warning rather than a fix. It was the last remaining way to lose other people's
work by accident.

SET NULL instead: the task stays and loses its author, the same shape
`assignee_id` and `completed_by` already have.

SQLite cannot alter a foreign key action, so the table is rebuilt — and with
foreign keys on, the drop in the middle of a rebuild is a cascading delete that
takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op
inside a transaction, so migrations now run on a connection of their own, and a
migration can opt out of enforcement with `-- verdande:rebuild-tables`.
`PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
dangling reference fails the migration rather than writing it.

The test fills a database at 0007, upgrades it, and counts. Without the marker it
loses a task, a label link and a comment — which is exactly what it is for.

The admin list now says two numbers instead of one: what disappears, and what
stays behind unattributed. They are different sentences and the second is a
reassurance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:03:59 +02:00

241 lines
8.0 KiB
Go

package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Who is on the instance, and how somebody else gets on it.
//
// There is no open registration and there never was: an account comes into
// existence in exactly two ways — the first admin at setup, and somebody accepting
// an invite. This surface is the second one made reachable without first sharing a
// project, which is what "create a user" turns out to mean here.
//
// The whole file is administrators only.
type adminUserJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
IsAdmin bool `json:"is_admin"`
// Self marks the caller, so the interface can grey out the two things nobody
// should do to their own account from here.
Self bool `json:"self"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at,omitempty"`
// ProjectCount and TaskCount are what a delete would destroy, and
// AuthoredElsewhere is what it would leave behind without an author. Sent with
// the list rather than fetched when the button is pressed, so the confirmation
// can name real numbers without a round trip in the middle of a decision.
ProjectCount int `json:"project_count"`
TaskCount int `json:"task_count"`
AuthoredElsewhere int `json:"authored_elsewhere"`
}
type pendingInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
// ProjectName is empty for an invite to the instance itself, which is what
// this page issues. The two read very differently to whoever is looking.
ProjectName string `json:"project_name,omitempty"`
Role string `json:"role"`
InvitedBy string `json:"invited_by,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
me := userFrom(r.Context())
users, err := s.db.ListUsers(r.Context())
if err != nil {
s.internal(w, r, "list users", err)
return
}
invites, err := s.db.ListPendingInvites(r.Context())
if err != nil {
s.internal(w, r, "list pending invites", err)
return
}
out := make([]adminUserJSON, 0, len(users))
for _, u := range users {
j := adminUserJSON{
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
AuthoredElsewhere: u.AuthoredElsewhere,
}
// Zero means never signed in, which is not a time and must not be sent as
// one — 1970 in an interface reads as a bug rather than as "never".
if !u.LastSeenAt.IsZero() {
j.LastSeenAt = u.LastSeenAt.Format(time.RFC3339)
}
out = append(out, j)
}
pending := make([]pendingInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, pendingInviteJSON{
ID: i.ID, Email: i.Email, ProjectName: i.ProjectName, Role: string(i.Role),
InvitedBy: i.InvitedBy,
CreatedAt: i.CreatedAt.Format(time.RFC3339),
ExpiresAt: i.ExpiresAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"users": out, "invites": pending})
}
type createUserRequest struct {
Email string `json:"email"`
}
// handleCreateUser invites somebody to the instance.
//
// An invite rather than an account with a password the administrator chooses. A
// password typed here would be known to two people before it was ever used, and
// would have to travel to the second one by some channel nobody has thought about.
// The link does the same job and ends with a password only its owner has seen.
//
// No name is asked for, because the signup form asks the person themselves — a
// name an administrator guesses is a name somebody has to correct.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
inviter := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if _, err := s.db.UserByEmail(r.Context(), email); err == nil {
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
return
}
// RoleEditor is carried on the row because the column requires a role, and it
// is never read: AcceptInvite returns early when there is no project to grant
// anything on.
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
inviter.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create instance invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendInvite(r.Context(), email, inviter.Name, "", link, s.cfg.InviteTTL); err != nil {
s.log.Error("send instance invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type updateUserRequest struct {
IsAdmin *bool `json:"is_admin"`
}
// handleUpdateUser promotes or demotes an administrator.
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
var req updateUserRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
if req.IsAdmin == nil {
w.WriteHeader(http.StatusNoContent)
return
}
// Standing down as the last administrator locks everybody out of this page,
// and there is no console and no second setup run to undo it with.
if !*req.IsAdmin {
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be demoted"); err != nil {
return
}
}
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
s.storeError(w, r, "set user admin", err)
return
}
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteUser removes an account and everything the foreign keys take with
// it. See store.DeleteUser for what that is; it is more than it looks.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
me := userFrom(r.Context())
// Not yourself. Deleting the account you are signed in as would take your own
// projects while you were looking at them, and end the session mid-request.
// Somebody who really means it can be demoted first and removed by another
// administrator.
if userID == me.ID {
writeError(w, http.StatusConflict, CodeConflict,
"you cannot delete the account you are signed in as")
return
}
if err := s.refuseIfLastAdmin(w, r, userID, "the last administrator cannot be deleted"); err != nil {
return
}
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
s.storeError(w, r, "delete user", err)
return
}
s.log.Info("user deleted", "by", me.ID, "user", userID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refuseIfLastAdmin writes a 409 and returns an error when the named user is the
// only administrator left. The two callers both need it, and getting it wrong in
// one of them is an instance nobody can administer again.
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
target, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, r, "get user", err)
return err
}
if !target.IsAdmin {
return nil
}
admins, err := s.db.CountAdmins(r.Context())
if err != nil {
s.internal(w, r, "count admins", err)
return err
}
if admins <= 1 {
writeError(w, http.StatusConflict, CodeLastAdmin, message)
return errLastAdmin
}
return nil
}