Merge: settings page — profile, email prefs, CalDAV tabs

This commit is contained in:
m
2026-04-20 13:18:40 +02:00
18 changed files with 1271 additions and 415 deletions

View File

@@ -23,7 +23,7 @@ import { renderTermine } from "./src/termine";
import { renderTermineNeu } from "./src/termine-neu";
import { renderTermineDetail } from "./src/termine-detail";
import { renderTermineKalender } from "./src/termine-kalender";
import { renderEinstellungenCalDAV } from "./src/einstellungen-caldav";
import { renderEinstellungen } from "./src/einstellungen";
import { renderDashboard } from "./src/dashboard";
import { renderOnboarding } from "./src/onboarding";
@@ -60,7 +60,7 @@ async function build() {
join(import.meta.dir, "src/client/termine-neu.ts"),
join(import.meta.dir, "src/client/termine-detail.ts"),
join(import.meta.dir, "src/client/termine-kalender.ts"),
join(import.meta.dir, "src/client/einstellungen-caldav.ts"),
join(import.meta.dir, "src/client/einstellungen.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
],
@@ -107,7 +107,7 @@ async function build() {
await Bun.write(join(DIST, "termine-neu.html"), renderTermineNeu());
await Bun.write(join(DIST, "termine-detail.html"), renderTermineDetail());
await Bun.write(join(DIST, "termine-kalender.html"), renderTermineKalender());
await Bun.write(join(DIST, "einstellungen-caldav.html"), renderEinstellungenCalDAV());
await Bun.write(join(DIST, "einstellungen.html"), renderEinstellungen());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());

View File

@@ -1,247 +0,0 @@
import { initI18n, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Config {
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;
}
let config: Config | null = null;
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;
}
}
async function loadConfig(): 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;
config = await resp.json();
return true;
} catch {
return false;
}
}
function fillForm() {
if (!config) return;
if (!config.configured) return;
(document.getElementById("caldav-url") as HTMLInputElement).value = config.url ?? "";
(document.getElementById("caldav-username") as HTMLInputElement).value = config.username ?? "";
(document.getElementById("caldav-calendar-path") as HTMLInputElement).value = config.calendar_path ?? "";
(document.getElementById("caldav-enabled") as HTMLInputElement).checked = config.enabled ?? false;
document.getElementById("caldav-delete-btn")!.style.display = "";
}
function renderStatus() {
if (!config || !config.configured) {
document.getElementById("caldav-status-card")!.style.display = "none";
return;
}
document.getElementById("caldav-status-card")!.style.display = "";
document.getElementById("caldav-last-sync")!.textContent = fmtDateTime(config.last_sync_at);
const errRow = document.getElementById("caldav-status-error-row")!;
if (config.last_sync_error) {
errRow.style.display = "";
document.getElementById("caldav-last-error")!.textContent = config.last_sync_error;
} else {
errRow.style.display = "none";
}
}
async function loadLog() {
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 readForm() {
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 saveConfig(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("caldav-msg")!;
msg.textContent = "";
const payload = readForm();
if (!payload.url || !payload.username) {
msg.textContent = t("caldav.error.required");
msg.className = "form-msg form-msg-error";
return;
}
if (!config?.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) {
config = await resp.json();
// Server returns PublicConfig, not the GET shape — normalise.
config = { configured: true, ...config };
(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 = "";
// Re-fetch to get last_sync_at etc.
await loadConfig();
renderStatus();
await loadLog();
} 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 testConnection() {
const msg = document.getElementById("caldav-msg")!;
msg.textContent = "";
const payload = readForm();
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 deleteConfig() {
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) {
config = { configured: false };
(document.getElementById("caldav-form") as HTMLFormElement).reset();
document.getElementById("caldav-delete-btn")!.style.display = "none";
renderStatus();
await loadLog();
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";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
document.getElementById("caldav-form")!.addEventListener("submit", saveConfig);
document.getElementById("caldav-test-btn")!.addEventListener("click", testConnection);
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteConfig);
const ok = await loadConfig();
if (!ok) return;
fillForm();
renderStatus();
await loadLog();
});

View File

@@ -0,0 +1,544 @@
import { initI18n, onLangChange, getLang, setLang, t, 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;
role: string;
dezernat?: string;
lang: Lang;
email_preferences: Record<string, unknown>;
}
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";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav"];
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 .akten-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();
}
}
function wireTabLinks() {
document.querySelectorAll<HTMLAnchorElement>("#einstellungen-tabs .akten-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();
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.role;
(document.getElementById("profil-dezernat") as HTMLInputElement).value = me.dezernat ?? "";
(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 role = (document.getElementById("profil-role") as HTMLInputElement).value.trim();
const dezernat = (document.getElementById("profil-dezernat") 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 (!role) {
msg.textContent = t("einstellungen.profil.error.role");
msg.className = "form-msg form-msg-error";
return;
}
const payload = {
display_name: displayName,
office,
role,
dezernat,
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 fetchMe();
fillPrefsForm();
}
function fillPrefsForm() {
const master = document.getElementById("prefs-reminders-master") as HTMLInputElement;
const overdue = document.getElementById("prefs-reminders-overdue") as HTMLInputElement;
const tomorrow = document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement;
const weekly = document.getElementById("prefs-reminders-weekly") as HTMLInputElement;
master.checked = readPrefBool("deadline_reminders", true);
overdue.checked = readPrefBool("deadline_reminders.overdue", true);
tomorrow.checked = readPrefBool("deadline_reminders.tomorrow", true);
weekly.checked = readPrefBool("deadline_reminders.weekly", true);
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.tomorrow"] = (document.getElementById("prefs-reminders-tomorrow") as HTMLInputElement).checked;
next["deadline_reminders.weekly"] = (document.getElementById("prefs-reminders-weekly") as HTMLInputElement).checked;
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 }),
});
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();
}
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";
}
}
// --- 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);
onLangChange(() => {
if (loadedTabs.has("profil")) renderOfficeOptions();
if (loadedTabs.has("caldav")) {
renderCalDAVStatus();
void loadCalDAVLog();
}
});
showTab(parseTab(), false);
});

View File

@@ -685,8 +685,44 @@ const translations: Record<Lang, Record<string, string>> = {
// Termine + CalDAV (Phase F)
"nav.termine": "Termine",
"nav.group.einstellungen": "Einstellungen",
"nav.einstellungen": "Einstellungen",
"nav.caldav": "CalDAV",
// Settings page (t-paliad-022)
"einstellungen.title": "Einstellungen \u2014 Paliad",
"einstellungen.heading": "Einstellungen",
"einstellungen.subtitle": "Profil, Benachrichtigungen und Kalendersynchronisation.",
"einstellungen.loading": "L\u00e4dt\u2026",
"einstellungen.optional": "(optional)",
"einstellungen.save": "Speichern",
"einstellungen.saved": "Gespeichert.",
"einstellungen.error.generic": "Speichern fehlgeschlagen. Bitte sp\u00e4ter erneut versuchen.",
"einstellungen.tab.profil": "Profil",
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.profil.email": "E-Mail",
"einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.",
"einstellungen.profil.display_name": "Anzeigename",
"einstellungen.profil.display_name.placeholder": "Vor- und Nachname",
"einstellungen.profil.office": "B\u00fcro",
"einstellungen.profil.role": "Rolle",
"einstellungen.profil.role.placeholder": "z.B. Associate, Partner, PA",
"einstellungen.profil.dezernat": "Dezernat / Partner",
"einstellungen.profil.dezernat.placeholder": "z.B. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Sprache",
"einstellungen.profil.lang.de": "Deutsch",
"einstellungen.profil.lang.en": "English",
"einstellungen.profil.lang.hint": "Wird f\u00fcr Oberfl\u00e4che und Benachrichtigungs-E-Mails verwendet.",
"einstellungen.profil.error.display_name": "Bitte Anzeigename eingeben.",
"einstellungen.profil.error.office": "Bitte B\u00fcro ausw\u00e4hlen.",
"einstellungen.profil.error.role": "Bitte Rolle eingeben.",
"einstellungen.prefs.reminders.heading": "Frist-Erinnerungen",
"einstellungen.prefs.reminders.hint": "Paliad sendet Erinnerungen an Ihre E-Mail, wenn Fristen f\u00e4llig werden.",
"einstellungen.prefs.reminders.master": "Frist-Erinnerungen aktiv",
"einstellungen.prefs.reminders.overdue": "\u00dcberf\u00e4llige Fristen",
"einstellungen.prefs.reminders.tomorrow": "Fristen morgen f\u00e4llig",
"einstellungen.prefs.reminders.weekly": "Wochen\u00fcbersicht (montags)",
// Invitation modal (sidebar)
"invite.button": "Kolleg:in einladen",
"invite.modal.title": "Kolleg:in zu Paliad einladen",
@@ -1494,8 +1530,44 @@ const translations: Record<Lang, Record<string, string>> = {
// Termine + CalDAV (Phase F)
"nav.termine": "Appointments",
"nav.group.einstellungen": "Settings",
"nav.einstellungen": "Settings",
"nav.caldav": "CalDAV",
// Settings page (t-paliad-022)
"einstellungen.title": "Settings \u2014 Paliad",
"einstellungen.heading": "Settings",
"einstellungen.subtitle": "Profile, notifications, and calendar sync.",
"einstellungen.loading": "Loading\u2026",
"einstellungen.optional": "(optional)",
"einstellungen.save": "Save",
"einstellungen.saved": "Saved.",
"einstellungen.error.generic": "Save failed. Please try again.",
"einstellungen.tab.profil": "Profile",
"einstellungen.tab.benachrichtigungen": "Notifications",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.profil.email": "Email",
"einstellungen.profil.email.hint": "Email cannot be changed.",
"einstellungen.profil.display_name": "Display name",
"einstellungen.profil.display_name.placeholder": "First and last name",
"einstellungen.profil.office": "Office",
"einstellungen.profil.role": "Role",
"einstellungen.profil.role.placeholder": "e.g. Associate, Partner, PA",
"einstellungen.profil.dezernat": "Department / Partner",
"einstellungen.profil.dezernat.placeholder": "e.g. Dr. M\u00fcller, Team Schmidt",
"einstellungen.profil.lang": "Language",
"einstellungen.profil.lang.de": "Deutsch",
"einstellungen.profil.lang.en": "English",
"einstellungen.profil.lang.hint": "Used for the UI and notification emails.",
"einstellungen.profil.error.display_name": "Please enter a display name.",
"einstellungen.profil.error.office": "Please select an office.",
"einstellungen.profil.error.role": "Please enter a role.",
"einstellungen.prefs.reminders.heading": "Deadline reminders",
"einstellungen.prefs.reminders.hint": "Paliad emails you when deadlines approach.",
"einstellungen.prefs.reminders.master": "Deadline reminders enabled",
"einstellungen.prefs.reminders.overdue": "Overdue deadlines",
"einstellungen.prefs.reminders.tomorrow": "Due tomorrow",
"einstellungen.prefs.reminders.weekly": "Weekly summary (Monday)",
// Invitation modal (sidebar)
"invite.button": "Invite a colleague",
"invite.modal.title": "Invite a colleague to Paliad",

View File

@@ -100,7 +100,7 @@ export function Sidebar({ currentPath }: SidebarProps): string {
)}
{group("nav.group.einstellungen", "Einstellungen",
navItem("/einstellungen/caldav", ICON_GEAR, "nav.caldav", "CalDAV", currentPath),
navItem("/einstellungen", ICON_GEAR, "nav.einstellungen", "Einstellungen", currentPath),
)}
</nav>

View File

@@ -1,135 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderEinstellungenCalDAV(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="caldav.title">CalDAV-Synchronisation &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/einstellungen/caldav" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<h1 data-i18n="caldav.heading">CalDAV-Synchronisation</h1>
<p className="tool-subtitle" data-i18n="caldav.subtitle">
Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow&hellip;).
Das Passwort wird verschl&uuml;sselt gespeichert und nie zur&uuml;ckgegeben.
</p>
</div>
<div id="caldav-disabled" className="akten-unavailable" style="display:none">
<p data-i18n="caldav.disabled">
CalDAV-Synchronisation derzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div id="caldav-status-card" className="caldav-status-card" style="display:none">
<div className="caldav-status-row">
<span className="caldav-status-label" data-i18n="caldav.status.last_sync">Letzte Synchronisation:</span>
<span className="caldav-status-value" id="caldav-last-sync">&mdash;</span>
</div>
<div className="caldav-status-row" id="caldav-status-error-row" style="display:none">
<span className="caldav-status-label" data-i18n="caldav.status.last_error">Letzter Fehler:</span>
<span className="caldav-status-value caldav-status-error" id="caldav-last-error" />
</div>
</div>
<form id="caldav-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="caldav-url" data-i18n="caldav.field.url">CalDAV-Server-URL</label>
<input
type="url"
id="caldav-url"
required
placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/"
data-i18n-placeholder="caldav.field.url.placeholder"
/>
<p className="form-hint" data-i18n="caldav.field.url.hint">
Vollst&auml;ndige URL zu Ihrem Kalender (inkl. Pfad).
</p>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="caldav-username" data-i18n="caldav.field.username">Benutzername</label>
<input type="text" id="caldav-username" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="caldav-password" data-i18n="caldav.field.password">Passwort / App-Token</label>
<input
type="password"
id="caldav-password"
autocomplete="new-password"
placeholder="••••••••"
/>
<p className="form-hint" id="caldav-password-hint" data-i18n="caldav.field.password.hint">
Bei vorhandener Konfiguration leer lassen, um das gespeicherte Passwort zu behalten.
</p>
</div>
</div>
<div className="form-field">
<label htmlFor="caldav-calendar-path" data-i18n="caldav.field.calendar_path">Kalenderpfad (optional)</label>
<input
type="text"
id="caldav-calendar-path"
placeholder="/calendars/user/personal/"
data-i18n-placeholder="caldav.field.calendar_path.placeholder"
/>
<p className="form-hint" data-i18n="caldav.field.calendar_path.hint">
Falls die URL nur den Server zeigt, ist hier der Pfad zum konkreten Kalender einzutragen.
</p>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="caldav-enabled" />
<span data-i18n="caldav.field.enabled">Synchronisation aktiv</span>
</label>
</div>
<p className="form-msg" id="caldav-msg" />
<div className="form-actions caldav-actions">
<button type="button" id="caldav-test-btn" className="btn-secondary" data-i18n="caldav.test">Verbindung testen</button>
<button type="button" id="caldav-delete-btn" className="btn-danger" style="display:none" data-i18n="caldav.delete">Konfiguration l&ouml;schen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="caldav.save">Speichern</button>
</div>
</form>
<div className="caldav-log-card">
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
<table className="akten-table caldav-log-table">
<thead>
<tr>
<th data-i18n="caldav.log.col.time">Zeitpunkt</th>
<th data-i18n="caldav.log.col.pushed">Gesendet</th>
<th data-i18n="caldav.log.col.pulled">Empfangen</th>
<th data-i18n="caldav.log.col.duration">Dauer</th>
<th data-i18n="caldav.log.col.error">Fehler</th>
</tr>
</thead>
<tbody id="caldav-log-body" />
</table>
<p className="akten-events-empty" id="caldav-log-empty" data-i18n="caldav.log.empty">
Noch keine Synchronisationen aufgezeichnet.
</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/einstellungen-caldav.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,283 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
// Unified settings page. Three tabs today (Profil / Benachrichtigungen / CalDAV)
// — keep the structure additive so future sections (keys, API tokens, etc.)
// only need one more <a class="akten-tab"> and one more <section> below.
// Tab switching is client-side from ?tab=<name>; the default tab is profil.
export function renderEinstellungen(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="einstellungen.title">Einstellungen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/einstellungen" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<h1 data-i18n="einstellungen.heading">Einstellungen</h1>
<p className="tool-subtitle" data-i18n="einstellungen.subtitle">
Profil, Benachrichtigungen und Kalendersynchronisation.
</p>
</div>
<nav className="akten-tabs" id="einstellungen-tabs">
<a className="akten-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
<a className="akten-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
<a className="akten-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
</nav>
{/* --- Profil tab ---------------------------------------- */}
<section className="akten-tab-panel" id="tab-profil">
<div id="profil-loading" className="akten-loading">
<p data-i18n="einstellungen.loading">L&auml;dt&hellip;</p>
</div>
<form id="profil-form" className="akten-form" autocomplete="off" style="display:none">
<div className="form-field">
<label htmlFor="profil-email" data-i18n="einstellungen.profil.email">E-Mail</label>
<input type="email" id="profil-email" readOnly disabled />
<p className="form-hint" data-i18n="einstellungen.profil.email.hint">
E-Mail kann nicht ge&auml;ndert werden.
</p>
</div>
<div className="form-field">
<label htmlFor="profil-display-name" data-i18n="einstellungen.profil.display_name">Anzeigename</label>
<input
type="text"
id="profil-display-name"
required
autocomplete="name"
data-i18n-placeholder="einstellungen.profil.display_name.placeholder"
placeholder="Vor- und Nachname"
/>
</div>
<div className="form-field">
<label htmlFor="profil-office" data-i18n="einstellungen.profil.office">B&uuml;ro</label>
<select id="profil-office" required />
</div>
<div className="form-field">
<label htmlFor="profil-role" data-i18n="einstellungen.profil.role">Rolle</label>
<input
type="text"
id="profil-role"
list="profil-role-suggestions"
required
autocomplete="off"
data-i18n-placeholder="einstellungen.profil.role.placeholder"
placeholder="z.B. Associate, Partner, PA"
/>
<datalist id="profil-role-suggestions">
<option value="Partner"></option>
<option value="Associate"></option>
<option value="PA"></option>
<option value="Of Counsel"></option>
<option value="Referendar/in"></option>
<option value="Trainee"></option>
<option value="wiss. Mitarbeiter/in"></option>
<option value="Sekretariat"></option>
</datalist>
</div>
<div className="form-field">
<label htmlFor="profil-dezernat" data-i18n="einstellungen.profil.dezernat">
Dezernat / Partner <span className="login-label-optional" data-i18n="einstellungen.optional">(optional)</span>
</label>
<input
type="text"
id="profil-dezernat"
autocomplete="off"
data-i18n-placeholder="einstellungen.profil.dezernat.placeholder"
placeholder="z.B. Dr. M&uuml;ller, Team Schmidt"
/>
</div>
<div className="form-field">
<label htmlFor="profil-lang" data-i18n="einstellungen.profil.lang">Sprache</label>
<select id="profil-lang">
<option value="de" data-i18n="einstellungen.profil.lang.de">Deutsch</option>
<option value="en" data-i18n="einstellungen.profil.lang.en">English</option>
</select>
<p className="form-hint" data-i18n="einstellungen.profil.lang.hint">
Wird f&uuml;r Oberfl&auml;che und Benachrichtigungs-E-Mails verwendet.
</p>
</div>
<p className="form-msg" id="profil-msg" />
<div className="form-actions">
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="einstellungen.save">Speichern</button>
</div>
</form>
</section>
{/* --- Benachrichtigungen tab ---------------------------- */}
<section className="akten-tab-panel" id="tab-benachrichtigungen" style="display:none">
<form id="prefs-form" className="akten-form" autocomplete="off">
<h2 className="settings-subhead" data-i18n="einstellungen.prefs.reminders.heading">Frist-Erinnerungen</h2>
<p className="form-hint" data-i18n="einstellungen.prefs.reminders.hint">
Paliad sendet Erinnerungen an Ihre E-Mail, wenn Fristen f&auml;llig werden.
</p>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-master" />
<span data-i18n="einstellungen.prefs.reminders.master">Frist-Erinnerungen aktiv</span>
</label>
</div>
<div id="prefs-reminders-sub" className="settings-sub-group">
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-overdue" />
<span data-i18n="einstellungen.prefs.reminders.overdue">&Uuml;berf&auml;llige Fristen</span>
</label>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-tomorrow" />
<span data-i18n="einstellungen.prefs.reminders.tomorrow">Fristen morgen f&auml;llig</span>
</label>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="prefs-reminders-weekly" />
<span data-i18n="einstellungen.prefs.reminders.weekly">Wochen&uuml;bersicht (montags)</span>
</label>
</div>
</div>
<p className="form-msg" id="prefs-msg" />
<div className="form-actions">
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="einstellungen.save">Speichern</button>
</div>
</form>
</section>
{/* --- CalDAV tab ---------------------------------------- */}
<section className="akten-tab-panel" id="tab-caldav" style="display:none">
<p className="tool-subtitle" data-i18n="caldav.subtitle">
Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow&hellip;).
Das Passwort wird verschl&uuml;sselt gespeichert und nie zur&uuml;ckgegeben.
</p>
<div id="caldav-disabled" className="akten-unavailable" style="display:none">
<p data-i18n="caldav.disabled">
CalDAV-Synchronisation derzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div id="caldav-status-card" className="caldav-status-card" style="display:none">
<div className="caldav-status-row">
<span className="caldav-status-label" data-i18n="caldav.status.last_sync">Letzte Synchronisation:</span>
<span className="caldav-status-value" id="caldav-last-sync">&mdash;</span>
</div>
<div className="caldav-status-row" id="caldav-status-error-row" style="display:none">
<span className="caldav-status-label" data-i18n="caldav.status.last_error">Letzter Fehler:</span>
<span className="caldav-status-value caldav-status-error" id="caldav-last-error" />
</div>
</div>
<form id="caldav-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="caldav-url" data-i18n="caldav.field.url">CalDAV-Server-URL</label>
<input
type="url"
id="caldav-url"
required
placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/"
data-i18n-placeholder="caldav.field.url.placeholder"
/>
<p className="form-hint" data-i18n="caldav.field.url.hint">
Vollst&auml;ndige URL zu Ihrem Kalender (inkl. Pfad).
</p>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="caldav-username" data-i18n="caldav.field.username">Benutzername</label>
<input type="text" id="caldav-username" required autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="caldav-password" data-i18n="caldav.field.password">Passwort / App-Token</label>
<input
type="password"
id="caldav-password"
autocomplete="new-password"
placeholder="••••••••"
/>
<p className="form-hint" id="caldav-password-hint" data-i18n="caldav.field.password.hint">
Bei vorhandener Konfiguration leer lassen, um das gespeicherte Passwort zu behalten.
</p>
</div>
</div>
<div className="form-field">
<label htmlFor="caldav-calendar-path" data-i18n="caldav.field.calendar_path">Kalenderpfad (optional)</label>
<input
type="text"
id="caldav-calendar-path"
placeholder="/calendars/user/personal/"
data-i18n-placeholder="caldav.field.calendar_path.placeholder"
/>
<p className="form-hint" data-i18n="caldav.field.calendar_path.hint">
Falls die URL nur den Server zeigt, ist hier der Pfad zum konkreten Kalender einzutragen.
</p>
</div>
<div className="form-field caldav-toggle-field">
<label className="caldav-toggle-label">
<input type="checkbox" id="caldav-enabled" />
<span data-i18n="caldav.field.enabled">Synchronisation aktiv</span>
</label>
</div>
<p className="form-msg" id="caldav-msg" />
<div className="form-actions caldav-actions">
<button type="button" id="caldav-test-btn" className="btn-secondary" data-i18n="caldav.test">Verbindung testen</button>
<button type="button" id="caldav-delete-btn" className="btn-danger" style="display:none" data-i18n="caldav.delete">Konfiguration l&ouml;schen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="caldav.save">Speichern</button>
</div>
</form>
<div className="caldav-log-card">
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
<table className="akten-table caldav-log-table">
<thead>
<tr>
<th data-i18n="caldav.log.col.time">Zeitpunkt</th>
<th data-i18n="caldav.log.col.pushed">Gesendet</th>
<th data-i18n="caldav.log.col.pulled">Empfangen</th>
<th data-i18n="caldav.log.col.duration">Dauer</th>
<th data-i18n="caldav.log.col.error">Fehler</th>
</tr>
</thead>
<tbody id="caldav-log-body" />
</table>
<p className="akten-events-empty" id="caldav-log-empty" data-i18n="caldav.log.empty">
Noch keine Synchronisationen aufgezeichnet.
</p>
</div>
</section>
</div>
</section>
</main>
<Footer />
<script src="/assets/einstellungen.js"></script>
</body>
</html>
);
}

View File

@@ -5184,6 +5184,23 @@ input[type="range"]::-moz-range-thumb {
padding: 0.4rem 0.6rem;
}
/* ===== Settings page (t-paliad-022) ===== */
.settings-subhead {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.settings-sub-group {
margin-left: 1.5rem;
padding-top: 0.25rem;
border-left: 2px solid var(--color-border);
padding-left: 0.8rem;
transition: opacity 0.15s ease;
}
.settings-sub-group .form-field {
margin-bottom: 0.3rem;
}
/* ===== Notizen (polymorphic notes — Phase I) ===== */
.notiz-container {
display: flex;

View File

@@ -0,0 +1,8 @@
-- Reverse t-paliad-022 preferences columns. Restore 016's nullable lang and
-- drop email_preferences entirely.
ALTER TABLE paliad.users
ALTER COLUMN lang DROP NOT NULL,
ALTER COLUMN lang DROP DEFAULT;
ALTER TABLE paliad.users
DROP COLUMN IF EXISTS email_preferences;

View File

@@ -0,0 +1,22 @@
-- Settings page (t-paliad-022): per-user email notification preferences and
-- a non-null language column.
--
-- email_preferences is a free-form JSONB bag. The app reads well-known keys
-- today (deadline_reminders, deadline_reminders.overdue, .tomorrow, .weekly);
-- unknown keys are ignored. An empty object means "all reminders on" — the
-- reminder_service treats a missing key as opt-in so existing users keep the
-- behaviour they had before the settings page shipped.
--
-- lang was added nullable in 016. Now that the settings page lets every user
-- pick DE/EN, backfill any remaining NULLs to the German default and enforce
-- NOT NULL so downstream code (reminder templates) can stop dancing around
-- a *string.
ALTER TABLE paliad.users
ADD COLUMN IF NOT EXISTS email_preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
UPDATE paliad.users SET lang = 'de' WHERE lang IS NULL;
ALTER TABLE paliad.users
ALTER COLUMN lang SET NOT NULL,
ALTER COLUMN lang SET DEFAULT 'de';

View File

@@ -159,6 +159,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/notizen/{id}", handleDeleteNotiz)
protected.HandleFunc("GET /api/me", handleGetMe)
protected.HandleFunc("PATCH /api/me", handleUpdateMe)
protected.HandleFunc("GET /api/users", handleListUsers)
protected.HandleFunc("GET /api/offices", handleListOffices)
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
@@ -204,7 +205,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /termine/kalender", gateOnboarded(handleTermineKalenderPage))
protected.HandleFunc("GET /termine/{id}", gateOnboarded(handleTermineDetailPage))
protected.HandleFunc("GET /akten/{id}/termine/neu", gateOnboarded(handleTermineNewPage))
protected.HandleFunc("GET /einstellungen/caldav", gateOnboarded(handleEinstellungenCalDAVPage))
protected.HandleFunc("GET /einstellungen", gateOnboarded(handleEinstellungenPage))
protected.HandleFunc("GET /einstellungen/caldav", handleEinstellungenCalDAVRedirect)
// Session middleware refreshes tokens; user-id middleware extracts the
// JWT sub claim into the request context for handlers that need it.

View File

@@ -23,6 +23,17 @@ func handleTermineKalenderPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/termine-kalender.html")
}
func handleEinstellungenCalDAVPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/einstellungen-caldav.html")
// handleEinstellungenPage serves the unified settings page with tabs for
// Profil / Benachrichtigungen / CalDAV. The active tab is picked client-side
// from ?tab=<name> so switching tabs doesn't round-trip.
func handleEinstellungenPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/einstellungen.html")
}
// handleEinstellungenCalDAVRedirect keeps /einstellungen/caldav working for
// bookmarks and any external links while the canonical URL moves to
// /einstellungen?tab=caldav. 301 Moved Permanently — browsers cache the hop
// so the redirect only costs once per bookmark.
func handleEinstellungenCalDAVRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/einstellungen?tab=caldav", http.StatusMovedPermanently)
}

View File

@@ -1,12 +1,15 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/services"
)
// GET /api/me — returns the caller's paliad.users row (or 404 if onboarding
@@ -41,6 +44,63 @@ func handleGetMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, u)
}
// PATCH /api/me — mutates the caller's paliad.users row. The settings page
// sends only the fields the user touched; other fields stay as-is. Email is
// *not* updatable here — auth.users owns the email and any attempt to set it
// via this endpoint is ignored (the decode silently drops unknown fields).
func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
// Decode into a permissive map first so we can detect (and reject) a
// client that *tried* to change their email — keeping behaviour explicit
// is friendlier than a silent no-op.
var peek map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&peek); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if _, tryingEmail := peek["email"]; tryingEmail {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "email cannot be changed — contact an administrator",
})
return
}
var input services.UpdateProfileInput
// Re-serialise and decode into the typed struct so strict field mapping
// applies without a second body read.
if raw, err := json.Marshal(peek); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
} else if err := json.Unmarshal(raw, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
u, err := dbSvc.users.UpdateProfile(r.Context(), uid, input)
if err != nil {
switch {
case errors.Is(err, services.ErrUserNotOnboarded):
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "no paliad.users row — onboarding required",
})
case errors.Is(err, services.ErrAdminBootstrapOnly):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "admin role cannot be self-assigned",
})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return
}
writeJSON(w, http.StatusOK, u)
}
// GET /api/users — minimal user list for the collaborator picker. Only callable
// by authenticated users. Response is the full models.User list (email +
// display_name + office + role).

View File

@@ -22,11 +22,18 @@ type User struct {
Role string `db:"role" json:"role"`
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
// Lang is the preferred UI language for transactional email ("de"/"en").
// NULL → MailService falls back to German. Not collected at onboarding
// today; reserved for a future profile-edit screen.
Lang *string `db:"lang" json:"lang,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// NOT NULL (migration 017) with DB default 'de'; the settings page lets
// every user flip it.
Lang string `db:"lang" json:"lang"`
// EmailPreferences is an opaque JSONB bag. Well-known keys today:
// deadline_reminders (bool, default true if missing)
// deadline_reminders.overdue (bool, default true)
// deadline_reminders.tomorrow (bool, default true)
// deadline_reminders.weekly (bool, default true)
// Missing key = opt-in, matching the pre-settings-page default.
EmailPreferences json.RawMessage `db:"email_preferences" json:"email_preferences"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.

View File

@@ -96,7 +96,7 @@ func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter
msg := strings.TrimSpace(in.Message)
lang := "de"
if inviter.Lang != nil && *inviter.Lang == "en" {
if inviter.Lang == "en" {
lang = "en"
}
@@ -135,7 +135,7 @@ func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter
type Inviter struct {
DisplayName string
Email string
Lang *string
Lang string
}
func (s *InviteService) domainAllowed(email string) bool {

View File

@@ -28,6 +28,7 @@ package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -130,17 +131,18 @@ func (s *ReminderService) RunOnce(ctx context.Context) {
// fristReminderRow is the projection needed to render a per-Frist email.
// We join the parent Akte for its Aktenzeichen / title and the user row for
// the preferred language.
// the preferred language and notification preferences.
type fristReminderRow struct {
FristID uuid.UUID `db:"frist_id"`
FristTitle string `db:"frist_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title"`
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang *string `db:"user_lang"`
FristID uuid.UUID `db:"frist_id"`
FristTitle string `db:"frist_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title"`
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang string `db:"user_lang"`
UserEmailPreferences json.RawMessage `db:"user_email_preferences"`
}
// sendPerFrist covers the two per-Frist reminder kinds. The query filters on
@@ -175,7 +177,8 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang
u.lang AS user_lang,
u.email_preferences AS user_email_preferences
FROM paliad.fristen f
JOIN paliad.akten a ON a.id = f.akte_id
JOIN paliad.users u ON u.id = f.created_by
@@ -197,6 +200,9 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
}
for _, r := range rows {
if !reminderEnabled(r.UserEmailPreferences, kind) {
continue
}
if err := s.deliverFristReminder(ctx, kind, r); err != nil {
slog.Warn("reminder: deliver failed",
"kind", kind, "frist_id", r.FristID, "user_id", r.UserID, "error", err)
@@ -208,7 +214,7 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, r fristReminderRow) error {
lang := "de"
if r.UserLang != nil && *r.UserLang == "en" {
if r.UserLang == "en" {
lang = "en"
}
@@ -237,10 +243,11 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string,
// preferred language. We hold rows per-user in memory and emit one email
// per user with the aggregated table.
type weeklyRow struct {
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang *string `db:"user_lang"`
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
UserLang string `db:"user_lang"`
UserEmailPreferences json.RawMessage `db:"user_email_preferences"`
FristID uuid.UUID `db:"frist_id"`
FristTitle string `db:"frist_title"`
@@ -248,6 +255,35 @@ type weeklyRow struct {
AkteAktenzeichen string `db:"akte_aktenzeichen"`
}
// reminderEnabled reports whether the user's email_preferences allow a given
// reminder kind. A missing key or empty object means on (opt-out) — that
// preserves the behaviour users had before the settings page shipped.
//
// Two gates: the master toggle "deadline_reminders" and the per-kind key
// "deadline_reminders.<kind>". Either being explicitly false skips the send.
func reminderEnabled(raw json.RawMessage, kind string) bool {
if len(raw) == 0 {
return true
}
prefs := map[string]any{}
if err := json.Unmarshal(raw, &prefs); err != nil {
// Corrupt JSON in the DB shouldn't silence reminders — err on the
// side of sending so users aren't dropped because of bad data.
return true
}
if v, ok := prefs["deadline_reminders"]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
if v, ok := prefs["deadline_reminders."+kind]; ok {
if b, ok := v.(bool); ok && !b {
return false
}
}
return true
}
func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error {
end := today.AddDate(0, 0, 7)
@@ -256,6 +292,7 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences,
f.id AS frist_id,
f.title AS frist_title,
f.due_date AS due_date,
@@ -284,6 +321,13 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error
}
for _, uid := range order {
userRows := byUser[uid]
if len(userRows) == 0 {
continue
}
if !reminderEnabled(userRows[0].UserEmailPreferences, "weekly") {
continue
}
alreadySent, err := s.hasWeeklySentSince(ctx, uid, s.clock().Add(-reminderDedupWindow))
if err != nil {
slog.Warn("reminder: weekly dedup check failed", "user_id", uid, "error", err)
@@ -292,7 +336,7 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error
if alreadySent {
continue
}
if err := s.deliverWeekly(ctx, today, byUser[uid]); err != nil {
if err := s.deliverWeekly(ctx, today, userRows); err != nil {
slog.Warn("reminder: weekly deliver failed", "user_id", uid, "error", err)
continue
}
@@ -319,7 +363,7 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro
}
first := rows[0]
lang := "de"
if first.UserLang != nil && *first.UserLang == "en" {
if first.UserLang == "en" {
lang = "en"
}
items := make([]map[string]any, 0, len(rows))

View File

@@ -0,0 +1,50 @@
package services
import (
"encoding/json"
"testing"
)
func TestReminderEnabled(t *testing.T) {
tests := []struct {
name string
raw string
kind string
want bool
}{
// Empty / missing preferences → opt-in default.
{"nil bytes default-on", "", "overdue", true},
{"empty object default-on", "{}", "overdue", true},
{"unrelated keys default-on", `{"theme":"dark"}`, "weekly", true},
// Master switch.
{"master off blocks overdue", `{"deadline_reminders":false}`, "overdue", false},
{"master off blocks tomorrow", `{"deadline_reminders":false}`, "tomorrow", false},
{"master off blocks weekly", `{"deadline_reminders":false}`, "weekly", false},
{"master on allows overdue", `{"deadline_reminders":true}`, "overdue", true},
// Per-kind override.
{"overdue explicitly off", `{"deadline_reminders.overdue":false}`, "overdue", false},
{"weekly explicitly off", `{"deadline_reminders.weekly":false}`, "weekly", false},
{"tomorrow off doesn't block weekly", `{"deadline_reminders.tomorrow":false}`, "weekly", true},
// Corrupt JSON must not silence reminders.
{"corrupt json falls back on", `{not json`, "overdue", true},
// Non-bool values are ignored (treated as absent → default on).
{"non-bool master ignored", `{"deadline_reminders":"yes"}`, "overdue", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var raw json.RawMessage
if tc.raw != "" {
raw = json.RawMessage(tc.raw)
}
got := reminderEnabled(raw, tc.kind)
if got != tc.want {
t.Errorf("reminderEnabled(%q, %q) = %v, want %v", tc.raw, tc.kind, got, tc.want)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -23,6 +24,10 @@ var (
// when other paliad.users rows already exist. Only the very first user can
// bootstrap themselves as admin (403 Forbidden on the wire).
ErrAdminBootstrapOnly = errors.New("admin role reserved for the first user")
// ErrUserNotOnboarded is returned when an endpoint that requires an
// existing paliad.users row is called by a user who hasn't onboarded yet
// (404 Not Found on the wire — callers should redirect to /onboarding).
ErrUserNotOnboarded = errors.New("paliad.users row missing — onboarding required")
)
// UserService reads paliad.users. Writes happen via the Phase D onboarding
@@ -37,7 +42,7 @@ func NewUserService(db *sqlx.DB) *UserService {
}
const userColumns = `id, email, display_name, office, practice_group, role, dezernat,
lang, created_at, updated_at`
lang, email_preferences, created_at, updated_at`
// GetByID returns the user row, or (nil, nil) if the user hasn't completed
// onboarding yet. Real errors bubble up.
@@ -152,6 +157,119 @@ func (s *UserService) Create(ctx context.Context, id uuid.UUID, email string, in
return s.GetByID(ctx, id)
}
// UpdateProfileInput is the payload for PATCH /api/me. Every field is a
// pointer so callers can omit keys they don't want to touch — the settings
// page sends only the fields the user changed. Email is deliberately absent:
// auth.users.email is the source of truth and the handler rejects any attempt
// to mutate it via this endpoint.
type UpdateProfileInput struct {
DisplayName *string `json:"display_name,omitempty"`
Office *string `json:"office,omitempty"`
Role *string `json:"role,omitempty"`
Dezernat *string `json:"dezernat,omitempty"`
Lang *string `json:"lang,omitempty"`
EmailPreferences *json.RawMessage `json:"email_preferences,omitempty"`
}
// UpdateProfile mutates the paliad.users row for the authenticated user.
// Returns the fresh row.
//
// The 'admin' role is never assignable via this endpoint — role changes
// downgrading an admin, or promoting a non-admin to admin, must go through
// SQL / a future admin UI (mirrors the onboarding restriction).
func (s *UserService) UpdateProfile(ctx context.Context, id uuid.UUID, input UpdateProfileInput) (*models.User, error) {
sets := []string{}
args := []any{}
i := 1
if input.DisplayName != nil {
dn := strings.TrimSpace(*input.DisplayName)
if dn == "" {
return nil, fmt.Errorf("display_name cannot be empty")
}
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("invalid office %q", *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("role cannot be empty")
}
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.Lang != nil {
lang := strings.ToLower(strings.TrimSpace(*input.Lang))
if lang != "de" && lang != "en" {
return nil, fmt.Errorf("invalid lang %q (expected de or en)", *input.Lang)
}
sets = append(sets, fmt.Sprintf("lang = $%d", i))
args = append(args, lang)
i++
}
if input.EmailPreferences != nil {
raw := *input.EmailPreferences
// Reject anything that isn't a JSON object — the column is JSONB but
// the app model is "bag of feature flags", not arbitrary scalars.
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("email_preferences must be a JSON object")
}
sets = append(sets, fmt.Sprintf("email_preferences = $%d", i))
args = append(args, []byte(raw))
i++
}
if len(sets) == 0 {
// No-op PATCH is legal — just return the current row.
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)
}
// List returns all users (used by collaborator-picker in Phase D).
func (s *UserService) List(ctx context.Context) ([]models.User, error) {
var users []models.User