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

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