#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.
148 lines
6.8 KiB
Go
148 lines
6.8 KiB
Go
// Variable contracts for the /admin/email-templates editor. Surfaces in the
|
|
// editor sidebar so an admin sees what `{{.Foo}}` placeholders are
|
|
// available and what each renders to with sample data. Single source of
|
|
// truth for both the docs and the sample data.
|
|
package services
|
|
|
|
// EmailTemplateVariable describes one placeholder a template may reference.
|
|
// Type is informational ("string", "[]Row", "bool", etc.); the editor uses
|
|
// it for rendering, not for validation.
|
|
type EmailTemplateVariable struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Description string `json:"description"`
|
|
SampleDE string `json:"sample_de"`
|
|
SampleEN string `json:"sample_en"`
|
|
}
|
|
|
|
// EmailTemplateVariables returns the variable contract for (key). The list
|
|
// is identical across languages; the SampleDE/SampleEN fields differ.
|
|
func EmailTemplateVariables(key string) []EmailTemplateVariable {
|
|
switch key {
|
|
case EmailTemplateKeyInvitation:
|
|
return invitationVariables
|
|
case EmailTemplateKeyAddUserWelcome:
|
|
return addUserWelcomeVariables
|
|
case EmailTemplateKeyDeadlineDigest:
|
|
return deadlineDigestVariables
|
|
case EmailTemplateKeyBase:
|
|
return baseVariables
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var invitationVariables = []EmailTemplateVariable{
|
|
{Name: ".InviterName", Type: "string",
|
|
Description: "Anzeigename der einladenden Person.",
|
|
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
|
|
{Name: ".InviterEmail", Type: "string",
|
|
Description: "E-Mail-Adresse der einladenden Person.",
|
|
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
|
|
{Name: ".ToEmail", Type: "string",
|
|
Description: "Empfänger:in der Einladung.",
|
|
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
|
|
{Name: ".Message", Type: "string (optional)",
|
|
Description: "Persönliche Nachricht der einladenden Person. Der Body sollte den Block mit {{if .Message}}…{{end}} umschliessen.",
|
|
SampleDE: "Hallo Kolleg:in — schau es dir an.", SampleEN: "Hi — have a look."},
|
|
{Name: ".RegisterURL", Type: "string",
|
|
Description: "Link zum Login/Registrierungs-Endpunkt.",
|
|
SampleDE: "https://paliad.de/login", SampleEN: "https://paliad.de/login"},
|
|
{Name: ".Firm", Type: "string",
|
|
Description: "Firmenname (FIRM_NAME). Wird im Body und Footer verwendet.",
|
|
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.",
|
|
SampleDE: "morning", SampleEN: "morning"},
|
|
{Name: ".IsEvening", Type: "bool",
|
|
Description: "True im Abend-Slot — steuert die DRINGEND/URGENT-Headline.",
|
|
SampleDE: "false", SampleEN: "false"},
|
|
{Name: ".Overdue", Type: "[]Row",
|
|
Description: "Überfällige Fristen. Iteriere mit {{range .Overdue}}…{{end}}.",
|
|
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
|
|
{Name: ".OverdueCount", Type: "int",
|
|
Description: "Länge von .Overdue, vorgerechnet für Überschriften.",
|
|
SampleDE: "1", SampleEN: "1"},
|
|
{Name: ".DueToday", Type: "[]Row",
|
|
Description: "Heute fällige Fristen.",
|
|
SampleDE: "2 Einträge", SampleEN: "2 entries"},
|
|
{Name: ".DueTodayCount", Type: "int",
|
|
Description: "Länge von .DueToday.",
|
|
SampleDE: "2", SampleEN: "2"},
|
|
{Name: ".DueWarning", Type: "[]Row",
|
|
Description: "Innerhalb der Vorwarnung fällige Fristen (typisch: ≤ 7 Tage).",
|
|
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
|
|
{Name: ".DueWarningCount", Type: "int",
|
|
Description: "Länge von .DueWarning.",
|
|
SampleDE: "1", SampleEN: "1"},
|
|
{Name: ".OpenTotal", Type: "int",
|
|
Description: "Summe von .DueTodayCount + .DueWarningCount, vorgerechnet für die Betreffzeile.",
|
|
SampleDE: "3", SampleEN: "3"},
|
|
{Name: ".DeadlinesURL", Type: "string",
|
|
Description: "Ziel des „Alle Fristen / All deadlines\"-Buttons.",
|
|
SampleDE: "https://paliad.de/deadlines", SampleEN: "https://paliad.de/deadlines"},
|
|
{Name: ".Firm", Type: "string",
|
|
Description: "Firmenname (FIRM_NAME).",
|
|
SampleDE: "HLC", SampleEN: "HLC"},
|
|
{Name: "Row.DueDate", Type: "string (ISO)",
|
|
Description: "Fälligkeitsdatum, ISO YYYY-MM-DD.",
|
|
SampleDE: "2026-04-29", SampleEN: "2026-04-29"},
|
|
{Name: "Row.Title", Type: "string",
|
|
Description: "Frist-Titel.",
|
|
SampleDE: "Klageerwiderung einreichen", SampleEN: "File reply to complaint"},
|
|
{Name: "Row.ProjectReference", Type: "string",
|
|
Description: "Akten-/Projekt-Aktenzeichen.",
|
|
SampleDE: "HL-2025-0011", SampleEN: "HL-2025-0011"},
|
|
{Name: "Row.ProjectTitle", Type: "string (optional)",
|
|
Description: "Projekt-Titel; kann leer sein.",
|
|
SampleDE: "Gamma AG vs Delta Inc", SampleEN: "Gamma AG vs Delta Inc"},
|
|
{Name: "Row.OwnerName", Type: "string",
|
|
Description: "Eigentümer:in der Frist.",
|
|
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
|
|
{Name: "Row.IsOtherOwner", Type: "bool",
|
|
Description: "True wenn die Frist nicht der/dem Empfänger:in gehört (zeigt Eigentümer-Hinweis).",
|
|
SampleDE: "true", SampleEN: "true"},
|
|
{Name: "Row.URL", Type: "string",
|
|
Description: "Direktlink zur Frist-Detailseite.",
|
|
SampleDE: "https://paliad.de/deadlines/<uuid>", SampleEN: "https://paliad.de/deadlines/<uuid>"},
|
|
}
|
|
|
|
var baseVariables = []EmailTemplateVariable{
|
|
{Name: ".Lang", Type: "string",
|
|
Description: "Sprache der Mail. Wird in <html lang=\"…\"> eingesetzt.",
|
|
SampleDE: "de", SampleEN: "en"},
|
|
{Name: ".Subject", Type: "string",
|
|
Description: "Vom Inhalts-Template übergebene Betreffzeile. Erscheint im <title>-Tag.",
|
|
SampleDE: "Beispielbetreff", SampleEN: "Example subject"},
|
|
{Name: ".Firm", Type: "string",
|
|
Description: "Firmenname (FIRM_NAME).",
|
|
SampleDE: "HLC", SampleEN: "HLC"},
|
|
}
|