// api_users.go — JSON handlers for the user-management surface. // // All endpoints in this file are admin-only; gating happens at the // route-mount site (server.go's admin band). package http import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "log/slog" stdhttp "net/http" "net/mail" "strings" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type listUsersResponse struct { Users []apiUser `json:"users"` } type apiUser struct { ID string `json:"id"` Username string `json:"username"` Role string `json:"role"` Email *string `json:"email,omitempty"` Disabled bool `json:"disabled"` MustChangePassword bool `json:"must_change_password"` CreatedAt string `json:"created_at"` LastLoginAt *string `json:"last_login_at,omitempty"` } func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { users, err := s.deps.Store.ListUsers(r.Context()) if err != nil { slog.Error("api users: list", "err", err) writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } out := make([]apiUser, len(users)) for i, u := range users { var lastLogin *string if u.LastLoginAt != nil { s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") lastLogin = &s } out[i] = apiUser{ ID: u.ID, Username: u.Username, Role: string(u.Role), Email: u.Email, Disabled: u.DisabledAt != nil, MustChangePassword: u.MustChangePassword, CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), LastLoginAt: lastLogin, } } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out}) } type createUserRequest struct { Username string `json:"username"` Email string `json:"email,omitempty"` Role string `json:"role"` } type createUserResponse struct { ID string `json:"id"` SetupURL string `json:"setup_url"` } // generateSetupToken returns 32 random bytes hex-encoded (64 chars). func generateSetupToken() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } // validRole maps a wire role string to the typed constant. Returns // ("", false) for anything unknown. func validRole(r string) (store.Role, bool) { switch r { case "admin": return store.RoleAdmin, true case "operator": return store.RoleOperator, true case "viewer": return store.RoleViewer, true } return "", false } func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) { actor, _ := s.requireUser(r) // already gated by middleware var req createUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } uname := strings.ToLower(strings.TrimSpace(req.Username)) if uname == "" { writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "") return } role, ok := validRole(req.Role) if !ok { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") return } if req.Email != "" { if _, err := mail.ParseAddress(req.Email); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) return } } // Check for collision against existing user (case-insensitive). existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname) if err == nil { body := map[string]any{ "error": "username_taken", "existing_user_id": existing.ID, "disabled": existing.DisabledAt != nil, } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(stdhttp.StatusConflict) _ = json.NewEncoder(w).Encode(body) return } else if !errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } id := ulid.Make().String() now := time.Now().UTC() var emailPtr *string if req.Email != "" { em := strings.ToLower(strings.TrimSpace(req.Email)) emailPtr = &em } if err := s.deps.Store.CreateUser(r.Context(), store.User{ ID: id, Username: uname, PasswordHash: "", Role: role, Email: emailPtr, CreatedAt: now, MustChangePassword: true, }); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } rawToken, err := generateSetupToken() if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } var actorID *string if actor != nil { actorID = &actor.ID } if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ UserID: id, TokenHash: hashSetupToken(rawToken), ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: actorID, }); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: actorID, Actor: "user", Action: "user.created", TargetKind: ptr("user"), TargetID: &id, TS: now, }) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(stdhttp.StatusCreated) _ = json.NewEncoder(w).Encode(createUserResponse{ ID: id, SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, }) } func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { id := chi.URLParam(r, "id") u, err := s.deps.Store.GetUserByID(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") return } writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } out := apiUser{ ID: u.ID, Username: u.Username, Role: string(u.Role), Email: u.Email, Disabled: u.DisabledAt != nil, MustChangePassword: u.MustChangePassword, CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), } if u.LastLoginAt != nil { ll := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") out.LastLoginAt = &ll } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(out) } type patchUserRequest struct { Role *string `json:"role,omitempty"` Email *string `json:"email,omitempty"` } func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) { actor, _ := s.requireUser(r) id := chi.URLParam(r, "id") u, err := s.deps.Store.GetUserByID(r.Context(), id) if err != nil { writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") return } var req patchUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } if req.Role != nil { newRole, ok := validRole(*req.Role) if !ok { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") return } // Last-admin guard: cannot demote the only enabled admin. if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil { n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) if n <= 1 { writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") return } } if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } } if req.Email != nil { em := strings.TrimSpace(*req.Email) if em != "" { if _, err := mail.ParseAddress(em); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) return } } if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } } var actorID *string if actor != nil { actorID = &actor.ID } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: actorID, Actor: "user", Action: "user.updated", TargetKind: ptr("user"), TargetID: &id, TS: time.Now().UTC(), }) w.WriteHeader(stdhttp.StatusOK) } func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) { actor, _ := s.requireUser(r) id := chi.URLParam(r, "id") u, err := s.deps.Store.GetUserByID(r.Context(), id) if err != nil { writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") return } if u.Role == store.RoleAdmin && u.DisabledAt == nil { n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) if n <= 1 { writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") return } } now := time.Now().UTC() if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } // Kick existing sessions so the user is bounced immediately. _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id) var actorID *string if actor != nil { actorID = &actor.ID } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: actorID, Actor: "user", Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id, TS: now, }) w.WriteHeader(stdhttp.StatusOK) } func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) { actor, _ := s.requireUser(r) id := chi.URLParam(r, "id") if err := s.deps.Store.EnableUser(r.Context(), id); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } var actorID *string if actor != nil { actorID = &actor.ID } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: actorID, Actor: "user", Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id, TS: time.Now().UTC(), }) w.WriteHeader(stdhttp.StatusOK) }