Files
paliad/internal/handlers/admin_users.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

171 lines
6.1 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
// admin_users.go — backing endpoints for the /admin/team page (t-paliad-050).
// All four routes are registered behind RequireAdminFunc in handlers.go, so
// the in-handler logic can assume the caller already passed the admin gate
// and only the operation itself needs validation.
// GET /api/admin/users — full unredacted list of every paliad.users row.
func handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
users, err := dbSvc.users.List(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, users)
}
// GET /api/admin/users/unonboarded — auth.users entries without a paliad.users
// row. Feeds the "direct add" dropdown so an admin can onboard a colleague
// who logged in but never finished the form.
func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
rows, err := dbSvc.users.ListUnonboardedAuthUsers(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/users — direct-create a paliad.users row for an existing
// auth.users entry. The recipient email's domain must already match the
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),
// but we re-check here so a stale auth.users row from before the policy
// existed can't sneak through.
func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
var input services.AdminCreateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if !isAllowedEmailDomain(input.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "email domain not on the " + branding.Name + " allow-list",
})
return
}
u, err := dbSvc.users.AdminCreateUser(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserAlreadyOnboarded):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "user already onboarded",
})
case errors.Is(err, services.ErrInvalidInput):
// AdminCreateUser uses ErrInvalidInput for both bad-shape inputs
// and the "no auth.users row for this email" case. Surfacing the
// raw message keeps the form's error display useful without a
// separate error type for each.
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
return
}
writeJSON(w, http.StatusCreated, u)
}
// PATCH /api/admin/users/{id} — mutate any paliad.users row.
func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.AdminUpdateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
u, err := dbSvc.users.AdminUpdateUser(r.Context(), id, input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
case errors.Is(err, services.ErrLastGlobalAdmin):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "cannot demote the last remaining global admin",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
return
}
writeJSON(w, http.StatusOK, u)
}
// DELETE /api/admin/users/{id} — remove a paliad.users row + cascade clean-up
// of project_teams / partner_unit_members. auth.users is left intact so the
// user can re-onboard later if needed.
func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.users.AdminDeleteUser(r.Context(), id); err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
case errors.Is(err, services.ErrLastGlobalAdmin):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "cannot delete the last remaining global admin",
})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleAdminTeamPage serves the SPA shell for /admin/team.
func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-team.html")
}
// handleAdminIndexPage serves the SPA shell for /admin — the admin landing
// page that lists current and planned admin sub-pages so the area is
// browseable. Like /admin/team, the route is gated through RequireAdminFunc
// at registration in handlers.go; non-admins get the standard 302 to
// /dashboard?forbidden=admin from rejectAdmin.
func handleAdminIndexPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin.html")
}
// handleAdminPartnerUnitsPage serves the SPA shell for /admin/partner-units.
// Same gate pattern as the other /admin/* pages.
func handleAdminPartnerUnitsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-partner-units.html")
}