Compare commits
1 Commits
mai/ritchi
...
mai/gauss/
| Author | SHA1 | Date | |
|---|---|---|---|
| 96aef9b5dd |
@@ -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
|
||||
|
||||
@@ -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">×</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ält eine E-Mail mit einem Link, ü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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
242
internal/services/supabase_admin.go
Normal file
242
internal/services/supabase_admin.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
154
internal/services/supabase_admin_test.go
Normal file
154
internal/services/supabase_admin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
12
internal/templates/email/add_user_welcome.de.html
Normal file
12
internal/templates/email/add_user_welcome.de.html
Normal 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ür Sie bei Paliad — der Patent-Praxis-Plattform für {{.Firm}} — 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ültig. Anschließend kö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önnen Sie sie ignorieren — ohne das Festlegen eines Passworts bleibt das Konto unbenutzbar.</p>
|
||||
{{end}}
|
||||
12
internal/templates/email/add_user_welcome.en.html
Normal file
12
internal/templates/email/add_user_welcome.en.html
Normal 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 — 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 — without setting a password the account stays unusable.</p>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user