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/full — create BOTH an auth.users row (via Supabase // Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B // (#49). Lets a global_admin onboard a colleague without forcing them // through the email-invitation round-trip; the new user is visible in // dropdowns immediately and can log in via the emailed magic-link. // // Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when // unset so a deploy that hasn't provisioned the credential yet gets a // clear diagnostic instead of a cryptic 500. // // Error mapping: // - ErrSupabaseAdminUnavailable → 503 // - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing") // - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable) // - ErrInvalidInput → 400 (bad shape) // - email domain not on whitelist → 403 (mirrors handleAdminCreateUser) // - other → 500 func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return } uid, ok := requireUser(w, r) if !ok { return } var input services.AdminCreateFullInput 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 } // Look up the inviter (the calling admin) so the welcome email and // audit row carry their identity. Failures here shouldn't block the // create; we just degrade to empty fields. inviter, err := dbSvc.users.GetByID(r.Context(), uid) if err == nil && inviter != nil { input.InviterID = inviter.ID input.InviterName = inviter.DisplayName input.InviterEmail = inviter.Email } u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input) if err != nil { switch { case errors.Is(err, services.ErrSupabaseAdminUnavailable): writeJSON(w, http.StatusServiceUnavailable, map[string]string{ "error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server", }) case errors.Is(err, services.ErrSupabaseEmailExists): writeJSON(w, http.StatusConflict, map[string]string{ "error": "auth account already exists — please use 'Onboard existing' instead", }) case errors.Is(err, services.ErrUserAlreadyOnboarded): writeJSON(w, http.StatusConflict, map[string]string{ "error": "user already onboarded", }) 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.StatusCreated, u) } // 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") }