261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
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
|
|
}
|