feat(determinator/slice-2): /projects/new return-bounce + Step 1 preselect

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.
This commit is contained in:
m
2026-05-08 19:54:11 +02:00
parent d4c129f0d6
commit dba8ad3fdd
2 changed files with 33 additions and 1 deletions

View File

@@ -17,6 +17,21 @@ function $(id: string): HTMLElement {
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;
@@ -41,6 +56,20 @@ function submitForm() {
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);

View File

@@ -136,7 +136,10 @@ export function renderFristenrechner(): string {
<div className="fristen-step1-divider">
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
</div>
<a href="/projects/new" className="fristen-step1-new" id="fristen-step1-new"
{/* return-bounce: projects-new.ts honours ?return= and
redirects back to /tools/fristenrechner?project=<new_uuid>
so the new Akte preselects itself in Step 1. */}
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
data-i18n="deadlines.step1.new.cta">
+ Neue Akte anlegen
</a>