#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
9.2 KiB
Go
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"),
|
|
)
|
|
}
|