Files
paliad/frontend/src/client/settings.ts
mAi fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00

1159 lines
43 KiB
TypeScript

import { initI18n, onLangChange, getLang, setLang, t, tDyn, Lang } from "./i18n";
import { initSidebar } from "./sidebar";
// Unified settings page. One init function for the whole page; each tab has
// its own idempotent loader (load*Tab) that runs on first activation and is
// safe to call again on subsequent switches.
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface Me {
id: string;
email: string;
display_name: string;
office: string;
job_title: string | null;
global_role: string;
lang: Lang;
email_preferences: Record<string, unknown>;
reminder_morning_time: string;
reminder_evening_time: string;
reminder_timezone: string;
reminder_warning_offset_days: number;
// Optional override of the DRINGEND/overdue escalation channel — when
// set, replaces the global_admins fallback for this user's deadlines.
// Server returns either a UUID string or omits the key (omitempty).
escalation_contact_id?: string;
}
interface CalDAVConfig {
configured: boolean;
url?: string;
username?: string;
calendar_path?: string;
enabled?: boolean;
last_sync_at?: string;
last_sync_error?: string;
updated_at?: string;
}
interface SyncLogEntry {
id: string;
occurred_at: string;
direction: string;
items_pushed: number;
items_pulled: number;
error?: string;
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
let offices: Office[] = [];
let caldavConfig: CalDAVConfig | null = null;
const loadedTabs = new Set<TabName>();
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDateTime(iso?: string): string {
if (!iso) return t("caldav.never");
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// --- Tab switching ----------------------------------------------------------
function parseTab(): TabName {
const params = new URLSearchParams(window.location.search);
const raw = params.get("tab");
return (TABS as string[]).includes(raw ?? "") ? (raw as TabName) : DEFAULT_TAB;
}
function showTab(tab: TabName, pushHistory: boolean) {
for (const name of TABS) {
const panel = document.getElementById(`tab-${name}`);
if (panel) panel.style.display = name === tab ? "" : "none";
}
document.querySelectorAll<HTMLElement>("#einstellungen-tabs .entity-tab").forEach((el) => {
const name = el.getAttribute("data-tab");
el.classList.toggle("active", name === tab);
});
if (pushHistory) {
const url = new URL(window.location.href);
if (tab === DEFAULT_TAB) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tab);
}
window.history.replaceState({}, "", url.toString());
}
if (!loadedTabs.has(tab)) {
loadedTabs.add(tab);
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "export") void loadExportTab();
}
}
function wireTabLinks() {
document.querySelectorAll<HTMLAnchorElement>("#einstellungen-tabs .entity-tab").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const tab = a.getAttribute("data-tab") as TabName | null;
if (tab) showTab(tab, true);
});
});
}
// --- Profil tab -------------------------------------------------------------
async function loadProfilTab() {
const loading = document.getElementById("profil-loading")!;
const form = document.getElementById("profil-form") as HTMLFormElement;
await Promise.all([fetchMe(), fetchOffices()]);
renderOfficeOptions();
fillProfilForm();
void renderMyPartnerUnits();
loading.style.display = "none";
form.style.display = "";
}
async function fetchMe(): Promise<void> {
if (me) return;
const resp = await fetch("/api/me");
if (resp.status === 404) {
// Shouldn't happen — /einstellungen is behind the onboarding gate — but
// if it does, push the user to onboarding so they don't sit on an empty
// form.
window.location.href = "/onboarding";
return;
}
if (!resp.ok) return;
me = await resp.json();
}
async function fetchOffices(): Promise<void> {
if (offices.length > 0) return;
try {
const resp = await fetch("/api/offices");
if (!resp.ok) return;
offices = await resp.json();
} catch {
offices = [];
}
}
function renderOfficeOptions() {
const select = document.getElementById("profil-office") as HTMLSelectElement | null;
if (!select) return;
const isEN = getLang() === "en";
const previous = select.value || me?.office || "";
select.innerHTML = offices
.map((o) => {
const label = isEN ? o.label_en : o.label_de;
return `<option value="${esc(o.key)}">${esc(label)}</option>`;
})
.join("");
if (previous && offices.some((o) => o.key === previous)) {
select.value = previous;
}
}
function fillProfilForm() {
if (!me) return;
(document.getElementById("profil-email") as HTMLInputElement).value = me.email;
(document.getElementById("profil-display-name") as HTMLInputElement).value = me.display_name;
(document.getElementById("profil-office") as HTMLSelectElement).value = me.office;
(document.getElementById("profil-role") as HTMLInputElement).value = me.job_title ?? "";
(document.getElementById("profil-lang") as HTMLSelectElement).value = me.lang || "de";
}
async function saveProfil(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("profil-msg")!;
msg.textContent = "";
msg.className = "form-msg";
const displayName = (document.getElementById("profil-display-name") as HTMLInputElement).value.trim();
const office = (document.getElementById("profil-office") as HTMLSelectElement).value;
const jobTitle = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
const lang = (document.getElementById("profil-lang") as HTMLSelectElement).value as Lang;
if (!displayName) {
msg.textContent = t("einstellungen.profil.error.display_name");
msg.className = "form-msg form-msg-error";
return;
}
if (!office) {
msg.textContent = t("einstellungen.profil.error.office");
msg.className = "form-msg form-msg-error";
return;
}
if (!jobTitle) {
msg.textContent = t("einstellungen.profil.error.job_title");
msg.className = "form-msg form-msg-error";
return;
}
const payload = {
display_name: displayName,
office,
job_title: jobTitle,
lang,
};
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
me = await resp.json();
msg.textContent = t("einstellungen.saved");
msg.className = "form-msg form-msg-success";
// Keep the client-side lang in sync with the newly persisted value so
// the UI (and other tabs) immediately reflect the choice.
if (me && me.lang && me.lang !== getLang()) {
setLang(me.lang);
}
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("einstellungen.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("einstellungen.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
// --- Benachrichtigungen tab -------------------------------------------------
function readPrefBool(key: string, fallback: boolean): boolean {
if (!me?.email_preferences) return fallback;
const v = me.email_preferences[key];
return typeof v === "boolean" ? v : fallback;
}
async function loadPrefsTab() {
await Promise.all([fetchMe(), loadUserOptions()]);
fillPrefsForm();
fillEscalationContactOptions();
}
function fillEscalationContactOptions() {
const select = document.getElementById("prefs-escalation-contact") as HTMLSelectElement | null;
if (!select || !me) return;
// Filter out self — pointing your escalation at yourself is silly and
// the server enforces it via a CHECK constraint. Sort by display_name
// for a stable order (then email as tiebreaker for users without a
// display name).
const candidates = userOptions
.filter((u) => u.id !== me!.id)
.slice()
.sort((a, b) => {
const an = (a.display_name || a.email).toLowerCase();
const bn = (b.display_name || b.email).toLowerCase();
return an.localeCompare(bn);
});
const defaultLabel = t("einstellungen.prefs.escalation.default_option");
const opts: string[] = [`<option value="">${esc(defaultLabel)}</option>`];
for (const u of candidates) {
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
opts.push(`<option value="${esc(u.id)}">${esc(label)}</option>`);
}
select.innerHTML = opts.join("");
select.value = me.escalation_contact_id ?? "";
}
function fillPrefsForm() {
const master = document.getElementById("prefs-reminders-master") as HTMLInputElement;
const overdue = document.getElementById("prefs-reminders-overdue") as HTMLInputElement;
const dueToday = document.getElementById("prefs-reminders-due-today") as HTMLInputElement;
const dueWarning = document.getElementById("prefs-reminders-due-warning") as HTMLInputElement;
master.checked = readPrefBool("deadline_reminders", true);
overdue.checked = readPrefBool("deadline_reminders.overdue", true);
dueToday.checked = readPrefBool("deadline_reminders.due_today", true);
dueWarning.checked = readPrefBool("deadline_reminders.due_warning", true);
// The model returns "HH:MM:SS" but <input type="time"> wants "HH:MM".
(document.getElementById("prefs-reminder-morning") as HTMLInputElement).value =
(me?.reminder_morning_time ?? "09:00:00").slice(0, 5);
(document.getElementById("prefs-reminder-evening") as HTMLInputElement).value =
(me?.reminder_evening_time ?? "16:00:00").slice(0, 5);
(document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value =
me?.reminder_timezone ?? "Europe/Berlin";
(document.getElementById("prefs-warning-offset") as HTMLInputElement).value =
String(me?.reminder_warning_offset_days ?? 7);
updatePrefsSubGroup(master.checked);
master.addEventListener("change", () => updatePrefsSubGroup(master.checked));
}
function updatePrefsSubGroup(masterOn: boolean) {
const sub = document.getElementById("prefs-reminders-sub")!;
sub.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((el) => {
el.disabled = !masterOn;
});
sub.style.opacity = masterOn ? "1" : "0.5";
}
async function savePrefs(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("prefs-msg")!;
msg.textContent = "";
msg.className = "form-msg";
// Start from the existing prefs so we don't erase keys we don't know about
// (future additions, other subsystems).
const next: Record<string, unknown> = { ...(me?.email_preferences ?? {}) };
next["deadline_reminders"] = (document.getElementById("prefs-reminders-master") as HTMLInputElement).checked;
next["deadline_reminders.overdue"] = (document.getElementById("prefs-reminders-overdue") as HTMLInputElement).checked;
next["deadline_reminders.due_today"] = (document.getElementById("prefs-reminders-due-today") as HTMLInputElement).checked;
next["deadline_reminders.due_warning"] = (document.getElementById("prefs-reminders-due-warning") as HTMLInputElement).checked;
// Retire legacy keys so the next save's PATCH body doesn't keep them on
// the row indefinitely.
delete next["deadline_reminders.tomorrow"];
delete next["deadline_reminders.due_today_evening"];
delete next["deadline_reminders.weekly"];
const morning = (document.getElementById("prefs-reminder-morning") as HTMLInputElement).value;
const evening = (document.getElementById("prefs-reminder-evening") as HTMLInputElement).value;
const timezone = (document.getElementById("prefs-reminder-timezone") as HTMLInputElement).value.trim();
const warningOffset = parseInt(
(document.getElementById("prefs-warning-offset") as HTMLInputElement).value,
10,
);
// "" = clear back to the global_admins fallback; UUID = explicit
// escalation contact. The server's UpdateProfileInput uses *string with
// empty-string-as-clear semantics for nullable references.
const escalationContact = (document.getElementById("prefs-escalation-contact") as HTMLSelectElement).value;
if (!morning || !evening) {
msg.textContent = t("einstellungen.prefs.times.error.required");
msg.className = "form-msg form-msg-error";
return;
}
if (!Number.isFinite(warningOffset) || warningOffset < 1 || warningOffset > 30) {
msg.textContent = t("einstellungen.prefs.warning_offset.error");
msg.className = "form-msg form-msg-error";
return;
}
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email_preferences: next,
reminder_morning_time: morning,
reminder_evening_time: evening,
reminder_timezone: timezone || "Europe/Berlin",
reminder_warning_offset_days: warningOffset,
escalation_contact_id: escalationContact,
}),
});
if (resp.ok) {
me = await resp.json();
msg.textContent = t("einstellungen.saved");
msg.className = "form-msg form-msg-success";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("einstellungen.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("einstellungen.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
// --- CalDAV tab -------------------------------------------------------------
async function loadCalDAVTab() {
const ok = await loadCalDAVConfig();
if (!ok) return;
fillCalDAVForm();
renderCalDAVStatus();
await loadCalDAVLog();
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
// project picker for scope=project; runs in parallel with the binding
// list fetch.
void loadBindingProjects();
await loadBindings();
}
async function loadCalDAVConfig(): Promise<boolean> {
try {
const resp = await fetch("/api/caldav-config");
if (resp.status === 501) {
document.getElementById("caldav-disabled")!.style.display = "block";
document.getElementById("caldav-form")!.style.display = "none";
return false;
}
if (!resp.ok) return false;
caldavConfig = await resp.json();
return true;
} catch {
return false;
}
}
function fillCalDAVForm() {
if (!caldavConfig?.configured) return;
(document.getElementById("caldav-url") as HTMLInputElement).value = caldavConfig.url ?? "";
(document.getElementById("caldav-username") as HTMLInputElement).value = caldavConfig.username ?? "";
(document.getElementById("caldav-calendar-path") as HTMLInputElement).value = caldavConfig.calendar_path ?? "";
(document.getElementById("caldav-enabled") as HTMLInputElement).checked = caldavConfig.enabled ?? false;
document.getElementById("caldav-delete-btn")!.style.display = "";
}
function renderCalDAVStatus() {
const card = document.getElementById("caldav-status-card")!;
if (!caldavConfig?.configured) {
card.style.display = "none";
return;
}
card.style.display = "";
document.getElementById("caldav-last-sync")!.textContent = fmtDateTime(caldavConfig.last_sync_at);
const errRow = document.getElementById("caldav-status-error-row")!;
if (caldavConfig.last_sync_error) {
errRow.style.display = "";
document.getElementById("caldav-last-error")!.textContent = caldavConfig.last_sync_error;
} else {
errRow.style.display = "none";
}
}
async function loadCalDAVLog() {
try {
const resp = await fetch("/api/caldav-config/log");
if (!resp.ok) return;
const rows: SyncLogEntry[] = await resp.json();
const tbody = document.getElementById("caldav-log-body")!;
const empty = document.getElementById("caldav-log-empty")!;
if (rows.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = rows
.map((r) => {
const dur = r.duration_ms != null ? `${r.duration_ms} ms` : "\u2014";
const err = r.error ? `<span class="caldav-status-error">${esc(r.error)}</span>` : "\u2014";
return `<tr>
<td>${esc(fmtDateTime(r.occurred_at))}</td>
<td>${r.items_pushed}</td>
<td>${r.items_pulled}</td>
<td>${dur}</td>
<td>${err}</td>
</tr>`;
})
.join("");
} catch {
/* non-fatal */
}
}
function readCalDAVForm() {
return {
url: (document.getElementById("caldav-url") as HTMLInputElement).value.trim(),
username: (document.getElementById("caldav-username") as HTMLInputElement).value.trim(),
password: (document.getElementById("caldav-password") as HTMLInputElement).value,
calendar_path: (document.getElementById("caldav-calendar-path") as HTMLInputElement).value.trim(),
enabled: (document.getElementById("caldav-enabled") as HTMLInputElement).checked,
};
}
async function saveCalDAV(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("caldav-msg")!;
msg.textContent = "";
const payload = readCalDAVForm();
if (!payload.url || !payload.username) {
msg.textContent = t("caldav.error.required");
msg.className = "form-msg form-msg-error";
return;
}
if (!caldavConfig?.configured && !payload.password) {
msg.textContent = t("caldav.error.password_required");
msg.className = "form-msg form-msg-error";
return;
}
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/caldav-config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
caldavConfig = { configured: true, ...(await resp.json()) };
(document.getElementById("caldav-password") as HTMLInputElement).value = "";
msg.textContent = t("caldav.saved");
msg.className = "form-msg form-msg-ok";
document.getElementById("caldav-delete-btn")!.style.display = "";
await loadCalDAVConfig();
renderCalDAVStatus();
await loadCalDAVLog();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function testCalDAVConnection() {
const msg = document.getElementById("caldav-msg")!;
msg.textContent = "";
const payload = readCalDAVForm();
const btn = document.getElementById("caldav-test-btn") as HTMLButtonElement;
btn.disabled = true;
try {
const resp = await fetch("/api/caldav-config/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json().catch(() => ({}) as { ok?: boolean; error?: string });
if (data.ok) {
msg.textContent = t("caldav.test.ok");
msg.className = "form-msg form-msg-ok";
} else {
msg.textContent = `${t("caldav.test.fail")}: ${data.error ?? "?"}`;
msg.className = "form-msg form-msg-error";
}
} catch (e) {
msg.textContent = `${t("caldav.test.fail")}: ${(e as Error).message}`;
msg.className = "form-msg form-msg-error";
} finally {
btn.disabled = false;
}
}
async function deleteCalDAVConfig() {
const msg = document.getElementById("caldav-msg")!;
if (!confirm(t("caldav.delete.confirm"))) return;
msg.textContent = "";
try {
const resp = await fetch("/api/caldav-config", { method: "DELETE" });
if (resp.ok || resp.status === 204) {
caldavConfig = { configured: false };
(document.getElementById("caldav-form") as HTMLFormElement).reset();
document.getElementById("caldav-delete-btn")!.style.display = "none";
renderCalDAVStatus();
await loadCalDAVLog();
msg.textContent = t("caldav.delete.done");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
}
}
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
interface UserCalendarBinding {
id: string;
user_id: string;
calendar_path: string;
display_name: string;
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
scope_id?: string | null;
include_personal: boolean;
enabled: boolean;
last_sync_at?: string | null;
last_sync_error?: string | null;
}
interface DiscoveredCalendar {
href: string;
display_name: string;
supported_components?: string[];
}
interface ProjectListItem {
id: string;
reference?: string;
title?: string;
type?: string;
}
let bindings: UserCalendarBinding[] = [];
let discoveredCalendars: DiscoveredCalendar[] = [];
let bindingProjects: ProjectListItem[] = [];
let editingBindingID: string | null = null;
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
// true = MKCALENDAR supported (show "Create new calendar" radio),
// false = degrade UX (hide radio, surface bilingual notice).
let supportsMKCalendar: boolean | null = null;
async function loadBindings(): Promise<void> {
const section = document.getElementById("caldav-bindings-section");
if (!section) return;
try {
const resp = await fetch("/api/caldav-bindings");
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
if (!resp.ok) return;
bindings = (await resp.json()) as UserCalendarBinding[];
section.style.display = "";
renderBindingsList();
} catch {
/* non-fatal */
}
}
function renderBindingsList(): void {
const list = document.getElementById("caldav-bindings-list")!;
const empty = document.getElementById("caldav-bindings-empty")!;
if (!bindings.length) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = bindings.map(renderBindingCard).join("");
// Wire per-card buttons.
for (const b of bindings) {
const card = document.getElementById(`caldav-binding-card-${b.id}`);
if (!card) continue;
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
}
}
function renderBindingCard(b: UserCalendarBinding): string {
const label = b.display_name || b.calendar_path;
const scope = scopeLabel(b);
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
<div class="caldav-binding-card-row">
<div class="caldav-binding-card-title">
<strong>${esc(label)}</strong>
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
</div>
<label class="caldav-toggle-label">
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
</label>
</div>
<div class="caldav-binding-card-row caldav-binding-card-meta">
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
</div>
<div class="caldav-binding-card-actions">
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
</div>
</div>`;
}
function scopeLabel(b: UserCalendarBinding): string {
switch (b.scope_kind) {
case "all_visible":
return t("caldav.bindings.scope.all_visible");
case "personal_only":
return t("caldav.bindings.scope.personal_only");
case "project": {
const p = bindingProjects.find((p) => p.id === b.scope_id);
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
return `${t("caldav.bindings.scope.project")}: ${name}`;
}
default:
return b.scope_kind;
}
}
async function loadBindingProjects(): Promise<void> {
if (bindingProjects.length) return;
try {
const resp = await fetch("/api/projects");
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
} catch {
/* ignore */
}
}
async function loadDiscoveredCalendars(): Promise<void> {
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
try {
const resp = await fetch("/api/caldav-discover");
if (!resp.ok) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
return;
}
const data = (await resp.json()) as {
calendars: DiscoveredCalendar[];
supports_mkcalendar?: boolean | null;
};
discoveredCalendars = data.calendars || [];
supportsMKCalendar = data.supports_mkcalendar ?? null;
if (!discoveredCalendars.length) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
} else {
sel.innerHTML = discoveredCalendars
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
.join("");
}
syncBindingSourceModeUI();
} catch {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
}
}
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
// radio + the Google-degrade notice based on the cached
// supports_mkcalendar capability. Also flips the visible input
// (dropdown vs URL text box) to match the currently selected mode.
function syncBindingSourceModeUI(): void {
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
const degrade = document.getElementById("caldav-binding-degrade-notice");
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
// If supports_mkcalendar flipped to false while "create" was selected,
// fall back to "existing" so the user isn't staring at a hidden radio.
if (supportsMKCalendar !== true) {
const createRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="create"]',
) as HTMLInputElement | null;
if (createRadio?.checked) {
const existing = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existing) existing.checked = true;
}
}
const mode = currentBindingSourceMode();
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
sel.style.display = mode === "existing" ? "" : "none";
customInput.style.display = mode === "custom" ? "" : "none";
}
function currentBindingSourceMode(): "existing" | "create" | "custom" {
const checked = document.querySelector(
'input[name="caldav-binding-source-mode"]:checked',
) as HTMLInputElement | null;
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
}
function openBindingModal(b: UserCalendarBinding | null) {
editingBindingID = b ? b.id : null;
const modal = document.getElementById("caldav-binding-modal")!;
const title = document.getElementById("caldav-binding-modal-title")!;
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
const sourceField = document.getElementById("caldav-binding-source-field")!;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
if (b) {
title.textContent = t("caldav.bindings.modal.edit_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
sourceField.style.display = "none";
nameInput.value = b.display_name;
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
if (radio) radio.checked = true;
} else {
title.textContent = t("caldav.bindings.modal.add_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
sourceField.style.display = "";
// Reset the 3-way source-mode radio to "existing" (most common path).
const existingRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existingRadio) existingRadio.checked = true;
customInput.value = "";
nameInput.value = "";
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
radio.checked = true;
void loadDiscoveredCalendars();
}
// Project picker — populate options when project scope is picked.
projectSel.innerHTML = bindingProjects
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
.join("");
if (b && b.scope_kind === "project" && b.scope_id) {
projectSel.value = b.scope_id;
projectSel.disabled = false;
}
syncBindingScopeUI();
syncBindingSourceModeUI();
modal.style.display = "flex";
}
function closeBindingModal() {
document.getElementById("caldav-binding-modal")!.style.display = "none";
editingBindingID = null;
}
function syncBindingScopeUI(): void {
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
projectSel.disabled = scope !== "project";
}
async function submitBindingModal(ev: Event): Promise<void> {
ev.preventDefault();
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
if (!scope) {
msg.textContent = t("caldav.bindings.error.scope");
msg.className = "form-msg form-msg-error";
return;
}
if (scope === "project" && !projectSel.value) {
msg.textContent = t("caldav.bindings.error.scope_project");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
if (editingBindingID) {
const patchPayload: Record<string, unknown> = {
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") patchPayload.scope_id = projectSel.value;
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
const mode = currentBindingSourceMode();
if (mode === "create") {
// Slice 2c MKCALENDAR path.
const displayName = nameInput.value.trim();
if (!displayName) {
msg.textContent = t("caldav.bindings.error.create_name_required");
msg.className = "form-msg form-msg-error";
return;
}
const createPayload: Record<string, unknown> = {
display_name: displayName,
scope_kind: scope,
};
if (scope === "project") createPayload.scope_id = projectSel.value;
const resp = await fetch("/api/caldav-mkcalendar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(createPayload),
});
if (resp.status === 501) {
// Race: probe flipped to false between modal-open and submit.
// Re-sync the UI and surface a helpful message.
supportsMKCalendar = false;
syncBindingSourceModeUI();
msg.textContent = t("caldav.bindings.error.create_unsupported");
msg.className = "form-msg form-msg-error";
return;
}
if (resp.status === 409) {
msg.textContent = t("caldav.bindings.error.create_name_taken");
msg.className = "form-msg form-msg-error";
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
// existing | custom — POST /api/caldav-bindings with the path.
const path = mode === "custom" ? customInput.value.trim() : sel.value;
if (!path) {
msg.textContent = t("caldav.bindings.error.path");
msg.className = "form-msg form-msg-error";
return;
}
const postPayload: Record<string, unknown> = {
calendar_path: path,
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") postPayload.scope_id = projectSel.value;
if (!postPayload.display_name && mode === "existing") {
const opt = sel.options[sel.selectedIndex];
postPayload.display_name = opt ? opt.text : "";
}
const resp = await fetch("/api/caldav-bindings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(postPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
}
}
closeBindingModal();
await loadBindings();
} catch {
msg.textContent = t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
alert(t("caldav.bindings.delete.failed"));
return;
}
await loadBindings();
} catch {
alert(t("caldav.bindings.delete.failed"));
}
}
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
try {
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (resp.ok) {
b.enabled = enabled;
}
} catch {
/* non-fatal */
}
}
// --- "Meine Partner Units" card on the profile tab -------------------------
//
// Read-only summary of the current user's structural memberships. Membership
// writes are admin-driven via /admin/partner-units.
interface PartnerUnit {
id: string;
name: string;
lead_user_id?: string | null;
office: string;
}
interface UserOption {
id: string;
display_name: string;
email: string;
}
let userOptions: UserOption[] = [];
async function loadUserOptions(): Promise<void> {
if (userOptions.length) return;
try {
const resp = await fetch("/api/users");
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
} catch {
// ignore
}
}
async function renderMyPartnerUnits(): Promise<void> {
const container = document.getElementById("partner-unit-my");
if (!container || !me) return;
try {
const resp = await fetch("/api/partner-units?include=members");
if (!resp.ok) return;
type PUWithMembers = PartnerUnit & {
lead_display_name?: string;
members: { user_id: string; display_name: string; email: string }[];
};
const all = (await resp.json()) as PUWithMembers[];
const mine = all.filter((u) => u.members.some((m) => m.user_id === me!.id));
if (!mine.length) {
container.innerHTML = `<p class="form-hint">${esc(t("partner_unit.none") || "Sie sind noch keiner Partner Unit zugeordnet.")}</p>`;
return;
}
container.innerHTML = mine
.map(
(u) => `<div class="partner-unit-card">
<h3>${esc(u.name)}</h3>
<p class="form-hint">${esc(tDyn("office." + u.office) || u.office)}${u.lead_display_name ? ` &middot; <strong>${esc(u.lead_display_name)}</strong>` : ""}</p>
<p class="form-hint"><strong>${u.members.length}</strong> ${esc(t("partner_unit.members_label") || "Mitglieder")}</p>
<ul class="partner-unit-member-list">
${u.members
.map((m) => `<li>${esc(m.display_name)} <span class="form-hint">(${esc(m.email)})</span></li>`)
.join("")}
</ul>
</div>`,
)
.join("");
} catch {
// ignore — leave previous render
}
}
// --- Export tab (t-paliad-214 Slice 1) -------------------------------------
// Personal data export. One button; on click hits GET /api/me/export and the
// browser handles the download via Content-Disposition. We use an anchor +
// hidden iframe pattern so any non-200 response can surface inline instead
// of silently triggering a save dialog with an error-html body.
async function loadExportTab(): Promise<void> {
// Nothing to fetch on render; the tab is static text + button. Wired in
// the DOMContentLoaded handler.
}
function runExport(): void {
const msg = document.getElementById("export-msg");
const btn = document.getElementById("export-btn") as HTMLButtonElement | null;
if (msg) msg.textContent = "";
if (btn) btn.disabled = true;
// Trigger a navigation to the endpoint. The server sets
// Content-Disposition: attachment which the browser respects.
// We use a transient <a download> so the click goes through the
// normal download path even on browsers that try to render text/json.
const a = document.createElement("a");
a.href = "/api/me/export";
// download="" tells the browser to keep the server-provided filename
// when one is set via Content-Disposition.
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Re-enable after a short timeout so users can re-trigger if needed.
// We don't try to detect download completion — there's no portable
// browser API for it.
if (btn) {
setTimeout(() => {
btn.disabled = false;
if (msg)
msg.textContent =
t("einstellungen.export.started") ||
"Download gestartet. Falls nichts passiert, prüfen Sie Ihren Browser-Downloadordner.";
}, 500);
}
}
// --- Init -------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireTabLinks();
document.getElementById("profil-form")!.addEventListener("submit", saveProfil);
document.getElementById("prefs-form")!.addEventListener("submit", savePrefs);
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
el.addEventListener("change", syncBindingSourceModeUI);
});
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
el.addEventListener("change", syncBindingScopeUI);
});
const exportBtn = document.getElementById("export-btn");
if (exportBtn) exportBtn.addEventListener("click", runExport);
onLangChange(() => {
if (loadedTabs.has("profil")) renderOfficeOptions();
if (loadedTabs.has("caldav")) {
renderCalDAVStatus();
void loadCalDAVLog();
}
});
showTab(parseTab(), false);
});