Merge: settings page — profile, email prefs, CalDAV tabs
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
544
frontend/src/client/einstellungen.ts
Normal file
544
frontend/src/client/einstellungen.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 — 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…).
|
||||
Das Passwort wird verschlüsselt gespeichert und nie zurückgegeben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="caldav-disabled" className="akten-unavailable" style="display:none">
|
||||
<p data-i18n="caldav.disabled">
|
||||
CalDAV-Synchronisation derzeit nicht verfügbar — 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">—</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ä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ö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>
|
||||
);
|
||||
}
|
||||
283
frontend/src/einstellungen.tsx
Normal file
283
frontend/src/einstellungen.tsx
Normal 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 — 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ädt…</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ä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ü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ü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ür Oberflä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ä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">Überfä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ä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ü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…).
|
||||
Das Passwort wird verschlüsselt gespeichert und nie zurückgegeben.
|
||||
</p>
|
||||
|
||||
<div id="caldav-disabled" className="akten-unavailable" style="display:none">
|
||||
<p data-i18n="caldav.disabled">
|
||||
CalDAV-Synchronisation derzeit nicht verfügbar — 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">—</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ä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ö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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
8
internal/db/migrations/017_user_preferences.down.sql
Normal file
8
internal/db/migrations/017_user_preferences.down.sql
Normal 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;
|
||||
22
internal/db/migrations/017_user_preferences.up.sql
Normal file
22
internal/db/migrations/017_user_preferences.up.sql
Normal 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';
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
50
internal/services/reminder_service_test.go
Normal file
50
internal/services/reminder_service_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user