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>
238 lines
7.8 KiB
Go
238 lines
7.8 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. 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"`
|
|
}
|
|
|
|
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, "list users", err)
|
|
return
|
|
}
|
|
invites, err := s.db.ListPendingInvites(r.Context())
|
|
if err != nil {
|
|
s.internal(w, "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,
|
|
}
|
|
// 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, "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, "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, "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, "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, "get user", err)
|
|
return err
|
|
}
|
|
if !target.IsAdmin {
|
|
return nil
|
|
}
|
|
admins, err := s.db.CountAdmins(r.Context())
|
|
if err != nil {
|
|
s.internal(w, "count admins", err)
|
|
return err
|
|
}
|
|
if admins <= 1 {
|
|
writeError(w, http.StatusConflict, CodeLastAdmin, message)
|
|
return errLastAdmin
|
|
}
|
|
return nil
|
|
}
|