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.
This commit is contained in:
mAi
2026-05-20 15:18:42 +02:00
parent 1c021ed515
commit 3d3a4fa36d
16 changed files with 1053 additions and 1 deletions

View File

@@ -128,6 +128,20 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
// new "Konto direkt anlegen" path on /admin/team. The key is
// optional: when unset the client still wires (so dependents
// don't panic) but every call short-circuits with
// ErrSupabaseAdminUnavailable so the rest of the server stays
// runnable.
supabaseAdminClient := services.LoadSupabaseAdminClient()
if supabaseAdminClient.Enabled() {
log.Println("supabase admin API configured — /admin/team Add-User path active")
} else {
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
}
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet

View File

@@ -33,6 +33,9 @@ export function renderAdminTeam(): string {
</p>
</div>
<div className="admin-team-actions">
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
Konto direkt anlegen
</button>
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
Bestehendes Konto onboarden
</button>
@@ -132,6 +135,67 @@ export function renderAdminTeam(): string {
</div>
</div>
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
Creates BOTH the auth.users row (via Supabase Admin API) and
the paliad.users row in one click. New user is visible in
dropdowns immediately. */}
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erh&auml;lt eine E-Mail mit einem Link, &uuml;ber den sie ein Passwort setzt.
</p>
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
<input type="text" id="admin-af-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
<select id="admin-af-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
<select id="admin-af-profession" name="profession">
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
<select id="admin-af-lang" name="lang">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
</div>
<label className="form-checkbox">
<input type="checkbox" id="admin-af-send-welcome" checked />
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
</label>
<div id="admin-af-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
</div>
</form>
</div>
</div>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-team.js"></script>

View File

@@ -468,11 +468,125 @@ function initInviteButton() {
});
}
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
// the auth.users row (via Supabase Admin API) and the paliad.users row in
// one POST. New user appears in dropdowns immediately. Welcome email with
// magic-link is sent by default; admin can opt out via the checkbox.
function openAddFullModal() {
const modal = document.getElementById("admin-add-full-modal")!;
const fb = document.getElementById("admin-af-feedback")!;
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
fb.style.display = "none";
emailField.value = "";
nameField.value = "";
jobTitleField.value = "";
profSel.value = "associate";
langSel.value = "de";
sendWelcome.checked = true;
officeSel.innerHTML = officeOptions("munich");
modal.style.display = "flex";
emailField.focus();
}
function closeAddFullModal() {
document.getElementById("admin-add-full-modal")!.style.display = "none";
}
function initAddFullModal() {
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeAddFullModal();
});
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
// Pre-fill the display name from the email local-part the first time the
// admin tabs out of the email field — mirrors the existing onboard flow.
emailField.addEventListener("blur", () => {
if (nameField.value || !emailField.value) return;
const local = emailField.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
});
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-af-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
const payload: Record<string, unknown> = {
email: emailField.value.trim().toLowerCase(),
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
profession: profSel.value,
lang: langSel.value,
send_welcome_mail: sendWelcome.checked,
};
submitBtn.disabled = true;
try {
const resp = await fetch("/api/admin/users/full", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
// Map two friendly cases inline; everything else surfaces the
// server message so the admin can act on it.
if (resp.status === 503) {
fb.textContent = t("admin.team.add_full.error.unavailable")
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
} else if (resp.status === 409) {
fb.textContent = body.error
|| (t("admin.team.add_full.error.email_exists")
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
} else {
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
}
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeAddFullModal();
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
render();
} finally {
submitBtn.disabled = false;
}
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initAddFullModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();

View File

@@ -2077,8 +2077,24 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.team.heading": "Team-Verwaltung",
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
"admin.team.add.full": "Konto direkt anlegen",
"admin.team.add.direct": "Bestehendes Konto onboarden",
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
"admin.team.add_full.title": "Konto direkt anlegen",
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
"admin.team.add_full.email": "E-Mail",
"admin.team.add_full.name": "Anzeigename",
"admin.team.add_full.office": "Standort",
"admin.team.add_full.profession": "Profession",
"admin.team.add_full.job_title": "Berufsbezeichnung",
"admin.team.add_full.lang": "Sprache",
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
"admin.team.add_full.cancel": "Abbrechen",
"admin.team.add_full.submit": "Anlegen",
"admin.team.add_full.feedback.added": "Konto angelegt.",
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
"admin.team.loading": "Lade…",
"admin.team.empty": "Keine Treffer.",
"admin.team.error.forbidden": "Zugriff nur für Admins.",
@@ -4785,8 +4801,24 @@ const translations: Record<Lang, Record<string, string>> = {
"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.",

View File

@@ -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"

View File

@@ -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),

View File

@@ -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))

View File

@@ -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" {

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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.

View File

@@ -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"),
)
}

View File

@@ -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)
}
}

View File

@@ -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).

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Willkommen bei Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} hat ein Konto f&uuml;r Sie bei Paliad &mdash; der Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; angelegt.</p>
<p style="margin:0 0 20px 0;">Bitte legen Sie ein Passwort fest, um sich zum ersten Mal anzumelden:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Passwort festlegen und anmelden
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">Der Link ist 24 Stunden g&uuml;ltig. Anschlie&szlig;end k&ouml;nnen Sie sich jederzeit unter <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> mit Ihrer E-Mail-Adresse {{.ToEmail}} und dem neuen Passwort einloggen.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Angelegt von {{.InviterEmail}}. Falls Sie diese Nachricht unerwartet erhalten, k&ouml;nnen Sie sie ignorieren &mdash; ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.</p>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Welcome to Paliad</h1>
<p style="margin:0 0 12px 0;">{{.InviterName}} has created a Paliad account for you &mdash; Paliad is the patent practice platform for {{.Firm}}.</p>
<p style="margin:0 0 20px 0;">Please set a password to sign in for the first time:</p>
<p style="margin:0;">
<a href="{{.MagicLink}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Set password and sign in
</a>
</p>
<p style="margin:20px 0 0 0;font-size:13px;color:#44403c;">The link is valid for 24 hours. After that, you can always sign in at <a href="{{.BaseURL}}/login" style="color:#1c1917;">{{.BaseURL}}/login</a> with your email {{.ToEmail}} and the new password.</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Created by {{.InviterEmail}}. If you weren't expecting this message you can ignore it &mdash; without setting a password the account stays unusable.</p>
{{end}}