Files
paliad/frontend/src/admin-team.tsx
mAi 3d3a4fa36d 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.
2026-05-20 15:19:48 +02:00

206 lines
11 KiB
TypeScript

import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAdminTeam(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.team.title">Team-Verwaltung &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/team" />
<BottomNav currentPath="/admin/team" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.team.heading">Team-Verwaltung</h1>
<p className="tool-subtitle" data-i18n="admin.team.subtitle">
Alle Paliad-Konten anzeigen, bearbeiten oder hinzuf&uuml;gen.
</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>
<button className="btn-primary" id="admin-team-invite" type="button" data-i18n="admin.team.add.invite">
Neue:n Kolleg:in einladen
</button>
</div>
</div>
<div className="admin-team-controls">
<div className="glossar-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="admin-team-search"
className="glossar-search"
placeholder="Nach Name oder E-Mail suchen..."
data-i18n-placeholder="admin.team.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="admin-team-count" />
</div>
<div className="admin-team-filter-row" id="admin-team-office-filters">
<button className="filter-pill active" data-office="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
</div>
<div id="admin-team-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap admin-team-table-wrap">
<table className="entity-table entity-table--readonly admin-team-table">
<thead>
<tr>
<th data-i18n="admin.team.col.name">Name</th>
<th data-i18n="admin.team.col.email">E-Mail</th>
<th data-i18n="admin.team.col.office">Standort</th>
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
<th data-i18n="admin.team.col.profession">Profession</th>
<th data-i18n="admin.team.col.permission">Berechtigung</th>
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
<th data-i18n="admin.team.col.lang">Sprache</th>
<th data-i18n="admin.team.col.created">Angelegt</th>
<th data-i18n="admin.team.col.actions">Aktionen</th>
</tr>
</thead>
<tbody id="admin-team-tbody">
<tr><td colspan={10} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="admin-team-empty" style="display:none">
<p data-i18n="admin.team.empty">Keine Treffer.</p>
</div>
</div>
</section>
</main>
{/* Direct-add modal: pick from unonboarded auth.users dropdown. */}
<div className="modal-overlay" id="admin-direct-add-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.team.direct_add.title">Bestehendes Konto onboarden</h2>
<button className="modal-close" id="admin-direct-add-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.team.direct_add.body" className="invite-modal-body">
Diese Auswahl zeigt Konten, die sich angemeldet haben, aber noch kein Profil ausgef&uuml;llt haben.
</p>
<form id="admin-direct-add-form" className="entity-form" autocomplete="off">
<div className="form-field">
<label htmlFor="admin-da-email" data-i18n="admin.team.direct_add.email">E-Mail</label>
<select id="admin-da-email" name="email" required>
<option value="" data-i18n="admin.team.direct_add.email.placeholder">Bitte ausw&auml;hlen...</option>
</select>
</div>
<div className="form-field">
<label htmlFor="admin-da-name" data-i18n="admin.team.direct_add.name">Anzeigename</label>
<input type="text" id="admin-da-name" name="display_name" required />
</div>
<div className="form-field">
<label htmlFor="admin-da-office" data-i18n="admin.team.direct_add.office">Standort</label>
<select id="admin-da-office" name="office" required />
</div>
<div className="form-field">
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
</div>
<div id="admin-da-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="admin-da-cancel" data-i18n="admin.team.direct_add.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="admin-da-submit" data-i18n="admin.team.direct_add.submit">Anlegen</button>
</div>
</form>
</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>
</body>
</html>
);
}