feat(admin): /admin/team page + admin-only user CRUD (t-paliad-050)

- New auth.RequireAdmin middleware (gates by paliad.users.role='admin')
  with API/browser-aware reject paths and a fail-closed lookup-error 500.
- Service: AdminCreateUser (onboard from existing auth.users), AdminUpdate
  (full profile fields incl. additional_offices), AdminDeleteUser (also
  removes project_teams + department_members memberships and clears any
  led-Dezernat seat — auth.users is left intact), ListUnonboardedAuthUsers,
  IsAdmin (implements auth.AdminLookup).
- Handlers: GET/POST /api/admin/users, GET /api/admin/users/unonboarded,
  PATCH/DELETE /api/admin/users/{id}, plus GET /admin/team for the page.
  All registered through RequireAdminFunc so non-admins get 403/302.
- Refuses to delete the last remaining admin and rejects role='admin'
  assignment via the admin UI (still SQL-only) — same rules as PATCH /api/me.
- /admin/team page: full users table with inline edit (display_name, office,
  role, dezernat, additional_offices, lang), trash with confirm, search +
  office filters, "Onboard existing account" modal driven by
  /api/admin/users/unonboarded, and an Invite button that re-opens the
  shared sidebar invite modal.
- Sidebar gains a hidden Admin section that sidebar.ts reveals after a
  successful /api/me lookup confirms role='admin' (fails closed on error).
- DE+EN i18n strings for the page, modal and table.
- Tests: require_admin_test.go covers admin-allowed, non-admin 403/302,
  unauthenticated 401 and lookup-error fail-closed paths.
This commit is contained in:
m
2026-04-27 13:40:00 +02:00
parent 94222f790b
commit c697fe3418
12 changed files with 1501 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
import { renderAdminTeam } from "./src/admin-team";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -74,6 +75,7 @@ async function build() {
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -151,6 +153,7 @@ async function build() {
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

142
frontend/src/admin-team.tsx Normal file
View File

@@ -0,0 +1,142 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
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="#65a30d" />
<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-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="akten-table-wrap admin-team-table-wrap">
<table className="akten-table 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.role">Rolle</th>
<th data-i18n="admin.team.col.dezernat">Dezernat</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={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
</tbody>
</table>
</div>
<div className="akten-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="akten-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.role">Rolle</label>
<input type="text" id="admin-da-role" name="role" placeholder="Associate" />
</div>
<div className="form-field">
<label htmlFor="admin-da-dezernat" data-i18n="admin.team.direct_add.dezernat">Dezernat (optional)</label>
<input type="text" id="admin-da-dezernat" name="dezernat" />
</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>
<Footer />
<script src="/assets/admin-team.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,414 @@
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
additional_offices?: string[];
role: string;
dezernat?: string;
lang: string;
reminder_morning_time?: string;
reminder_evening_time?: string;
reminder_timezone?: string;
created_at: string;
}
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface Unonboarded {
id: string;
email: string;
created_at: string;
}
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
const ROLE_SUGGESTIONS = ["Partner", "Associate", "PA", "Of Counsel", "Referendar/in", "Trainee", "wiss. Mitarbeiter/in", "Sekretariat"];
let users: User[] = [];
let offices: Office[] = [];
let unonboarded: Unonboarded[] = [];
let activeOffice = "all";
let searchQuery = "";
let editingId: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return t("office." + key) || (document.documentElement.lang === "en" ? o.label_en : o.label_de);
}
function fmtDate(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
async function loadAll() {
const [usersResp, officesResp] = await Promise.all([
fetch("/api/admin/users"),
fetch("/api/offices"),
]);
if (usersResp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (officesResp.ok) offices = (await officesResp.json()) as Office[];
buildOfficeFilters();
render();
}
async function loadUnonboarded() {
const resp = await fetch("/api/admin/users/unonboarded");
if (!resp.ok) {
unonboarded = [];
return;
}
unonboarded = (await resp.json()) as Unonboarded[];
}
function presentOffices(): string[] {
const seen = new Set<string>();
for (const u of users) seen.add(u.office);
return OFFICE_ORDER.filter((k) => seen.has(k)).concat(
Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(),
);
}
function buildOfficeFilters() {
const container = document.getElementById("admin-team-office-filters")!;
const present = presentOffices();
const allBtn = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all") || "Alle")}</button>`;
const pills = present
.map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
.join("");
container.innerHTML = allBtn + pills;
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
btn.addEventListener("click", () => {
activeOffice = btn.dataset.office ?? "all";
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
render();
});
});
}
function userMatchesSearch(u: User): boolean {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
u.display_name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.dezernat ?? "").toLowerCase().includes(q) ||
u.role.toLowerCase().includes(q)
);
}
function userMatchesOffice(u: User): boolean {
if (activeOffice === "all") return true;
if (u.office === activeOffice) return true;
return (u.additional_offices ?? []).includes(activeOffice);
}
function officeOptions(selected: string): string {
return offices
.map((o) => `<option value="${esc(o.key)}"${o.key === selected ? " selected" : ""}>${esc(officeLabel(o.key))}</option>`)
.join("");
}
function additionalOfficesEditor(selected: string[]): string {
return offices
.map((o) => {
const checked = selected.includes(o.key) ? " checked" : "";
return `<label class="admin-team-multi-opt"><input type="checkbox" data-additional="${esc(o.key)}"${checked} /> ${esc(officeLabel(o.key))}</label>`;
})
.join("");
}
function langOptions(selected: string): string {
return ["de", "en"]
.map((l) => `<option value="${l}"${l === selected ? " selected" : ""}>${l.toUpperCase()}</option>`)
.join("");
}
function renderRow(u: User): string {
if (editingId === u.id) return renderEditRow(u);
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
return `
<tr data-user-id="${esc(u.id)}">
<td class="akten-col-title">${esc(u.display_name)}</td>
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
<td><span class="akten-office-chip akten-office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${u.role === "admin" ? `<strong>${esc(u.role)}</strong>` : esc(u.role)}</td>
<td>${esc(u.dezernat ?? "")}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link admin-team-edit" data-id="${esc(u.id)}" data-i18n="admin.team.row.edit">Bearbeiten</button>
<button type="button" class="btn-link admin-team-delete" data-id="${esc(u.id)}" data-i18n="admin.team.row.delete">L&ouml;schen</button>
</td>
</tr>`;
}
function renderEditRow(u: User): string {
const additional = u.additional_offices ?? [];
const roleList = ROLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
return `
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
<td><span class="admin-team-muted" title="E-Mail kann nicht ge&auml;ndert werden">${esc(u.email)}</span></td>
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
<td>
${u.role === "admin"
? `<span class="admin-team-muted">admin</span>`
: `<input type="text" class="admin-team-input" data-field="role" value="${esc(u.role)}" list="admin-team-role-suggest-${esc(u.id)}" />
<datalist id="admin-team-role-suggest-${esc(u.id)}">${roleList}</datalist>`}
</td>
<td><input type="text" class="admin-team-input" data-field="dezernat" value="${esc(u.dezernat ?? "")}" /></td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
<td class="akten-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-primary admin-team-save" data-id="${esc(u.id)}" data-i18n="admin.team.row.save">Speichern</button>
<button type="button" class="btn-cancel admin-team-cancel" data-id="${esc(u.id)}" data-i18n="admin.team.row.cancel">Abbrechen</button>
</td>
</tr>`;
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-team-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) {
setTimeout(() => { el.style.display = "none"; }, 3500);
}
}
function render() {
const tbody = document.getElementById("admin-team-tbody")!;
const empty = document.getElementById("admin-team-empty")!;
const count = document.getElementById("admin-team-count")!;
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesSearch(u));
count.textContent = `${filtered.length} / ${users.length}`;
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
// Stable sort: admins first, then by display_name.
const sorted = filtered.slice().sort((a, b) => {
if (a.role === "admin" && b.role !== "admin") return -1;
if (b.role === "admin" && a.role !== "admin") return 1;
return a.display_name.localeCompare(b.display_name);
});
tbody.innerHTML = sorted.map(renderRow).join("");
attachRowListeners();
}
function attachRowListeners() {
document.querySelectorAll<HTMLButtonElement>(".admin-team-edit").forEach((b) => {
b.addEventListener("click", () => {
editingId = b.dataset.id ?? null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-cancel").forEach((b) => {
b.addEventListener("click", () => {
editingId = null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-save").forEach((b) => {
b.addEventListener("click", () => saveRow(b.dataset.id!));
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-delete").forEach((b) => {
b.addEventListener("click", () => deleteRow(b.dataset.id!));
});
}
async function saveRow(id: string) {
const tr = document.querySelector<HTMLTableRowElement>(`tr[data-user-id="${id}"]`);
if (!tr) return;
const payload: Record<string, unknown> = {};
tr.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-field]").forEach((el) => {
payload[el.dataset.field!] = el.value;
});
const additional: string[] = [];
tr.querySelectorAll<HTMLInputElement>("[data-additional]").forEach((cb) => {
if (cb.checked) additional.push(cb.dataset.additional!);
});
payload.additional_offices = additional;
const resp = await fetch(`/api/admin/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Fehler beim Speichern.", true);
return;
}
const updated = (await resp.json()) as User;
users = users.map((u) => (u.id === id ? updated : u));
editingId = null;
showFeedback(t("admin.team.feedback.saved") || "Gespeichert.", false);
render();
}
async function deleteRow(id: string) {
const u = users.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.team.confirm.delete") || "{name} wirklich löschen?").replace("{name}", u.display_name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/admin/users/${id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
return;
}
users = users.filter((x) => x.id !== id);
showFeedback(t("admin.team.feedback.deleted") || "Gelöscht.", false);
render();
}
function initSearch() {
const input = document.getElementById("admin-team-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value;
render();
});
}
function openDirectAddModal() {
const modal = document.getElementById("admin-direct-add-modal")!;
const select = document.getElementById("admin-da-email") as HTMLSelectElement;
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const fb = document.getElementById("admin-da-feedback")!;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
fb.style.display = "none";
nameField.value = "";
roleField.value = "";
dezField.value = "";
officeSel.innerHTML = officeOptions("munich");
loadUnonboarded().then(() => {
select.innerHTML = `<option value="">${esc(t("admin.team.direct_add.email.placeholder") || "Bitte auswählen...")}</option>` +
unonboarded.map((u) => `<option value="${esc(u.email)}">${esc(u.email)}</option>`).join("");
if (unonboarded.length === 0) {
const noneMsg = t("admin.team.direct_add.empty") || "Keine offenen Konten.";
select.innerHTML = `<option value="">${esc(noneMsg)}</option>`;
}
});
modal.style.display = "flex";
}
function closeDirectAddModal() {
document.getElementById("admin-direct-add-modal")!.style.display = "none";
}
function initDirectAddModal() {
document.getElementById("admin-team-direct-add")!.addEventListener("click", openDirectAddModal);
document.getElementById("admin-direct-add-close")!.addEventListener("click", closeDirectAddModal);
document.getElementById("admin-da-cancel")!.addEventListener("click", closeDirectAddModal);
const emailSel = document.getElementById("admin-da-email") as HTMLSelectElement;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
emailSel.addEventListener("change", () => {
if (!nameField.value && emailSel.value) {
// Pre-fill from email local-part.
const local = emailSel.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
}
});
document.getElementById("admin-direct-add-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeDirectAddModal();
});
const form = document.getElementById("admin-direct-add-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-da-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const roleField = document.getElementById("admin-da-role") as HTMLInputElement;
const dezField = document.getElementById("admin-da-dezernat") as HTMLInputElement;
const payload: Record<string, unknown> = {
email: emailSel.value,
display_name: nameField.value.trim(),
office: officeSel.value,
role: roleField.value.trim() || "Associate",
lang: "de",
};
if (dezField.value.trim()) payload.dezernat = dezField.value.trim();
const resp = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
fb.textContent = body.error || "Fehler.";
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeDirectAddModal();
showFeedback(t("admin.team.feedback.added") || "Konto onboardet.", false);
render();
});
}
function initInviteButton() {
document.getElementById("admin-team-invite")!.addEventListener("click", () => {
document.getElementById("sidebar-invite-btn")?.click();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();
render();
});
loadAll();
});

View File

@@ -1176,6 +1176,47 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "Ohne Dezernat",
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
"nav.admin.team": "Team-Verwaltung",
"admin.team.title": "Team-Verwaltung — Paliad",
"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.direct": "Bestehendes Konto onboarden",
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
"admin.team.loading": "Lade…",
"admin.team.empty": "Keine Treffer.",
"admin.team.error.forbidden": "Zugriff nur für Admins.",
"admin.team.col.name": "Name",
"admin.team.col.email": "E-Mail",
"admin.team.col.office": "Standort",
"admin.team.col.role": "Rolle",
"admin.team.col.dezernat": "Dezernat",
"admin.team.col.additional": "Weitere Standorte",
"admin.team.col.lang": "Sprache",
"admin.team.col.created": "Angelegt",
"admin.team.col.actions": "Aktionen",
"admin.team.row.edit": "Bearbeiten",
"admin.team.row.delete": "Löschen",
"admin.team.row.save": "Speichern",
"admin.team.row.cancel": "Abbrechen",
"admin.team.confirm.delete": "{name} wirklich löschen? Diese Aktion ist endgültig.",
"admin.team.feedback.saved": "Gespeichert.",
"admin.team.feedback.deleted": "Gelöscht.",
"admin.team.feedback.added": "Konto onboardet.",
"admin.team.direct_add.title": "Bestehendes Konto onboarden",
"admin.team.direct_add.body": "Diese Auswahl zeigt Konten, die sich angemeldet haben, aber noch kein Profil ausgefüllt haben.",
"admin.team.direct_add.email": "E-Mail",
"admin.team.direct_add.email.placeholder": "Bitte auswählen…",
"admin.team.direct_add.empty": "Keine offenen Konten.",
"admin.team.direct_add.name": "Anzeigename",
"admin.team.direct_add.office": "Standort",
"admin.team.direct_add.role": "Rolle",
"admin.team.direct_add.dezernat": "Dezernat (optional)",
"admin.team.direct_add.cancel": "Abbrechen",
"admin.team.direct_add.submit": "Anlegen",
// Not-found (404) page
"notfound.title": "Seite nicht gefunden — Paliad",
"notfound.heading": "Seite nicht gefunden",
@@ -2349,6 +2390,47 @@ const translations: Record<Lang, Record<string, string>> = {
"team.dept.lead": "Lead",
"team.dept.unassigned": "No department",
// Admin team management (t-paliad-050)
"nav.group.admin": "Admin",
"nav.admin.team": "Team Management",
"admin.team.title": "Team Management — Paliad",
"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.direct": "Onboard existing account",
"admin.team.add.invite": "Invite Colleague",
"admin.team.loading": "Loading…",
"admin.team.empty": "No matches.",
"admin.team.error.forbidden": "Admins only.",
"admin.team.col.name": "Name",
"admin.team.col.email": "Email",
"admin.team.col.office": "Office",
"admin.team.col.role": "Role",
"admin.team.col.dezernat": "Department",
"admin.team.col.additional": "Additional offices",
"admin.team.col.lang": "Lang",
"admin.team.col.created": "Created",
"admin.team.col.actions": "Actions",
"admin.team.row.edit": "Edit",
"admin.team.row.delete": "Delete",
"admin.team.row.save": "Save",
"admin.team.row.cancel": "Cancel",
"admin.team.confirm.delete": "Really delete {name}? This action is permanent.",
"admin.team.feedback.saved": "Saved.",
"admin.team.feedback.deleted": "Deleted.",
"admin.team.feedback.added": "Account onboarded.",
"admin.team.direct_add.title": "Onboard existing account",
"admin.team.direct_add.body": "This list shows accounts that have signed in but never completed onboarding.",
"admin.team.direct_add.email": "Email",
"admin.team.direct_add.email.placeholder": "Please select…",
"admin.team.direct_add.empty": "No pending accounts.",
"admin.team.direct_add.name": "Display name",
"admin.team.direct_add.office": "Office",
"admin.team.direct_add.role": "Role",
"admin.team.direct_add.dezernat": "Department (optional)",
"admin.team.direct_add.cancel": "Cancel",
"admin.team.direct_add.submit": "Create",
// Not-found (404) page
"notfound.title": "Page not found — Paliad",
"notfound.heading": "Page not found",

View File

@@ -68,6 +68,7 @@ export function initSidebar() {
initInviteModal();
initGlobalSearch();
initChangelogBadge();
initAdminGroup();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
@@ -294,6 +295,27 @@ function initChangelogBadge(): void {
});
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms role='admin'. The markup is in the DOM with
// display:none for everyone — flipping it on after the fetch lands keeps
// non-admin pageloads cheap (no flash, no second render) and avoids a
// privilege flash for admins on cached pages.
function initAdminGroup(): void {
const group = document.getElementById("sidebar-admin-group") as HTMLElement | null;
if (!group) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { role?: string } | null) => {
if (me && me.role === "admin") {
group.style.display = "";
}
})
.catch(() => {
// silent: not being able to check role just means we keep the section
// hidden, which fails closed.
});
}
// Invitation modal — opened from the sidebar "Kolleg:in einladen" button.
// Keeps the whole flow client-side: validates, POSTs to /api/invite, shows
// success or the server's error message in the same modal. Kept inside

View File

@@ -22,6 +22,7 @@ const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
interface SidebarProps {
currentPath: string;
@@ -130,6 +131,16 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{group("nav.group.einstellungen", "Einstellungen",
navItem("/settings", ICON_GEAR, "nav.einstellungen", "Einstellungen", currentPath),
)}
{/* Admin section: hidden by default. sidebar.ts reveals it after a
successful /api/me lookup confirms role='admin'. Keeping the
markup in the DOM (vs. fetched HTML) means there's nothing to
flash in/out for admins on subsequent navigations once the
role is known — only the first visit waits for /api/me. */}
<div className="sidebar-group sidebar-admin-group" id="sidebar-admin-group" style="display:none">
<div className="sidebar-group-label" data-i18n="nav.group.admin">Admin</div>
{navItem("/admin/team", ICON_SHIELD, "nav.admin.team", "Team-Verwaltung", currentPath)}
</div>
</nav>
<div className="sidebar-spacer" />

View File

@@ -6831,3 +6831,113 @@ dialog.quick-add-sheet::backdrop {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* =========================================================================
Admin team-management page (t-paliad-050)
========================================================================= */
.admin-team-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.admin-team-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.admin-team-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-team-table-wrap {
margin-top: 0.5rem;
}
.admin-team-table {
font-size: 0.85rem;
}
.admin-team-table tbody tr {
cursor: default;
}
.admin-team-table tbody tr:hover {
background: #f8fbf0;
}
.admin-team-loading {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 1rem;
}
.admin-team-muted {
color: var(--color-text-muted);
font-size: 0.85em;
}
.admin-team-actions-cell {
white-space: nowrap;
text-align: right;
}
.admin-team-actions-cell .btn-link {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 0.82rem;
padding: 0.2rem 0.5rem;
}
.admin-team-actions-cell .btn-link:hover {
text-decoration: underline;
}
.admin-team-actions-cell .admin-team-delete {
color: #b91c1c;
}
.admin-team-edit-row {
background: #fdfff5;
}
.admin-team-input {
width: 100%;
padding: 0.3rem 0.45rem;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
}
.admin-team-multi {
max-width: 220px;
}
.admin-team-multi-opt {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin: 0.1rem 0.4rem 0.1rem 0;
font-size: 0.78rem;
}
.admin-team-edit-row .btn-primary,
.admin-team-edit-row .btn-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
margin-left: 0.3rem;
}
.sidebar-admin-group .sidebar-group-label {
color: #c6f41c;
}

View File

@@ -0,0 +1,69 @@
package auth
import (
"context"
"net/http"
"github.com/google/uuid"
)
// AdminLookup is the minimal interface RequireAdmin needs to consult the
// caller's paliad.users row. Implemented by services.UserService — kept as an
// interface here so the auth package doesn't import services (which would be
// a layering inversion: services depends on auth, not the other way around).
type AdminLookup interface {
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
}
// RequireAdmin wraps a handler so only callers whose paliad.users row has
// role='admin' may proceed. Anyone else gets 403 (JSON for /api/*, redirect
// to /dashboard for browser paths).
//
// Must run downstream of Client.Middleware + Client.WithUserID — the user's
// UUID is read from the request context that those populate.
//
// If the lookup itself errors, the request is rejected with 500 rather than
// fail-open: an admin-gated endpoint that silently lets non-admins through
// when the DB blips is the worst possible failure mode.
func RequireAdmin(lookup AdminLookup) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
rejectAdmin(w, r, http.StatusUnauthorized, "authentication required")
return
}
ok, err := lookup.IsAdmin(r.Context(), uid)
if err != nil {
rejectAdmin(w, r, http.StatusInternalServerError, "internal error")
return
}
if !ok {
rejectAdmin(w, r, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAdminFunc is the http.HandlerFunc-flavoured wrapper, since most of
// the protected mux is registered with HandleFunc.
func RequireAdminFunc(lookup AdminLookup, h http.HandlerFunc) http.HandlerFunc {
wrapped := RequireAdmin(lookup)(h)
return wrapped.ServeHTTP
}
func rejectAdmin(w http.ResponseWriter, r *http.Request, status int, msg string) {
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// Hand-rolled to avoid pulling encoding/json for one constant payload.
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
return
}
// Browser path: send the user back to /dashboard with a flash-style query
// param the page can pick up if it wants to surface the message. Avoids
// rendering a bare 403 the user has no obvious way to recover from.
http.Redirect(w, r, "/dashboard?forbidden=admin", http.StatusFound)
}

View File

@@ -0,0 +1,114 @@
package auth
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
// fakeAdminLookup implements AdminLookup for unit tests.
type fakeAdminLookup struct {
admin bool
err error
}
func (f fakeAdminLookup) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
return f.admin, f.err
}
// withUID returns a request that already has the user-id context value set,
// matching what Client.WithUserID would have populated.
func withUID(req *http.Request, id uuid.UUID) *http.Request {
ctx := context.WithValue(req.Context(), userIDContextKey, id)
return req.WithContext(ctx)
}
func TestRequireAdmin_AllowsAdmin(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
h := RequireAdmin(fakeAdminLookup{admin: true})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !called {
t.Fatal("admin user should reach the wrapped handler")
}
if rec.Code != http.StatusOK {
t.Errorf("status: got %d, want 200", rec.Code)
}
}
func TestRequireAdmin_RejectsNonAdminAPI(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("non-admin must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: false})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("API path should 403 for non-admin, got %d", rec.Code)
}
}
func TestRequireAdmin_RedirectsNonAdminBrowser(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("non-admin must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: false})(next)
req := withUID(httptest.NewRequest("GET", "/admin/team", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Errorf("browser path should 302 for non-admin, got %d", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/dashboard?forbidden=admin" {
t.Errorf("redirect target: got %q", got)
}
}
func TestRequireAdmin_RejectsUnauthenticated(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("unauthenticated must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{admin: true})(next)
// No userIDContextKey on the request — simulates a path that didn't
// authenticate first. The middleware must fail closed even when the
// lookup would have approved.
req := httptest.NewRequest("GET", "/api/admin/users", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("missing user id should 401, got %d", rec.Code)
}
}
func TestRequireAdmin_FailsClosedOnLookupError(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("lookup-error path must not reach the wrapped handler")
})
h := RequireAdmin(fakeAdminLookup{err: errors.New("db gone")})(next)
req := withUID(httptest.NewRequest("GET", "/api/admin/users", nil), uuid.New())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("lookup error should 500 (fail closed), got %d", rec.Code)
}
}

View File

@@ -0,0 +1,154 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/services"
)
// admin_users.go — backing endpoints for the /admin/team page (t-paliad-050).
// All four routes are registered behind RequireAdminFunc in handlers.go, so
// the in-handler logic can assume the caller already passed the admin gate
// and only the operation itself needs validation.
// GET /api/admin/users — full unredacted list of every paliad.users row.
func handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
users, err := dbSvc.users.List(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, users)
}
// GET /api/admin/users/unonboarded — auth.users entries without a paliad.users
// row. Feeds the "direct add" dropdown so an admin can onboard a colleague
// who logged in but never finished the form.
func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
rows, err := dbSvc.users.ListUnonboardedAuthUsers(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, rows)
}
// 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),
// but we re-check here so a stale auth.users row from before the policy
// existed can't sneak through.
func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
var input services.AdminCreateInput
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 HLC allow-list",
})
return
}
u, err := dbSvc.users.AdminCreateUser(r.Context(), input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserAlreadyOnboarded):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "user already onboarded",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be assigned via the admin UI",
})
case errors.Is(err, services.ErrInvalidInput):
// AdminCreateUser uses ErrInvalidInput for both bad-shape inputs
// and the "no auth.users row for this email" case. Surfacing the
// raw message keeps the form's error display useful without a
// separate error type for each.
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)
}
// PATCH /api/admin/users/{id} — mutate any paliad.users row.
func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.AdminUpdateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
u, err := dbSvc.users.AdminUpdateUser(r.Context(), id, input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be assigned via the admin UI",
})
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.StatusOK, u)
}
// DELETE /api/admin/users/{id} — remove a paliad.users row + cascade clean-up
// of project_teams / department_members. auth.users is left intact so the
// user can re-onboard later if needed.
func handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.users.AdminDeleteUser(r.Context(), id); err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
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
}
w.WriteHeader(http.StatusNoContent)
}
// handleAdminTeamPage serves the SPA shell for /admin/team.
func handleAdminTeamPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-team.html")
}

View File

@@ -286,6 +286,21 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /settings", gateOnboarded(handleSettingsPage))
protected.HandleFunc("GET /settings/{tab}", handleSettingsTabRedirect)
// Admin team management — page + API endpoints. RequireAdmin gates every
// route at the middleware layer so the handlers themselves don't repeat
// the role check. Only available when the DB is configured (the lookup
// hits paliad.users).
if svc != nil && svc.Users != nil {
adminGate := auth.RequireAdminFunc
users := svc.Users
protected.HandleFunc("GET /admin/team", adminGate(users, gateOnboarded(handleAdminTeamPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
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))
}
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/models"
"mgit.msbls.de/m/patholo/internal/offices"
@@ -332,3 +333,367 @@ func (s *UserService) List(ctx context.Context) ([]models.User, error) {
}
return users, nil
}
// IsAdmin reports whether the given user is an admin. Implements
// auth.AdminLookup so the requireAdmin middleware can stay in package auth
// without importing services. Returns (false, nil) for an unknown / unonboarded
// user — which is what we want: a missing paliad.users row is not an admin.
func (s *UserService) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) {
u, err := s.GetByID(ctx, id)
if err != nil {
return false, err
}
return u != nil && u.Role == "admin", nil
}
// AdminCreateInput is the payload an admin uses to onboard a colleague who
// already exists in auth.users. Email is required (must already be in
// auth.users with an allowed domain — both checks happen in AdminCreateUser).
type AdminCreateInput struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Office string `json:"office"`
Role string `json:"role,omitempty"` // defaults to 'associate'
Dezernat *string `json:"dezernat,omitempty"`
Lang string `json:"lang,omitempty"` // defaults to 'de'
}
// AdminCreateUser inserts a paliad.users row for an auth.users entry that has
// not yet onboarded. Used by the admin team-management page to bulk-onboard
// real colleagues without forcing each one through the self-service flow.
//
// Returns ErrUserAlreadyOnboarded if a paliad.users row already exists for
// the given email's auth.users id. Returns a wrapped ErrInvalidInput when the
// email isn't in auth.users at all (so the handler can map to 404).
//
// 'admin' is rejected here for the same reason it's rejected in UpdateProfile:
// promotions to admin must go through SQL, not the admin UI. This keeps the
// blast radius of an admin's leaked session contained.
func (s *UserService) AdminCreateUser(ctx context.Context, input AdminCreateInput) (*models.User, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" {
return nil, fmt.Errorf("%w: email is required", ErrInvalidInput)
}
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)
}
role := strings.TrimSpace(input.Role)
if role == "" {
role = "associate"
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
}
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)
}
var dezernat *string
if input.Dezernat != nil {
trimmed := strings.TrimSpace(*input.Dezernat)
if trimmed != "" {
dezernat = &trimmed
}
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Look up the auth.users.id for the requested email. Admins bulk-onboard
// colleagues who have already signed in — if the email isn't in auth.users
// the right path is to invite them, not create a half-attached profile.
var authID uuid.UUID
if err := tx.GetContext(ctx, &authID,
`SELECT id FROM auth.users WHERE lower(email) = $1`, email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: email %q is not in auth.users — invite first", ErrInvalidInput, email)
}
return nil, fmt.Errorf("lookup auth.users: %w", err)
}
// Refuse a second paliad.users row for the same auth.uid().
var exists bool
if err := tx.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1)`, authID); err != nil {
return nil, fmt.Errorf("check existing user: %w", err)
}
if exists {
return nil, ErrUserAlreadyOnboarded
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, dezernat, lang)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
authID, email, displayName, input.Office, role, dezernat, lang,
); err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit admin create user: %w", err)
}
return s.GetByID(ctx, authID)
}
// 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).
type AdminUpdateInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
Role *string `json:"role,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
AdditionalOffices *[]string `json:"additional_offices,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
ReminderMorningTime *string `json:"reminder_morning_time,omitempty"`
ReminderEveningTime *string `json:"reminder_evening_time,omitempty"`
ReminderTimezone *string `json:"reminder_timezone,omitempty"`
}
// AdminUpdateUser mutates any paliad.users row. Same validation rules as
// UpdateProfile (no role='admin' assignment, valid office/lang/timezone).
// Returns ErrUserNotOnboarded when the target row is missing.
//
// Why a separate method instead of routing through UpdateProfile: the self
// service path is intentionally narrow (the user touches their own row), and
// AdminUpdate also writes additional_offices, which we do not want exposed on
// PATCH /api/me.
func (s *UserService) AdminUpdateUser(ctx context.Context, id uuid.UUID, input AdminUpdateInput) (*models.User, error) {
sets := []string{}
args := []any{}
i := 1
if input.DisplayName != nil {
dn := strings.TrimSpace(*input.DisplayName)
if dn == "" {
return nil, fmt.Errorf("%w: display_name cannot be empty", ErrInvalidInput)
}
sets = append(sets, fmt.Sprintf("display_name = $%d", i))
args = append(args, dn)
i++
}
if input.Office != nil {
if !offices.IsValid(*input.Office) {
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
}
sets = append(sets, fmt.Sprintf("office = $%d", i))
args = append(args, *input.Office)
i++
}
if input.Role != nil {
role := strings.TrimSpace(*input.Role)
if role == "" {
return nil, fmt.Errorf("%w: role cannot be empty", ErrInvalidInput)
}
if role == "admin" {
return nil, ErrAdminBootstrapOnly
}
sets = append(sets, fmt.Sprintf("role = $%d", i))
args = append(args, role)
i++
}
if input.Dezernat != nil {
trimmed := strings.TrimSpace(*input.Dezernat)
var val any
if trimmed == "" {
val = nil
} else {
val = trimmed
}
sets = append(sets, fmt.Sprintf("dezernat = $%d", i))
args = append(args, val)
i++
}
if input.AdditionalOffices != nil {
// Validate each key against the canonical office list. A typo here
// would silently break the /team filter pills for that user.
clean := make([]string, 0, len(*input.AdditionalOffices))
seen := map[string]bool{}
for _, k := range *input.AdditionalOffices {
k = strings.TrimSpace(k)
if k == "" || seen[k] {
continue
}
if !offices.IsValid(k) {
return nil, fmt.Errorf("%w: invalid additional office %q", ErrInvalidInput, k)
}
seen[k] = true
clean = append(clean, k)
}
sets = append(sets, fmt.Sprintf("additional_offices = $%d", i))
args = append(args, pq.StringArray(clean))
i++
}
if input.Lang != nil {
lang := strings.ToLower(strings.TrimSpace(*input.Lang))
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("%w: invalid lang %q", ErrInvalidInput, *input.Lang)
}
sets = append(sets, fmt.Sprintf("lang = $%d", i))
args = append(args, lang)
i++
}
if input.EmailPreferences != nil {
raw := *input.EmailPreferences
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("%w: email_preferences must be a JSON object", ErrInvalidInput)
}
sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
args = append(args, []byte(raw))
i++
}
if input.ReminderMorningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderMorningTime)
if err != nil {
return nil, fmt.Errorf("%w: reminder_morning_time: %v", ErrInvalidInput, err)
}
sets = append(sets, fmt.Sprintf("reminder_morning_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderEveningTime != nil {
t, err := normaliseTimeOfDay(*input.ReminderEveningTime)
if err != nil {
return nil, fmt.Errorf("%w: reminder_evening_time: %v", ErrInvalidInput, err)
}
sets = append(sets, fmt.Sprintf("reminder_evening_time = $%d", i))
args = append(args, t)
i++
}
if input.ReminderTimezone != nil {
tz := strings.TrimSpace(*input.ReminderTimezone)
if _, err := time.LoadLocation(tz); err != nil {
return nil, fmt.Errorf("%w: invalid reminder_timezone %q", ErrInvalidInput, tz)
}
sets = append(sets, fmt.Sprintf("reminder_timezone = $%d", i))
args = append(args, tz)
i++
}
if len(sets) == 0 {
return s.GetByID(ctx, id)
}
sets = append(sets, "updated_at = now()")
args = append(args, id)
query := fmt.Sprintf(
`UPDATE paliad.users SET %s WHERE id = $%d`,
strings.Join(sets, ", "), i,
)
res, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, fmt.Errorf("update user: rows affected: %w", err)
}
if n == 0 {
return nil, ErrUserNotOnboarded
}
return s.GetByID(ctx, id)
}
// AdminDeleteUser removes the paliad.users row for the given user and any
// project_teams / department_members rows pointing at the same auth.users.id.
// auth.users itself is left intact: Supabase identity is not the admin's to
// destroy, and the user can re-onboard later if they need to.
//
// Why explicit DELETEs instead of leaning on FK CASCADE: the cascade is from
// auth.users → paliad.*; leaving auth.users alone means the cascade never
// fires. We do the membership cleanup manually so the admin gets a single
// transactional "user is gone from this product" outcome.
//
// Returns ErrUserNotOnboarded when the row is missing (already gone).
// Refuses to delete the last remaining admin so the firm doesn't lock itself
// out of its own admin UI.
func (s *UserService) AdminDeleteUser(ctx context.Context, id uuid.UUID) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var role string
if err := tx.GetContext(ctx, &role,
`SELECT role FROM paliad.users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotOnboarded
}
return fmt.Errorf("lookup user: %w", err)
}
if role == "admin" {
var others int
if err := tx.GetContext(ctx, &others,
`SELECT count(*) FROM paliad.users WHERE role = 'admin' AND id <> $1`, id); err != nil {
return fmt.Errorf("count admins: %w", err)
}
if others == 0 {
return fmt.Errorf("%w: cannot delete the last remaining admin", ErrInvalidInput)
}
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_teams WHERE user_id = $1`, id); err != nil {
return fmt.Errorf("delete project_teams: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.department_members WHERE user_id = $1`, id); err != nil {
return fmt.Errorf("delete department_members: %w", err)
}
// A Department this user led keeps existing — the lead seat just goes empty.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.departments SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil {
return fmt.Errorf("clear dept leads: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete user: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete user: %w", err)
}
return nil
}
// UnonboardedAuthUser is one row in auth.users that has no matching
// paliad.users entry — i.e. the user logged in once via Supabase but never
// completed onboarding. Surfaced to admins so they can bulk-add real
// colleagues without chasing each one to fill in the form themselves.
type UnonboardedAuthUser struct {
ID uuid.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ListUnonboardedAuthUsers returns auth.users rows with no paliad.users row.
// Sorted oldest-first so the longest-pending colleagues bubble to the top of
// the admin's "direct add" dropdown.
func (s *UserService) ListUnonboardedAuthUsers(ctx context.Context) ([]UnonboardedAuthUser, error) {
rows := []UnonboardedAuthUser{}
err := s.db.SelectContext(ctx, &rows,
`SELECT a.id, lower(a.email) AS email, a.created_at
FROM auth.users a
LEFT JOIN paliad.users p ON p.id = a.id
WHERE p.id IS NULL
AND a.email IS NOT NULL
AND a.email <> ''
ORDER BY a.created_at ASC`)
if err != nil {
return nil, fmt.Errorf("list unonboarded: %w", err)
}
return rows, nil
}