Files
DriverVault/API Server/internal/api/admin.go
T
2026-07-06 08:50:52 +02:00

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
}