> = {
"admin.team.heading": "Team Management",
"admin.team.subtitle": "View, edit and add Paliad accounts.",
"admin.team.search.placeholder": "Search by name or email…",
+ "admin.team.add.full": "Add account directly",
"admin.team.add.direct": "Onboard existing account",
"admin.team.add.invite": "Invite Colleague",
+ "admin.team.add_full.title": "Add account directly",
+ "admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
+ "admin.team.add_full.email": "Email",
+ "admin.team.add_full.name": "Display name",
+ "admin.team.add_full.office": "Office",
+ "admin.team.add_full.profession": "Profession",
+ "admin.team.add_full.job_title": "Job title",
+ "admin.team.add_full.lang": "Language",
+ "admin.team.add_full.send_welcome": "Send welcome email with login link",
+ "admin.team.add_full.cancel": "Cancel",
+ "admin.team.add_full.submit": "Create",
+ "admin.team.add_full.feedback.added": "Account created.",
+ "admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
+ "admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
+ "admin.team.add_full.error.generic": "Could not create the account.",
"admin.team.loading": "Loading…",
"admin.team.empty": "No matches.",
"admin.team.error.forbidden": "Admins only.",
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index ecdf235..8bb7c69 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -440,7 +440,23 @@ export type I18nKey =
| "admin.section.planned"
| "admin.subtitle"
| "admin.team.add.direct"
+ | "admin.team.add.full"
| "admin.team.add.invite"
+ | "admin.team.add_full.body"
+ | "admin.team.add_full.cancel"
+ | "admin.team.add_full.email"
+ | "admin.team.add_full.error.email_exists"
+ | "admin.team.add_full.error.generic"
+ | "admin.team.add_full.error.unavailable"
+ | "admin.team.add_full.feedback.added"
+ | "admin.team.add_full.job_title"
+ | "admin.team.add_full.lang"
+ | "admin.team.add_full.name"
+ | "admin.team.add_full.office"
+ | "admin.team.add_full.profession"
+ | "admin.team.add_full.send_welcome"
+ | "admin.team.add_full.submit"
+ | "admin.team.add_full.title"
| "admin.team.col.actions"
| "admin.team.col.additional"
| "admin.team.col.created"
diff --git a/internal/handlers/admin_users.go b/internal/handlers/admin_users.go
index 6d3f051..67a59cf 100644
--- a/internal/handlers/admin_users.go
+++ b/internal/handlers/admin_users.go
@@ -44,6 +44,78 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
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),
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index a60fdaf..1da789d 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -509,6 +509,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
+ protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
diff --git a/internal/services/email_template_samples.go b/internal/services/email_template_samples.go
index 5587080..fa7d38f 100644
--- a/internal/services/email_template_samples.go
+++ b/internal/services/email_template_samples.go
@@ -12,6 +12,8 @@ func EmailTemplateSampleData(key, lang, slot string) map[string]any {
switch key {
case EmailTemplateKeyInvitation:
return invitationSample(lang)
+ case EmailTemplateKeyAddUserWelcome:
+ return addUserWelcomeSample(lang)
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestSample(lang, slot)
case EmailTemplateKeyBase:
@@ -98,6 +100,30 @@ func deadlineDigestSample(lang, slot string) map[string]any {
}
}
+// t-paliad-223 Slice B (#49) — sample data for the Add-User welcome mail.
+// The variable contract mirrors what UserService.AdminCreateUserFull
+// passes to MailService.SendTemplate at runtime.
+func addUserWelcomeSample(lang string) map[string]any {
+ if lang == "en" {
+ return map[string]any{
+ "InviterName": "Maria Schmidt",
+ "InviterEmail": "maria.schmidt@hlc.com",
+ "ToEmail": "new.colleague@hlc.com",
+ "MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
+ "BaseURL": "https://paliad.de",
+ "Firm": "HLC",
+ }
+ }
+ return map[string]any{
+ "InviterName": "Maria Schmidt",
+ "InviterEmail": "maria.schmidt@hlc.com",
+ "ToEmail": "neu.kollege@hlc.de",
+ "MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=…",
+ "BaseURL": "https://paliad.de",
+ "Firm": "HLC",
+ }
+}
+
func baseSample(lang string) map[string]any {
subj := "Beispielbetreff"
if lang == "en" {
diff --git a/internal/services/email_template_service.go b/internal/services/email_template_service.go
index 8694a69..4d9abe5 100644
--- a/internal/services/email_template_service.go
+++ b/internal/services/email_template_service.go
@@ -41,11 +41,17 @@ const (
EmailTemplateKeyInvitation = "invitation"
EmailTemplateKeyDeadlineDigest = "deadline_digest"
EmailTemplateKeyBase = "base"
+ // EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when
+ // a global_admin directly creates a paliad.users + auth.users pair from
+ // /admin/team's "Konto direkt anlegen" form. Carries a Supabase
+ // recovery-link so the new colleague can set their own password.
+ EmailTemplateKeyAddUserWelcome = "add_user_welcome"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
+ EmailTemplateKeyAddUserWelcome,
EmailTemplateKeyDeadlineDigest,
EmailTemplateKeyBase,
}
@@ -420,6 +426,10 @@ var defaultSubjects = map[string]map[string]string{
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
},
+ EmailTemplateKeyAddUserWelcome: {
+ "de": `[Paliad] Ihr Paliad-Konto ist bereit`,
+ "en": `[Paliad] Your Paliad account is ready`,
+ },
EmailTemplateKeyDeadlineDigest: {
"de": digestSubjectDE,
"en": digestSubjectEN,
diff --git a/internal/services/email_template_variables.go b/internal/services/email_template_variables.go
index 0ee3aa3..2ef6cfc 100644
--- a/internal/services/email_template_variables.go
+++ b/internal/services/email_template_variables.go
@@ -21,6 +21,8 @@ func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
+ case EmailTemplateKeyAddUserWelcome:
+ return addUserWelcomeVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
@@ -51,6 +53,30 @@ var invitationVariables = []EmailTemplateVariable{
SampleDE: "HLC", SampleEN: "HLC"},
}
+// t-paliad-223 Slice B (#49) — variables consumed by the Add-User welcome
+// mail. UserService.AdminCreateUserFull populates these at send time.
+var addUserWelcomeVariables = []EmailTemplateVariable{
+ {Name: ".InviterName", Type: "string",
+ Description: "Anzeigename der/des global_admin, die das Konto angelegt hat.",
+ SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
+ {Name: ".InviterEmail", Type: "string",
+ Description: "E-Mail-Adresse der/des global_admin.",
+ SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
+ {Name: ".ToEmail", Type: "string",
+ Description: "Empfänger:in (E-Mail der neuen Person).",
+ SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
+ {Name: ".MagicLink", Type: "string",
+ Description: "Einmaliger Supabase-Recovery-Link zum Passwort-Setzen.",
+ SampleDE: "https://supabase.paliad.de/auth/v1/verify?token=…",
+ SampleEN: "https://supabase.paliad.de/auth/v1/verify?token=…"},
+ {Name: ".BaseURL", Type: "string",
+ Description: "Öffentliche Paliad-URL (PALIAD_BASE_URL).",
+ SampleDE: "https://paliad.de", SampleEN: "https://paliad.de"},
+ {Name: ".Firm", Type: "string",
+ Description: "Firmenname (FIRM_NAME).",
+ SampleDE: "HLC", SampleEN: "HLC"},
+}
+
var deadlineDigestVariables = []EmailTemplateVariable{
{Name: ".Slot", Type: "string",
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",
diff --git a/internal/services/mail_service_test.go b/internal/services/mail_service_test.go
index c667362..b605a54 100644
--- a/internal/services/mail_service_test.go
+++ b/internal/services/mail_service_test.go
@@ -173,6 +173,53 @@ func TestRenderTemplateInvitation(t *testing.T) {
}
}
+// TestRenderTemplateAddUserWelcome — t-paliad-223 Slice B (#49). Catches
+// a typo in either add_user_welcome.{de,en}.html: the rendered body must
+// contain the inviter, the magic-link, the firm name, and the localised
+// fallback subject from defaultSubjects must look right.
+func TestRenderTemplateAddUserWelcome(t *testing.T) {
+ svc, err := NewMailService()
+ if err != nil {
+ t.Fatalf("NewMailService: %v", err)
+ }
+ for _, lang := range []string{"de", "en"} {
+ t.Run(lang, func(t *testing.T) {
+ subject, html, err := svc.RenderTemplate(TemplateData{
+ Lang: lang,
+ Name: EmailTemplateKeyAddUserWelcome,
+ Data: map[string]any{
+ "InviterName": "Maria Schmidt",
+ "InviterEmail": "maria@hlc.com",
+ "ToEmail": "neu.kollege@hlc.de",
+ "MagicLink": "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
+ "BaseURL": "https://paliad.de",
+ },
+ })
+ if err != nil {
+ t.Fatalf("RenderTemplate: %v", err)
+ }
+ for _, want := range []string{
+ "Maria Schmidt", "neu.kollege@hlc.de",
+ "https://supabase.paliad.de/auth/v1/verify?token=TESTTOKEN",
+ "https://paliad.de/login",
+ // {{.Firm}} placeholder must render — branding default is "HLC".
+ "HLC",
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("[%s] rendered html missing %q", lang, want)
+ }
+ }
+ wantSubject := "[Paliad] Ihr Paliad-Konto ist bereit"
+ if lang == "en" {
+ wantSubject = "[Paliad] Your Paliad account is ready"
+ }
+ if subject != wantSubject {
+ t.Errorf("[%s] subject got %q, want %q", lang, subject, wantSubject)
+ }
+ })
+ }
+}
+
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
// carries both the text and HTML parts — an earlier refactor dropped one
// part by mistake, caught by this.
diff --git a/internal/services/supabase_admin.go b/internal/services/supabase_admin.go
new file mode 100644
index 0000000..8b9d8f2
--- /dev/null
+++ b/internal/services/supabase_admin.go
@@ -0,0 +1,242 @@
+// 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"),
+ )
+}
diff --git a/internal/services/supabase_admin_test.go b/internal/services/supabase_admin_test.go
new file mode 100644
index 0000000..c9f3ab7
--- /dev/null
+++ b/internal/services/supabase_admin_test.go
@@ -0,0 +1,154 @@
+// Unit tests for the Supabase admin HTTP client. The client is a thin
+// shim over net/http; coverage lives at the wire-shape level: header
+// presence, request method, body decode, status-code → error mapping.
+// No live Supabase call — every test runs against an httptest.Server.
+
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestSupabaseAdminClient_Disabled(t *testing.T) {
+ c := NewSupabaseAdminClient("https://example.invalid", "")
+ if c.Enabled() {
+ t.Fatal("Enabled() must be false when service-role key is empty")
+ }
+ ctx := context.Background()
+ if _, err := c.CreateAuthUser(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
+ t.Errorf("CreateAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
+ }
+ if _, err := c.GenerateRecoveryLink(ctx, "x@hlc.com"); !errors.Is(err, ErrSupabaseAdminUnavailable) {
+ t.Errorf("GenerateRecoveryLink must return ErrSupabaseAdminUnavailable, got %v", err)
+ }
+ if err := c.DeleteAuthUser(ctx, uuid.New()); !errors.Is(err, ErrSupabaseAdminUnavailable) {
+ t.Errorf("DeleteAuthUser must return ErrSupabaseAdminUnavailable, got %v", err)
+ }
+}
+
+// TestSupabaseAdminClient_CreateAuthUser_Happy pins the wire-shape:
+// POST /auth/v1/admin/users, JSON body with email_confirm=true, both
+// apikey + Authorization headers present, parses the response id.
+func TestSupabaseAdminClient_CreateAuthUser_Happy(t *testing.T) {
+ wantID := uuid.New()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ t.Errorf("method = %q, want POST", r.Method)
+ }
+ if r.URL.Path != "/auth/v1/admin/users" {
+ t.Errorf("path = %q, want /auth/v1/admin/users", r.URL.Path)
+ }
+ if r.Header.Get("apikey") != "service-key" {
+ t.Errorf("missing apikey header")
+ }
+ if r.Header.Get("Authorization") != "Bearer service-key" {
+ t.Errorf("missing Bearer header")
+ }
+ body, _ := io.ReadAll(r.Body)
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if got["email"] != "x@hlc.com" {
+ t.Errorf("email = %v, want x@hlc.com", got["email"])
+ }
+ if got["email_confirm"] != true {
+ t.Errorf("email_confirm = %v, want true", got["email_confirm"])
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]string{"id": wantID.String()})
+ }))
+ defer srv.Close()
+
+ c := NewSupabaseAdminClient(srv.URL, "service-key")
+ gotID, err := c.CreateAuthUser(context.Background(), " X@HLC.COM ")
+ if err != nil {
+ t.Fatalf("CreateAuthUser: %v", err)
+ }
+ if gotID != wantID {
+ t.Errorf("id = %s, want %s", gotID, wantID)
+ }
+}
+
+// TestSupabaseAdminClient_CreateAuthUser_EmailExists pins the 422-with-
+// "already" body → ErrSupabaseEmailExists translation. Mapped to 409 by
+// the handler.
+func TestSupabaseAdminClient_CreateAuthUser_EmailExists(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"msg":"A user with this email address has already been registered"}`))
+ }))
+ defer srv.Close()
+ c := NewSupabaseAdminClient(srv.URL, "service-key")
+ _, err := c.CreateAuthUser(context.Background(), "dup@hlc.com")
+ if !errors.Is(err, ErrSupabaseEmailExists) {
+ t.Fatalf("got %v, want ErrSupabaseEmailExists", err)
+ }
+}
+
+// TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes — Supabase has
+// historically returned the link at top-level and nested under
+// properties. Both shapes must be accepted.
+func TestSupabaseAdminClient_GenerateRecoveryLink_BothShapes(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ want string
+ }{
+ {"top-level", `{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=A"}`, "https://supabase.paliad.de/auth/v1/verify?token=A"},
+ {"nested", `{"properties":{"action_link":"https://supabase.paliad.de/auth/v1/verify?token=B"}}`, "https://supabase.paliad.de/auth/v1/verify?token=B"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/auth/v1/admin/generate_link" {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(body), `"type":"recovery"`) {
+ t.Errorf("body missing type=recovery: %s", body)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(tc.body))
+ }))
+ defer srv.Close()
+ c := NewSupabaseAdminClient(srv.URL, "service-key")
+ got, err := c.GenerateRecoveryLink(context.Background(), "x@hlc.com")
+ if err != nil {
+ t.Fatalf("GenerateRecoveryLink: %v", err)
+ }
+ if got != tc.want {
+ t.Errorf("link = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestSupabaseAdminClient_DeleteAuthUser pins the DELETE-by-id route shape
+// + 2xx happy path; the cleanup runs after a paliad.users insert failure
+// in AdminCreateUserFull, so the round-trip needs to work even with a
+// short context window.
+func TestSupabaseAdminClient_DeleteAuthUser(t *testing.T) {
+ id := uuid.New()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "DELETE" {
+ t.Errorf("method = %q", r.Method)
+ }
+ if r.URL.Path != "/auth/v1/admin/users/"+id.String() {
+ t.Errorf("path = %q", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+ c := NewSupabaseAdminClient(srv.URL, "service-key")
+ if err := c.DeleteAuthUser(context.Background(), id); err != nil {
+ t.Errorf("DeleteAuthUser: %v", err)
+ }
+}
diff --git a/internal/services/user_service.go b/internal/services/user_service.go
index 57858a9..650a26b 100644
--- a/internal/services/user_service.go
+++ b/internal/services/user_service.go
@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log/slog"
+ "net/mail"
"strings"
"time"
@@ -56,8 +58,18 @@ var (
// UserService reads paliad.users. Writes happen via the Phase D onboarding
// endpoint and are not exposed here yet.
+//
+// supabase + mail + baseURL are optional dependencies wired post-construction
+// via SetAddUserDeps (t-paliad-223 Slice B). They power the new "Add User"
+// path on /admin/team which creates an auth.users row directly and emails
+// a paliad-branded welcome message. Older paths (Create / AdminCreateUser /
+// AdminUpdateUser / AdminDeleteUser) do not touch these fields and stay
+// runnable when supabase admin is unwired.
type UserService struct {
- db *sqlx.DB
+ db *sqlx.DB
+ supabase *SupabaseAdminClient
+ mail *MailService
+ baseURL string
}
// NewUserService wires the service to the pool.
@@ -65,6 +77,17 @@ func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
+// SetAddUserDeps injects the dependencies needed for AdminCreateUserFull
+// (t-paliad-223 Slice B). Called from cmd/server/main.go once supabase
+// admin + mail services + base URL are known. Safe to omit when the
+// deploy doesn't need the new "Add User" path — AdminCreateUserFull will
+// return ErrSupabaseAdminUnavailable in that case.
+func (s *UserService) SetAddUserDeps(supabase *SupabaseAdminClient, mail *MailService, baseURL string) {
+ s.supabase = supabase
+ s.mail = mail
+ s.baseURL = baseURL
+}
+
const userColumns = `id, email, display_name, office, additional_offices, practice_group,
job_title, global_role,
lang, email_preferences,
@@ -584,6 +607,193 @@ func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInpu
return s.GetByID(ctx, authID)
}
+// AdminCreateFullInput is the payload for AdminCreateUserFull (t-paliad-223
+// Slice B / m/paliad#49) — the "Konto direkt anlegen" path on /admin/team.
+//
+// Unlike AdminCreateUser this path does NOT require a pre-existing
+// auth.users row: it creates that row via the Supabase Admin API before
+// inserting paliad.users in the same tx. The two-step nature means an
+// auth.users row may exist with no paliad.users row if the second step
+// fails — recovery is via "Onboard existing".
+type AdminCreateFullInput struct {
+ Email string `json:"email"` // required
+ DisplayName string `json:"display_name"` // required
+ Office string `json:"office"` // required, validated against offices.IsValid
+ JobTitle string `json:"job_title,omitempty"`
+ Profession string `json:"profession,omitempty"`
+ Lang string `json:"lang,omitempty"`
+ SendWelcomeMail bool `json:"send_welcome_mail"` // default-on at the handler layer
+ // InviterID + InviterName + InviterEmail describe the global_admin
+ // performing the create. Used for the welcome-email template variables
+ // + the system_audit_log row. Filled by the handler from auth.uid()
+ // before the call, NOT from the request body, so a malicious admin
+ // can't impersonate another inviter.
+ InviterID uuid.UUID `json:"-"`
+ InviterName string `json:"-"`
+ InviterEmail string `json:"-"`
+}
+
+// AdminCreateUserFull creates both an auth.users row (via Supabase Admin
+// API) AND a paliad.users row in one operation. Returns the new
+// paliad.users row.
+//
+// Two-step flow with best-effort rollback:
+// 1. Validate input (email format, allowed-domain check happens at the
+// handler; office + profession + lang validated here).
+// 2. POST /auth/v1/admin/users → auth_id. ErrSupabaseEmailExists if taken.
+// 3. INSERT paliad.users in a tx; on failure DELETE /auth/v1/admin/users/{id}
+// to roll back.
+// 4. system_audit_log row written (best-effort; failure logged not raised).
+// 5. If SendWelcomeMail: GenerateRecoveryLink + MailService.SendTemplate
+// (best-effort; the user-create succeeds regardless).
+//
+// Returns ErrSupabaseAdminUnavailable when SUPABASE_SERVICE_ROLE_KEY is
+// unset (handler maps to 503). Returns ErrUserAlreadyOnboarded if a
+// paliad.users row exists for the same email already (defensive — should
+// be unreachable given step 2 catches the auth.users dup first).
+func (s *UserService) AdminCreateUserFull(ctx context.Context, input AdminCreateFullInput) (*models.User, error) {
+ if s.supabase == nil || !s.supabase.Enabled() {
+ return nil, ErrSupabaseAdminUnavailable
+ }
+
+ email := strings.ToLower(strings.TrimSpace(input.Email))
+ if email == "" {
+ return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
+ }
+ if _, err := mail.ParseAddress(email); err != nil {
+ return nil, fmt.Errorf("%w: invalid email %q", ErrInvalidInput, input.Email)
+ }
+ displayName := strings.TrimSpace(input.DisplayName)
+ if displayName == "" {
+ return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
+ }
+ if !offices.IsValid(input.Office) {
+ return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
+ }
+ jobTitle := strings.TrimSpace(input.JobTitle)
+ if jobTitle == "" {
+ jobTitle = "Associate"
+ }
+ profession := strings.TrimSpace(input.Profession)
+ if profession == "" {
+ profession = ProfessionAssociate
+ }
+ if !IsValidProfession(profession) {
+ return nil, fmt.Errorf("%w: invalid profession %q", ErrInvalidInput, profession)
+ }
+ lang := strings.ToLower(strings.TrimSpace(input.Lang))
+ if lang == "" {
+ lang = "de"
+ }
+ if lang != "de" && lang != "en" {
+ return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, input.Lang)
+ }
+
+ // Cheap pre-check on paliad.users — catches the rare case where
+ // paliad has a row but auth.users got swept (e.g. a Supabase support
+ // purge). The Admin-API call would still succeed and we'd hit a unique
+ // constraint on the FK in step 3.
+ var exists bool
+ if err := s.db.GetContext(ctx, &exists,
+ `SELECT EXISTS (SELECT 1 FROM paliad.users WHERE lower(email) = $1)`, email); err != nil {
+ return nil, fmt.Errorf("pre-check email: %w", err)
+ }
+ if exists {
+ return nil, ErrUserAlreadyOnboarded
+ }
+
+ // Step 2 — auth.users via Supabase Admin API. ErrSupabaseEmailExists
+ // bubbles to the handler unchanged (409 with a "use Onboard existing"
+ // hint).
+ authID, err := s.supabase.CreateAuthUser(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+
+ // Step 3 — paliad.users insert with rollback. The tx-rollback only
+ // reverts the paliad insert; the auth.users row needs an explicit
+ // delete because it lives in a different Postgres schema and is
+ // managed by Supabase's GoTrue, not our migration set.
+ rollbackAuth := func() {
+ // Detached context so a cancelled parent doesn't abort the cleanup.
+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if delErr := s.supabase.DeleteAuthUser(cleanupCtx, authID); delErr != nil {
+ // Best-effort: log + leave a recoverable orphan rather than
+ // raising a new error.
+ slog.Warn("admin_create_full: rollback DeleteAuthUser failed", "auth_id", authID, "err", delErr)
+ }
+ }
+
+ if _, err := s.db.ExecContext(ctx,
+ `INSERT INTO paliad.users (id, email, display_name, office, job_title, profession, global_role, lang)
+ VALUES ($1, $2, $3, $4, $5, $6, 'standard', $7)`,
+ authID, email, displayName, input.Office, jobTitle, profession, lang,
+ ); err != nil {
+ rollbackAuth()
+ return nil, fmt.Errorf("insert paliad.users: %w", err)
+ }
+
+ // Step 4 — audit row. Best-effort; an audit failure shouldn't break
+ // the user-create. Captured under a fresh context so the row is
+ // preserved even if the request context is on the verge of timing out.
+ auditCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ if _, err := s.db.ExecContext(auditCtx,
+ `INSERT INTO paliad.system_audit_log
+ (event_type, actor_id, actor_email, scope, scope_root, metadata)
+ VALUES ('user.added_by_admin', $1, $2, 'org', NULL, $3::jsonb)`,
+ nullableUUID(input.InviterID), input.InviterEmail,
+ fmt.Sprintf(`{"created_user_id":"%s","email":"%s","sent_welcome":%t}`,
+ authID, email, input.SendWelcomeMail),
+ ); err != nil {
+ slog.Warn("admin_create_full: audit insert failed", "auth_id", authID, "err", err)
+ }
+ cancel()
+
+ // Step 5 — welcome email. Best-effort; failure logged + returned in
+ // the result so the admin can retry the recovery-link send separately.
+ if input.SendWelcomeMail {
+ if err := s.sendAddUserWelcome(ctx, email, lang, input); err != nil {
+ slog.Warn("admin_create_full: welcome mail failed", "auth_id", authID, "err", err)
+ // Surfaced as a non-fatal warning via the returned model's
+ // caller-visible side channel? For v1 we just log — the
+ // admin can re-send via /admin/team's "Recovery link" follow-up
+ // (filed as out-of-scope in design §3).
+ }
+ }
+
+ return s.GetByID(ctx, authID)
+}
+
+// sendAddUserWelcome generates the recovery link and dispatches the
+// branded welcome email. Errors propagate so the caller can log them; the
+// caller (AdminCreateUserFull) decides whether they're fatal.
+func (s *UserService) sendAddUserWelcome(ctx context.Context, email, lang string, input AdminCreateFullInput) error {
+ if s.mail == nil {
+ return errors.New("mail service not wired")
+ }
+ link, err := s.supabase.GenerateRecoveryLink(ctx, email)
+ if err != nil {
+ return fmt.Errorf("generate recovery link: %w", err)
+ }
+ baseURL := s.baseURL
+ if baseURL == "" {
+ baseURL = "https://paliad.de"
+ }
+ return s.mail.SendTemplate(TemplateData{
+ To: email,
+ Lang: lang,
+ Name: EmailTemplateKeyAddUserWelcome,
+ Data: map[string]any{
+ "InviterName": input.InviterName,
+ "InviterEmail": input.InviterEmail,
+ "ToEmail": email,
+ "MagicLink": link,
+ "BaseURL": baseURL,
+ },
+ })
+}
+
// AdminUpdateInput is the payload for AdminUpdateUser. Same shape as
// UpdateProfileInput but additionally allows the additional_offices array
// (which the self-service settings page does not expose).
diff --git a/internal/templates/email/add_user_welcome.de.html b/internal/templates/email/add_user_welcome.de.html
new file mode 100644
index 0000000..41cb1e1
--- /dev/null
+++ b/internal/templates/email/add_user_welcome.de.html
@@ -0,0 +1,12 @@
+{{define "content"}}
+Willkommen bei Paliad
+{{.InviterName}} hat ein Konto für Sie bei Paliad — der Patent-Praxis-Plattform für {{.Firm}} — angelegt.
+Bitte legen Sie ein Passwort fest, um sich zum ersten Mal anzumelden:
+
+
+ Passwort festlegen und anmelden
+
+
+Der Link ist 24 Stunden gültig. Anschließend können Sie sich jederzeit unter {{.BaseURL}}/login mit Ihrer E-Mail-Adresse {{.ToEmail}} und dem neuen Passwort einloggen.
+Angelegt von {{.InviterEmail}}. Falls Sie diese Nachricht unerwartet erhalten, können Sie sie ignorieren — ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.
+{{end}}
diff --git a/internal/templates/email/add_user_welcome.en.html b/internal/templates/email/add_user_welcome.en.html
new file mode 100644
index 0000000..28217ff
--- /dev/null
+++ b/internal/templates/email/add_user_welcome.en.html
@@ -0,0 +1,12 @@
+{{define "content"}}
+Welcome to Paliad
+{{.InviterName}} has created a Paliad account for you — Paliad is the patent practice platform for {{.Firm}}.
+Please set a password to sign in for the first time:
+
+
+ Set password and sign in
+
+
+The link is valid for 24 hours. After that, you can always sign in at {{.BaseURL}}/login with your email {{.ToEmail}} and the new password.
+Created by {{.InviterEmail}}. If you weren't expecting this message you can ignore it — without setting a password the account stays unusable.
+{{end}}