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") }