#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside "Onboard existing" and "Invite colleague". Creates both auth.users (via Supabase Admin API) and paliad.users in one click; new user is visible in dropdowns immediately and receives a paliad-branded magic-link email. - internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping. - internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route). - internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort). - internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB). - internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars). - internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject. - internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other). - internal/handlers/handlers.go: route registered behind adminGate. - cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active. - frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on). - frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur. - i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN. Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head. go build && go test -short ./internal/... + bun run build all green.
243 lines
8.9 KiB
Go
243 lines
8.9 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/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")
|
|
}
|