import { initI18n } from "./i18n"; import { initSidebar } from "./sidebar"; import { loadParentCandidates, initParentPicker, wireTypeChange, showFieldsForType, readPayload, } from "./project-form"; // /projects/new client. Posts v2 CreateProjectInput shape using the shared // project-form helpers. function $(id: string): HTMLElement { const el = document.getElementById(id); if (!el) throw new Error("missing element: " + id); return el; } // sanitizeReturnUrl restricts the post-create bounce-back to same-origin // paths. Any value that could escape to a different origin (protocol- // relative `//foo`, absolute `https://...`, or non-rooted relative // paths) is rejected and the form falls back to /projects/{id}. m's // 2026-05-08 Determinator Slice 2: the /tools/fristenrechner Step 1 // "Neue Akte anlegen" link sends ?return=/tools/fristenrechner so the // new project preselects itself when control bounces back. function sanitizeReturnUrl(raw: string | null): string | null { if (!raw) return null; if (raw.startsWith("//")) return null; if (raw.includes("://")) return null; if (!raw.startsWith("/")) return null; return raw; } function submitForm() { const form = $("project-new-form") as HTMLFormElement; const msg = $("project-new-msg") as HTMLParagraphElement; form.addEventListener("submit", async (e) => { e.preventDefault(); msg.textContent = ""; msg.className = "form-msg"; const payload = readPayload(msg, { omitEmpty: true, mode: "create" }); if (!payload) return; try { const resp = await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!resp.ok) { const errBody = await resp.json().catch(() => ({ error: "unknown" })); msg.textContent = errBody.error || "Fehler beim Anlegen"; msg.className = "form-msg form-msg-error"; return; } const p = (await resp.json()) as { id: string }; // Honour ?return= if it's a same-origin rooted path. The // caller is responsible for ensuring the destination knows what // to do with the appended ?project= param; see Slice 1's Step 1 // hydration. const qs = new URLSearchParams(window.location.search); const returnUrl = sanitizeReturnUrl(qs.get("return")); if (returnUrl) { const dest = new URL(returnUrl, window.location.origin); dest.searchParams.set("project", p.id); window.location.href = dest.pathname + dest.search + dest.hash; return; } window.location.href = `/projects/${p.id}`; } catch (e) { msg.textContent = String(e); msg.className = "form-msg form-msg-error"; } }); } async function applyParentFromQueryString() { const qs = new URLSearchParams(window.location.search); const parentID = qs.get("parent"); if (!parentID) return; try { const resp = await fetch(`/api/projects/${encodeURIComponent(parentID)}`); if (!resp.ok) return; const p = (await resp.json()) as { id: string; title: string }; ($("projekt-parent-id") as HTMLInputElement).value = p.id; ($("projekt-parent-input") as HTMLInputElement).value = p.title; // Default to 'case' under a non-root parent; user can override. const typeSel = $("project-type") as HTMLSelectElement; if (typeSel.value === "client") { typeSel.value = "case"; showFieldsForType(typeSel.value); } } catch { // ignore } } document.addEventListener("DOMContentLoaded", async () => { initI18n(); initSidebar(); wireTypeChange(); await loadParentCandidates(); initParentPicker(); await applyParentFromQueryString(); submitForm(); });