m's 2026-05-08 Slice 2: "Neue Akte anlegen" on the Fristenrechner now
round-trips cleanly. The Step 1 link sends `?return=/tools/fristenrechner`
on the way out; projects-new.ts honours the param after a successful
POST and redirects back with `?project=<new_uuid>` appended so the
just-created Akte preselects itself in Step 1.
Two pieces:
- frontend/src/client/projects-new.ts — new sanitizeReturnUrl()
rejects anything that could escape to a different origin
(protocol-relative `//foo`, absolute `https://...`, non-rooted
relative paths). On submit success, if a sanitized return URL
exists, build the destination via URL() so existing query params
on the return path stay intact and ?project= is set without
clobbering, then redirect there. Falls back to /projects/{id}
when no return param is present (existing behaviour preserved).
- frontend/src/fristenrechner.tsx — Step 1 link gets the
?return=/tools/fristenrechner query string so the bounce-back
knows where to land.
Step 1 hydration from Slice 1 already handles `?project=<uuid>` —
fetchProjects() repopulates cachedAkten, the projectId looks up its
ProjectOption record, renderStep1Summary() renders the collapsed
state, Step 2 cards become visible. No client-side state coordination
needed; the URL is the contract.
Refs t-paliad-157 / m/paliad#15.
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
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=<path> 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();
|
|
});
|