Files
paliad/internal/services/supabase_admin.go
mAi 3d3a4fa36d feat(team-admin): t-paliad-223 Slice B — Add User via Supabase Admin API
#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.
2026-05-20 15:19:48 +02:00

243 lines
9.2 KiB
Go

// Package services — SupabaseAdminService — thin HTTP client for the
// privileged Supabase Admin API endpoints.
//
// t-paliad-223 Slice B (#49) — the new "Add User" path on /admin/team needs
// to create an auth.users row before inserting paliad.users (paliad.users.id
// is FK-constrained to auth.users.id). The Supabase JS / Go client library
// would be overkill for the three calls we actually make; this file is
// ~150 LoC of plain net/http instead.
//
// Only three Admin-API calls are exercised here:
//
// - POST {SUPABASE_URL}/auth/v1/admin/users
// Create an auth.users row with email_confirm=true so the user can log
// in via a recovery link without going through the email-confirm step.
//
// - POST {SUPABASE_URL}/auth/v1/admin/generate_link
// Mint a recovery link for the new user; paliad emails it via the
// existing MailService template (NOT Supabase's default mail) so the
// welcome message stays paliad-branded.
//
// - DELETE {SUPABASE_URL}/auth/v1/admin/users/{id}
// Best-effort rollback when the paliad.users insert fails after the
// auth.users row has been created. Failure here just leaves an
// unonboarded auth.users row that "Onboard existing" can recover.
//
// All requests carry the service-role key in BOTH the `apikey` header AND
// the `Authorization: Bearer` header — Supabase's PostgREST gateway checks
// the former, the auth admin handlers check the latter.
//
// SECURITY: SUPABASE_SERVICE_ROLE_KEY is one of the most-privileged
// credentials in the deploy. It must NEVER be sent to the browser or
// logged. Storage is Dokploy secret, age-encrypted at rest.
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
// ErrSupabaseAdminUnavailable signals SUPABASE_SERVICE_ROLE_KEY is unset.
// Handlers map to 503 — the Add-User path is the only feature that
// requires it; everything else keeps working.
ErrSupabaseAdminUnavailable = errors.New("supabase admin api unavailable (SUPABASE_SERVICE_ROLE_KEY not set)")
// ErrSupabaseEmailExists is returned by CreateAuthUser when the email
// already exists in auth.users. Handlers map to 409 with a nudge to
// use "Onboard existing".
ErrSupabaseEmailExists = errors.New("auth.users row already exists for this email")
)
// SupabaseAdminClient is the thin HTTP client. Constructed once at server
// boot; the embedded *http.Client is reused for connection pooling.
//
// Enabled() reports whether SUPABASE_SERVICE_ROLE_KEY is configured. When
// it isn't, every call returns ErrSupabaseAdminUnavailable so the rest of
// the boot path stays runnable for deployments that don't need Add-User.
type SupabaseAdminClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewSupabaseAdminClient wires the client. supabaseURL is required (already
// validated at boot for the anon-key flow); serviceRoleKey may be empty.
//
// Timeout is 10s — Supabase Admin API calls are normally sub-second; 10s
// is forgiving enough for cold starts on a slow network but short enough
// that a hung call doesn't block the admin UI indefinitely.
func NewSupabaseAdminClient(supabaseURL, serviceRoleKey string) *SupabaseAdminClient {
return &SupabaseAdminClient{
baseURL: strings.TrimRight(supabaseURL, "/"),
apiKey: strings.TrimSpace(serviceRoleKey),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Enabled reports whether the client has a service-role key to use.
func (c *SupabaseAdminClient) Enabled() bool {
return c != nil && c.apiKey != ""
}
// CreateAuthUser creates an auth.users row with email_confirm=true and no
// password (the new user signs in via the recovery link emailed later).
// Returns the new auth.users.id.
//
// 422 from Supabase typically means "email already exists" — mapped to
// ErrSupabaseEmailExists so the handler nudges the admin to "Onboard
// existing" instead.
func (c *SupabaseAdminClient) CreateAuthUser(ctx context.Context, email string) (uuid.UUID, error) {
if !c.Enabled() {
return uuid.Nil, ErrSupabaseAdminUnavailable
}
body := map[string]any{
"email": strings.ToLower(strings.TrimSpace(email)),
"email_confirm": true,
}
var resp struct {
ID string `json:"id"`
Msg string `json:"msg,omitempty"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/users", body, &resp)
if err != nil {
return uuid.Nil, err
}
if status == http.StatusUnprocessableEntity || status == http.StatusConflict {
// Supabase returns 422 (or sometimes 400 with "already registered"
// in the body) when the email is taken. Lower-case-match the
// substring so we catch both casings.
if strings.Contains(strings.ToLower(string(raw)), "already") {
return uuid.Nil, ErrSupabaseEmailExists
}
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
if status < 200 || status >= 300 {
return uuid.Nil, fmt.Errorf("supabase admin create user: status=%d body=%s", status, string(raw))
}
id, err := uuid.Parse(resp.ID)
if err != nil {
return uuid.Nil, fmt.Errorf("supabase admin create user: parse id %q: %w", resp.ID, err)
}
return id, nil
}
// GenerateRecoveryLink mints a one-time recovery link for an existing
// auth.users row. The action_link is what we email; clicking it lands the
// user on Supabase's password-reset page (which redirects to paliad.de
// after the user picks a password).
//
// The link type is "recovery" rather than "magiclink" so the user is forced
// to set a password — paliad doesn't support passwordless sign-in today.
func (c *SupabaseAdminClient) GenerateRecoveryLink(ctx context.Context, email string) (string, error) {
if !c.Enabled() {
return "", ErrSupabaseAdminUnavailable
}
body := map[string]any{
"type": "recovery",
"email": strings.ToLower(strings.TrimSpace(email)),
}
var resp struct {
ActionLink string `json:"action_link"`
Properties struct {
ActionLink string `json:"action_link"`
} `json:"properties"`
}
status, raw, err := c.do(ctx, "POST", "/auth/v1/admin/generate_link", body, &resp)
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("supabase admin generate_link: status=%d body=%s", status, string(raw))
}
// Supabase has historically returned the link in both shapes (top-level
// and nested under properties). Accept either.
if resp.ActionLink != "" {
return resp.ActionLink, nil
}
if resp.Properties.ActionLink != "" {
return resp.Properties.ActionLink, nil
}
return "", fmt.Errorf("supabase admin generate_link: response missing action_link: %s", string(raw))
}
// DeleteAuthUser removes an auth.users row by id. Best-effort rollback
// after the paliad.users insert has failed. A failure here is logged but
// doesn't propagate to the caller — the row can be cleaned up later via
// "Onboard existing" or the admin UI.
func (c *SupabaseAdminClient) DeleteAuthUser(ctx context.Context, id uuid.UUID) error {
if !c.Enabled() {
return ErrSupabaseAdminUnavailable
}
status, raw, err := c.do(ctx, "DELETE", "/auth/v1/admin/users/"+id.String(), nil, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("supabase admin delete user: status=%d body=%s", status, string(raw))
}
return nil
}
// do is the shared request helper. Returns (status, raw_body, err). When
// `out` is non-nil and the response is 2xx with a JSON body, decodes into
// it; raw_body is still returned so the caller can inspect error responses.
func (c *SupabaseAdminClient) do(ctx context.Context, method, path string, payload any, out any) (int, []byte, error) {
var rdr io.Reader
if payload != nil {
buf, err := json.Marshal(payload)
if err != nil {
return 0, nil, fmt.Errorf("marshal %s body: %w", path, err)
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr)
if err != nil {
return 0, nil, fmt.Errorf("build %s request: %w", path, err)
}
if rdr != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("apikey", c.apiKey)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", path, err)
}
if out != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, raw, fmt.Errorf("decode %s response: %w", path, err)
}
}
return resp.StatusCode, raw, nil
}
// LoadSupabaseAdminClient reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY
// from the environment and returns a client. The key is optional — when
// unset the client still wires (so dependents don't panic on nil-deref)
// but every call short-circuits with ErrSupabaseAdminUnavailable so the
// server boot stays runnable.
func LoadSupabaseAdminClient() *SupabaseAdminClient {
return NewSupabaseAdminClient(
os.Getenv("SUPABASE_URL"),
os.Getenv("SUPABASE_SERVICE_ROLE_KEY"),
)
}