package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // adminUser is the API shape returned by the admin user-management endpoints. type adminUser struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` Verified bool `json:"verified"` Created string `json:"created"` } func (rec userRecord) toAdminUser() adminUser { return adminUser{ ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: orDefault(rec.Role, "user"), Verified: rec.Verified, Created: rec.Created, } } // requireAdmin ensures the current request is from an admin. It re-reads the // user's role from PocketBase (rather than trusting the token) so a demotion // takes effect immediately. On failure it writes the response and returns false. func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { me, err := s.fetchUser(r, s.currentUserID(r)) if err != nil { writePBError(w, err) return false } if orDefault(me.Role, "user") != "admin" { writeError(w, http.StatusForbidden, "admin access required") return false } return true } // countAdmins returns how many users currently hold the admin role. Used to // prevent removing the last admin (which would lock everyone out of admin). func (s *Server) countAdmins(ctx context.Context) (int, error) { res, err := s.pb.List(ctx, s.usersCollection, url.Values{ "filter": {"role='admin'"}, "perPage": {"1"}, }) if err != nil { return 0, err } return res.TotalItems, nil } // handleListUsers serves GET /api/admin/users. func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{ "sort": {"email"}, "perPage": {"500"}, }) if err != nil { writePBError(w, err) return } var recs []userRecord if err := json.Unmarshal(res.Items, &recs); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } out := make([]adminUser, 0, len(recs)) for _, rec := range recs { out = append(out, rec.toAdminUser()) } writeJSON(w, http.StatusOK, out) } type createUserRequest struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` Role string `json:"role"` } // handleCreateUser serves POST /api/admin/users. func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var in createUserRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } in.Email = strings.TrimSpace(strings.ToLower(in.Email)) if in.Email == "" { writeError(w, http.StatusBadRequest, "email is required") return } if len(in.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } role := orDefault(in.Role, "user") if role != "user" && role != "admin" { writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'") return } name := in.Name if name == "" { name = strings.SplitN(in.Email, "@", 2)[0] } payload := map[string]any{ "email": in.Email, "password": in.Password, "passwordConfirm": in.Password, "name": name, "role": role, "emailVisibility": true, "verified": true, } var rec userRecord if err := s.pb.Create(r.Context(), s.usersCollection, payload, &rec); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusCreated, rec.toAdminUser()) } type updateUserRequest struct { Name *string `json:"name"` Role *string `json:"role"` } // handleUpdateUser serves PATCH /api/admin/users/{id} (name and/or role). func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } id := r.PathValue("id") var in updateUserRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } payload := map[string]any{} if in.Name != nil { payload["name"] = *in.Name } if in.Role != nil { role := *in.Role if role != "user" && role != "admin" { writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'") return } // Guard: don't demote the last remaining admin. if role != "admin" { if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil { writePBError(w, err) return } else if blocked { writeError(w, http.StatusBadRequest, "cannot demote the last admin") return } } payload["role"] = role } if len(payload) == 0 { writeError(w, http.StatusBadRequest, "nothing to update") return } var rec userRecord if err := s.pb.Update(r.Context(), s.usersCollection, id, payload, &rec); err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, rec.toAdminUser()) } type setPasswordRequest struct { NewPassword string `json:"newPassword"` } // handleSetUserPassword serves POST /api/admin/users/{id}/password. func (s *Server) handleSetUserPassword(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var in setPasswordRequest if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if len(in.NewPassword) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } payload := map[string]any{ "password": in.NewPassword, "passwordConfirm": in.NewPassword, } if err := s.pb.Update(r.Context(), s.usersCollection, r.PathValue("id"), payload, nil); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // handleDeleteUser serves DELETE /api/admin/users/{id}. func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } id := r.PathValue("id") if id == s.currentUserID(r) { writeError(w, http.StatusBadRequest, "you cannot delete your own account here") return } // Guard: don't delete the last remaining admin. if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil { writePBError(w, err) return } else if blocked { writeError(w, http.StatusBadRequest, "cannot delete the last admin") return } if err := s.pb.Delete(r.Context(), s.usersCollection, id); err != nil { writePBError(w, err) return } w.WriteHeader(http.StatusNoContent) } // wouldRemoveLastAdmin reports whether removing/demoting user `id` would leave // zero admins (i.e. `id` is currently an admin and is the only one). func (s *Server) wouldRemoveLastAdmin(r *http.Request, id string) (bool, error) { target, err := s.fetchUser(r, id) if err != nil { return false, err } if orDefault(target.Role, "user") != "admin" { return false, nil // not an admin; removing them changes nothing } count, err := s.countAdmins(r.Context()) if err != nil { return false, err } return count <= 1, nil }