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:
@@ -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
142
frontend/src/admin-team.tsx
Normal 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 — 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ü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">×</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ü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ä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>
|
||||
);
|
||||
}
|
||||
414
frontend/src/client/admin-team.ts
Normal file
414
frontend/src/client/admin-team.ts
Normal 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ö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ä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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
internal/auth/require_admin.go
Normal file
69
internal/auth/require_admin.go
Normal 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)
|
||||
}
|
||||
114
internal/auth/require_admin_test.go
Normal file
114
internal/auth/require_admin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
154
internal/handlers/admin_users.go
Normal file
154
internal/handlers/admin_users.go
Normal 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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user