Initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user