#49 — adds a third "Konto direkt anlegen" path on /admin/team alongside "Onboard existing" and "Invite colleague". Creates both auth.users (via Supabase Admin API) and paliad.users in one click; new user is visible in dropdowns immediately and receives a paliad-branded magic-link email. - internal/services/supabase_admin.go: new SupabaseAdminClient — thin net/http shim. 3 methods (CreateAuthUser, GenerateRecoveryLink, DeleteAuthUser). 10s timeout. ErrSupabaseAdminUnavailable when key unset, ErrSupabaseEmailExists when 422-with-"already" returned. apikey + Bearer headers on every call. Sentinel errors for handler mapping. - internal/services/supabase_admin_test.go: 5 tests pin wire-shape (disabled mode, happy-path POST + headers + body, email-exists mapping, both action-link response shapes, DELETE-by-id route). - internal/services/user_service.go: UserService grows optional supabase + mail + baseURL dependencies via SetAddUserDeps. AdminCreateFullInput (email/display_name/office/job_title/profession/lang/send_welcome_mail + inviter fields). AdminCreateUserFull validates input → calls supabase.CreateAuthUser → inserts paliad.users (best-effort DeleteAuthUser rollback on insert fail) → writes paliad.system_audit_log row (event_type='user.added_by_admin') → sends welcome mail with magic-link (best-effort). - internal/templates/email/add_user_welcome.{de,en}.html: new template with magic-link CTA + base-URL fallback + firm-name placeholder. Editable through the existing /admin/email-templates editor (admin-overridable via DB). - internal/services/email_template_*.go: register 'add_user_welcome' as a fourth canonical key, defaultSubjects entry, sample data, variable contract (6 vars). - internal/services/mail_service_test.go: TestRenderTemplateAddUserWelcome pins both langs render with magic-link + firm + matching subject. - internal/handlers/admin_users.go: handleAdminCreateFullUser POST /api/admin/users/full. Fills inviter fields from auth.uid() server-side (never trusts the request body). Error map: 503 (unavailable), 409 (email exists / already onboarded), 400 (invalid input), 403 (domain not on whitelist), 500 (other). - internal/handlers/handlers.go: route registered behind adminGate. - cmd/server/main.go: LoadSupabaseAdminClient + users.SetAddUserDeps + boot-log line so the deployer knows whether the path is active. - frontend/src/admin-team.tsx: "Konto direkt anlegen" button + admin-add-full-modal with email/name/office/profession/job_title/lang fields + send-welcome checkbox (default on). - frontend/src/client/admin-team.ts: initAddFullModal — POST to /api/admin/users/full, inline error handling for 503 / 409 / generic, optimistic insert into users[] on success, name auto-fills from email local-part on blur. - i18n: +20 keys (admin.team.add.full + admin.team.add_full.*) × DE + EN. Design picks honoured: Supabase Admin API path (Q1), welcome email default on (Q2), two-step with best-effort rollback (Q3), job_title default 'Associate' (Q4), profession default 'associate' (Q5). Trade-off #3 from §6 (privileged credential broadens trust surface) accepted by m via head. go build && go test -short ./internal/... + bun run build all green.
597 lines
23 KiB
TypeScript
597 lines
23 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
office: string;
|
|
additional_offices?: string[];
|
|
job_title: string | null;
|
|
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
|
|
// senior_pa/pa/paralegal). NULL = external. Editable via the
|
|
// admin-team Profession column.
|
|
profession?: string | null;
|
|
global_role: string;
|
|
lang: string;
|
|
reminder_morning_time?: string;
|
|
reminder_evening_time?: string;
|
|
reminder_timezone?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
const PROFESSION_VALUES = [
|
|
"partner",
|
|
"of_counsel",
|
|
"associate",
|
|
"senior_pa",
|
|
"pa",
|
|
"paralegal",
|
|
];
|
|
|
|
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 JOB_TITLE_SUGGESTIONS = [
|
|
"Partner",
|
|
"Associate",
|
|
"PA",
|
|
"Of Counsel",
|
|
"Counsel",
|
|
"Counsel Knowledge Lawyer",
|
|
"Knowledge Lawyer",
|
|
"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 tDyn("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();
|
|
}
|
|
|
|
function permissionLabel(globalRole: string): string {
|
|
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
|
|
return t("admin.team.permission.standard") || "Standard";
|
|
}
|
|
|
|
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.job_title ?? "").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 globalAdminCount(): number {
|
|
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
|
|
}
|
|
|
|
function permissionCell(u: User): string {
|
|
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
|
|
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
|
|
}
|
|
|
|
function permissionEditor(u: User): string {
|
|
// Disable demoting the only remaining global_admin.
|
|
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
|
|
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
|
|
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
|
|
const disabled = isLastAdmin ? " disabled" : "";
|
|
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
|
|
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
|
}
|
|
|
|
function professionLabel(p: string | null | undefined): string {
|
|
if (!p) return "";
|
|
return tDyn(`projects.team.profession.${p}`) || p;
|
|
}
|
|
|
|
function professionCell(u: User): string {
|
|
if (!u.profession) {
|
|
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
|
|
}
|
|
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
|
|
}
|
|
|
|
function professionEditor(u: User): string {
|
|
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
|
|
const opts = PROFESSION_VALUES.map(
|
|
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
|
|
).join("");
|
|
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
|
|
}
|
|
|
|
function renderRow(u: User): string {
|
|
if (editingId === u.id) return renderEditRow(u);
|
|
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
|
const jobTitle = u.job_title ?? "";
|
|
return `
|
|
<tr data-user-id="${esc(u.id)}">
|
|
<td class="entity-col-title">${esc(u.display_name)}</td>
|
|
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
|
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
|
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
|
<td>${professionCell(u)}</td>
|
|
<td>${permissionCell(u)}</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="entity-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 jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
|
|
const jobTitle = u.job_title ?? "";
|
|
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>
|
|
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
|
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
|
</td>
|
|
<td>${professionEditor(u)}</td>
|
|
<td>${permissionEditor(u)}</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="entity-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: global admins first, then by display_name.
|
|
const sorted = filtered.slice().sort((a, b) => {
|
|
const aAdmin = a.global_role === "global_admin";
|
|
const bAdmin = b.global_role === "global_admin";
|
|
if (aAdmin && !bAdmin) return -1;
|
|
if (bAdmin && !aAdmin) 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 jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
|
|
|
fb.style.display = "none";
|
|
nameField.value = "";
|
|
jobTitleField.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 jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
|
|
|
const payload: Record<string, unknown> = {
|
|
email: emailSel.value,
|
|
display_name: nameField.value.trim(),
|
|
office: officeSel.value,
|
|
job_title: jobTitleField.value.trim() || "Associate",
|
|
lang: "de",
|
|
};
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
|
|
// the auth.users row (via Supabase Admin API) and the paliad.users row in
|
|
// one POST. New user appears in dropdowns immediately. Welcome email with
|
|
// magic-link is sent by default; admin can opt out via the checkbox.
|
|
function openAddFullModal() {
|
|
const modal = document.getElementById("admin-add-full-modal")!;
|
|
const fb = document.getElementById("admin-af-feedback")!;
|
|
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
|
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
|
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
|
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
|
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
|
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
|
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
|
|
|
fb.style.display = "none";
|
|
emailField.value = "";
|
|
nameField.value = "";
|
|
jobTitleField.value = "";
|
|
profSel.value = "associate";
|
|
langSel.value = "de";
|
|
sendWelcome.checked = true;
|
|
officeSel.innerHTML = officeOptions("munich");
|
|
|
|
modal.style.display = "flex";
|
|
emailField.focus();
|
|
}
|
|
|
|
function closeAddFullModal() {
|
|
document.getElementById("admin-add-full-modal")!.style.display = "none";
|
|
}
|
|
|
|
function initAddFullModal() {
|
|
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
|
|
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
|
|
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
|
|
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) closeAddFullModal();
|
|
});
|
|
|
|
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
|
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
|
// Pre-fill the display name from the email local-part the first time the
|
|
// admin tabs out of the email field — mirrors the existing onboard flow.
|
|
emailField.addEventListener("blur", () => {
|
|
if (nameField.value || !emailField.value) return;
|
|
const local = emailField.value.split("@")[0] ?? "";
|
|
nameField.value = local
|
|
.split(/[._-]/)
|
|
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
|
.join(" ")
|
|
.trim();
|
|
});
|
|
|
|
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const fb = document.getElementById("admin-af-feedback")!;
|
|
fb.style.display = "none";
|
|
|
|
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
|
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
|
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
|
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
|
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
|
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
|
|
|
|
const payload: Record<string, unknown> = {
|
|
email: emailField.value.trim().toLowerCase(),
|
|
display_name: nameField.value.trim(),
|
|
office: officeSel.value,
|
|
job_title: jobTitleField.value.trim() || "Associate",
|
|
profession: profSel.value,
|
|
lang: langSel.value,
|
|
send_welcome_mail: sendWelcome.checked,
|
|
};
|
|
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const resp = await fetch("/api/admin/users/full", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
// Map two friendly cases inline; everything else surfaces the
|
|
// server message so the admin can act on it.
|
|
if (resp.status === 503) {
|
|
fb.textContent = t("admin.team.add_full.error.unavailable")
|
|
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
|
|
} else if (resp.status === 409) {
|
|
fb.textContent = body.error
|
|
|| (t("admin.team.add_full.error.email_exists")
|
|
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
|
|
} else {
|
|
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
|
|
}
|
|
fb.className = "form-msg form-msg-error";
|
|
fb.style.display = "block";
|
|
return;
|
|
}
|
|
const created = (await resp.json()) as User;
|
|
users = users.concat(created);
|
|
closeAddFullModal();
|
|
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
|
|
render();
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
initSearch();
|
|
initDirectAddModal();
|
|
initAddFullModal();
|
|
initInviteButton();
|
|
onLangChange(() => {
|
|
buildOfficeFilters();
|
|
render();
|
|
});
|
|
loadAll();
|
|
});
|