sudo is a React app now, hydrated in the browser as the site is: pages are still rendered on the server, then React Router takes over, so a sidebar link opens the next page without reloading. A lavender bar across the top shows while the next page loads (it appears at once, creeps, then fills and fades, as on the site) and the page dims after a moment. The phone menu closes when a link is followed, and a <Form> that was sent goes back to what the page now says, its button letting go of the spinner. The pages run only sudo's own scripts: each page's content security policy names a nonce made for that page (script-src 'self' 'nonce-…', connect-src 'self', everything else as strict as before, still no inline style), entry.server.tsx hands it to React Router's inline scripts and preloads, and every answer that is not a page (data, redirects, files) still forbids every script. The <details> folds still open before any script loads. Comments and the README that said sudo ships no JavaScript now say what is true. Checked: npm run typecheck and npm test in apps/sudo (160, new: the page policy and its nonce), npm run build, and the built worker run locally with the Access check stubbed (not committed) in headless Chrome: no CSP violations or console errors, the page hydrates, a folded section opens on click, a sidebar link navigates client-side with the bar and the dimming, the phone menu closes on navigation, and .data answers keep script-src 'none'. Posting a form was not tried locally (the same-origin check wants https://sudo.g1t.sh).
- G1t-Session:
- ses_01m50b25ede9b8spq6dcbvpvzh
18 files+242−390/18 viewed
| 246 | 246 | enterprises, Stripe billing links, invoice emails, invoices and the | |
| 247 | 247 | webhook show a confirmation step first; a credit needs the workspace's | |
| 248 | 248 | slug typed out. | |
| 249 | − | 5. **The pages ship no JavaScript.** The content security policy forbids | |
| 250 | − | every script and inline style; responses are `no-store`, `noindex` and | |
| 251 | − | cannot be framed. The worker has no `workers.dev` address or preview URLs. | |
| 249 | + | 5. **The pages run only sudo's own scripts.** Each page is rendered on the | |
| 250 | + | server and hydrated in the browser, as apps/web's are. Its content | |
| 251 | + | security policy names a nonce made for that page | |
| 252 | + | (`script-src 'self' 'nonce-…'`, `app/entry.server.tsx` and | |
| 253 | + | `app/lib/guard.ts`): sudo's files and the inline scripts React Router | |
| 254 | + | writes carrying the nonce run, and nothing else, no other site's and no | |
| 255 | + | other inline script; requests go only back to sudo; no inline style. | |
| 256 | + | Every other answer (data, redirects, files) forbids every script. | |
| 257 | + | Responses are `no-store`, `noindex` and cannot be framed. The worker has | |
| 258 | + | no `workers.dev` address or preview URLs. | |
| 252 | 259 | ||
| 253 | 260 | ## Setting up Access (once, in the Cloudflare dashboard) | |
| 254 | 261 |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | /* | |
| 74 | − | * sudo ships no JavaScript, so a long-running button says it is running | |
| 75 | − | * with CSS alone: a clicked button keeps focus while the old page waits for | |
| 76 | − | * the response. `:focus-visible` is left out, so tabbing to it does not | |
| 74 | + | * A long-running button says it is running with CSS alone, before any | |
| 75 | + | * script has loaded: a clicked button keeps focus while the old page waits | |
| 76 | + | * for the response (components/progress.tsx lets go of it once a <Form>'s | |
| 77 | + | * answer is in). `:focus-visible` is left out, so tabbing to it does not | |
| 77 | 78 | * read as running. | |
| 78 | 79 | */ | |
| 79 | 80 | .run-button .run-busy, |
| 1 | 1 | /** | |
| 2 | 2 | * The billing sections shared by a workspace's page and an enterprise's: | |
| 3 | 3 | * confirmations, terms, plan, pools and caps, credit, bank transfers, the | |
| 4 | − | * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript. | |
| 4 | + | * Stripe billing link, the ledger and the audit log. Plain forms, which work before any script has loaded. | |
| 5 | 5 | */ | |
| 6 | 6 | import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react"; | |
| 7 | 7 | import type { ReactNode } from "react"; |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | /** | |
| 124 | − | * The button that runs the nightly analysis now. sudo runs no JavaScript, | |
| 125 | − | * so app.css's run-button rules say it is running while the page waits. | |
| 124 | + | * The button that runs the nightly analysis now. A plain form: app.css's | |
| 125 | + | * run-button rules say it is running while the page waits. | |
| 126 | 126 | */ | |
| 127 | 127 | function RunButton() { | |
| 128 | 128 | return ( |
| 1 | 1 | /** | |
| 2 | 2 | * The Incidents pages' small parts: chips, a page's back link, the | |
| 3 | − | * timers strip and the component impact picker. Forms only: sudo ships | |
| 4 | − | * no JavaScript. | |
| 3 | + | * timers strip and the component impact picker. Forms only, which work | |
| 4 | + | * before any script has loaded. | |
| 5 | 5 | */ | |
| 6 | 6 | import { ArrowLeft } from "lucide-react"; | |
| 7 | 7 | import type { ReactNode } from "react"; |
| 2 | 2 | * An incident's parts, check by check: a latency chart per part, drawn as | |
| 3 | 3 | * SVG on the server, with the slow line, slow checks dotted and failures | |
| 4 | 4 | * marked along the top, and the latest checks as a table under it. No | |
| 5 | − | * script and no inline style (sudo ships no JavaScript); the table is the | |
| 5 | + | * script and no inline style (sudo's policy allows none); the table is the | |
| 6 | 6 | * way to read exact figures. | |
| 7 | 7 | */ | |
| 8 | 8 | import type { CheckHistory } from "@g1t/contracts"; |
| 1 | + | /** | |
| 2 | + | * The page on its way out while the next one loads, as apps/web's shell | |
| 3 | + | * has it: a bar across the top, and the page dimming after a moment. | |
| 4 | + | */ | |
| 5 | + | import { useEffect, useRef, useState } from "react"; | |
| 6 | + | import { useNavigation } from "react-router"; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * The page while the next one loads: after a moment it dims, under the | |
| 10 | + | * bar below, so it is plainly on its way out rather than frozen, and the | |
| 11 | + | * next page fades up from there. Sending a form does not dim it: the | |
| 12 | + | * form's button says it is working. Spread on the element holding the page. | |
| 13 | + | */ | |
| 14 | + | export function useLeaving() { | |
| 15 | + | const navigation = useNavigation(); | |
| 16 | + | const method = navigation.formMethod?.toUpperCase(); | |
| 17 | + | const leaving = navigation.state === "loading" && (method == null || method === "GET"); | |
| 18 | + | return { | |
| 19 | + | "aria-busy": leaving || undefined, | |
| 20 | + | className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`, | |
| 21 | + | }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * A form React Router sent (<Form>) leaves the page as a fresh one would: | |
| 26 | + | * once the answer is in, the form goes back to what the page now says | |
| 27 | + | * and its button lets go of focus, so app.css's "working" spinner on a | |
| 28 | + | * focused submit button stops. A plain <form> loads a new page anyway. | |
| 29 | + | */ | |
| 30 | + | export function useSettledForms() { | |
| 31 | + | const navigation = useNavigation(); | |
| 32 | + | const sent = useRef<HTMLFormElement | null>(null); | |
| 33 | + | useEffect(() => { | |
| 34 | + | const remember = (event: SubmitEvent) => { | |
| 35 | + | if (event.target instanceof HTMLFormElement) sent.current = event.target; | |
| 36 | + | }; | |
| 37 | + | document.addEventListener("submit", remember); | |
| 38 | + | return () => document.removeEventListener("submit", remember); | |
| 39 | + | }, []); | |
| 40 | + | useEffect(() => { | |
| 41 | + | const form = sent.current; | |
| 42 | + | if (navigation.state !== "idle" || !form) return; | |
| 43 | + | sent.current = null; | |
| 44 | + | if (!form.isConnected) return; | |
| 45 | + | form.reset(); | |
| 46 | + | if (document.activeElement instanceof HTMLElement && form.contains(document.activeElement)) document.activeElement.blur(); | |
| 47 | + | }, [navigation.state]); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * A bar across the top of the page while the next one loads: it appears | |
| 52 | + | * at once, creeps towards the end while waiting, then fills and fades when | |
| 53 | + | * the page arrives. At rest it has no width and no style attribute, so the | |
| 54 | + | * server's page carries no inline style for the policy to refuse; React | |
| 55 | + | * sets the width through the DOM once it moves. | |
| 56 | + | */ | |
| 57 | + | export function Progress() { | |
| 58 | + | const navigation = useNavigation(); | |
| 59 | + | const busy = navigation.state !== "idle"; | |
| 60 | + | const [width, setWidth] = useState(0); | |
| 61 | + | const [visible, setVisible] = useState(false); | |
| 62 | + | useEffect(() => { | |
| 63 | + | if (busy) { | |
| 64 | + | setVisible(true); | |
| 65 | + | setWidth(12); | |
| 66 | + | // Slows as it goes, never quite arriving until the page does. | |
| 67 | + | const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200); | |
| 68 | + | return () => clearInterval(timer); | |
| 69 | + | } | |
| 70 | + | setWidth((now) => (now > 0 ? 100 : 0)); | |
| 71 | + | const fade = setTimeout(() => setVisible(false), 250); | |
| 72 | + | const reset = setTimeout(() => setWidth(0), 550); | |
| 73 | + | return () => { | |
| 74 | + | clearTimeout(fade); | |
| 75 | + | clearTimeout(reset); | |
| 76 | + | }; | |
| 77 | + | }, [busy]); | |
| 78 | + | return ( | |
| 79 | + | <div | |
| 80 | + | aria-hidden="true" | |
| 81 | + | className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${ | |
| 82 | + | visible ? "opacity-100" : "opacity-0" | |
| 83 | + | }`} | |
| 84 | + | > | |
| 85 | + | <div | |
| 86 | + | className="h-full w-0 bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out" | |
| 87 | + | style={width > 0 ? { width: `${width}%` } : undefined} | |
| 88 | + | /> | |
| 89 | + | </div> | |
| 90 | + | ); | |
| 91 | + | } |
| 2 | 2 | * sudo's frame. On wide screens, a sidebar: Overview and Reach out as | |
| 3 | 3 | * links, then sections that fold open to their pages, as GitLab and | |
| 4 | 4 | * Vercel do theirs (apps/web's project sidebar is the same pattern). On a | |
| 5 | − | * phone, a top bar whose menu holds the same. sudo ships no JavaScript, | |
| 6 | − | * so every fold is a <details>, drawn open on the server for the section | |
| 7 | − | * holding the current page. All of it comes from lib/nav.ts. | |
| 5 | + | * phone, a top bar whose menu holds the same. Every fold is a <details>, | |
| 6 | + | * which opens and closes before the page's script has loaded, drawn open | |
| 7 | + | * for the section holding the current page. All of it comes from | |
| 8 | + | * lib/nav.ts. | |
| 8 | 9 | */ | |
| 9 | 10 | import { | |
| 10 | 11 | Activity, | |
| ⋯ | |||
| 279 | 280 | <Link to="/" aria-label="sudo overview" className="flex"> | |
| 280 | 281 | <Logo /> | |
| 281 | 282 | </Link> | |
| 282 | − | {/* A details element: it opens and closes with no script. A link | |
| 283 | − | loads a new page, which arrives with the menu closed. */} | |
| 284 | − | <details className="group/menu"> | |
| 283 | + | {/* A details element: it opens and closes with no script. Keyed | |
| 284 | + | by the page, so the next page arrives with the menu closed. */} | |
| 285 | + | <details key={pathname} className="group/menu"> | |
| 285 | 286 | <summary | |
| 286 | 287 | aria-label="Menu" | |
| 287 | 288 | className="flex h-9 cursor-pointer list-none items-center gap-1.5 rounded-md border border-line px-2.5 text-sm text-muted transition-colors select-none hover:border-line-strong hover:text-fg group-open/menu:border-line-strong group-open/menu:text-fg [&::-webkit-details-marker]:hidden" | |
| 2 | 2 | * Moving workspaces off Stripe's test mode before the live key, history | |
| 3 | 3 | * kept: one workspace on its page, every workspace on the Stripe page. A | |
| 4 | 4 | * preview (dry run) lists what will change; the change itself needs the | |
| 5 | − | * slug, or "detach", typed. sudo ships no JavaScript: the apply buttons say | |
| 6 | − | * they are running with app.css's run-button rules. | |
| 5 | + | * slug, or "detach", typed. Plain forms: the apply buttons say they are | |
| 6 | + | * running with app.css's run-button rules. | |
| 7 | 7 | */ | |
| 8 | 8 | import { Eye, Unplug } from "lucide-react"; | |
| 9 | 9 |
| 58 | 58 | ||
| 59 | 59 | /** | |
| 60 | 60 | * A native select drawn as shadcn's NativeSelect: the browser's own arrow | |
| 61 | − | * hidden, a chevron of ours in its place. Native because sudo ships no | |
| 62 | − | * JavaScript, so it still opens, types to search and works on phones. | |
| 61 | + | * hidden, a chevron of ours in its place. Native, so it opens, types to | |
| 62 | + | * search and works on phones before any script has loaded. | |
| 63 | 63 | */ | |
| 64 | 64 | export function Select({ className = "", ...props }: ComponentProps<"select">) { | |
| 65 | 65 | const classes = className.split(/\s+/).filter(Boolean); |
| 1 | + | import { StrictMode, startTransition } from "react"; | |
| 2 | + | import { hydrateRoot } from "react-dom/client"; | |
| 3 | + | import { HydratedRouter } from "react-router/dom"; | |
| 4 | + | ||
| 5 | + | startTransition(() => { | |
| 6 | + | hydrateRoot( | |
| 7 | + | document, | |
| 8 | + | <StrictMode> | |
| 9 | + | <HydratedRouter /> | |
| 10 | + | </StrictMode>, | |
| 11 | + | { | |
| 12 | + | // React recovers from a page the server rendered differently by | |
| 13 | + | // rendering it again here; say where, so it can be fixed. | |
| 14 | + | onRecoverableError(error, info) { | |
| 15 | + | console.error("sudo: recovered from", error, info.componentStack); | |
| 16 | + | }, | |
| 17 | + | }, | |
| 18 | + | ); | |
| 19 | + | }); |
| 1 | 1 | import { renderToReadableStream } from "react-dom/server"; | |
| 2 | 2 | import { type EntryContext, ServerRouter } from "react-router"; | |
| 3 | 3 | ||
| 4 | + | import { makeNonce, pagePolicy } from "./lib/guard"; | |
| 5 | + | ||
| 4 | 6 | /** | |
| 5 | − | * Renders the whole page before sending it. sudo ships no JavaScript, so | |
| 6 | − | * there is nothing to stream into and no inline script to allow. | |
| 7 | + | * Renders the whole page before sending it, then the browser hydrates it. | |
| 8 | + | * The page runs only sudo's own scripts: React Router writes its inline | |
| 9 | + | * scripts and preloads with this page's nonce (ServerRouter hands it to | |
| 10 | + | * <Links>, <Scripts> and <ScrollRestoration>), and the page's policy | |
| 11 | + | * names that nonce and nothing else (lib/guard.ts). workers/app.ts keeps | |
| 12 | + | * the policy set here. | |
| 7 | 13 | */ | |
| 8 | 14 | export default async function handleRequest( | |
| 9 | 15 | request: Request, | |
| ⋯ | |||
| 12 | 18 | routerContext: EntryContext, | |
| 13 | 19 | ) { | |
| 14 | 20 | let status = responseStatusCode; | |
| 15 | − | const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, { | |
| 21 | + | const nonce = makeNonce(); | |
| 22 | + | const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} nonce={nonce} />, { | |
| 23 | + | nonce, | |
| 16 | 24 | signal: request.signal, | |
| 17 | 25 | onError(error: unknown) { | |
| 18 | 26 | status = 500; | |
| ⋯ | |||
| 21 | 29 | }); | |
| 22 | 30 | await body.allReady; | |
| 23 | 31 | responseHeaders.set("content-type", "text/html; charset=utf-8"); | |
| 32 | + | responseHeaders.set("content-security-policy", pagePolicy(nonce)); | |
| 24 | 33 | return new Response(body, { headers: responseHeaders, status }); | |
| 25 | 34 | } | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CONTENT_SECURITY_POLICY, makeNonce, pagePolicy, secure } from "./guard.ts"; | |
| 5 | + | ||
| 6 | + | function directives(policy: string): Map<string, string> { | |
| 7 | + | return new Map(policy.split("; ").map((part) => [part.split(" ")[0]!, part])); | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | test("a page runs only sudo's own scripts, by its nonce", () => { | |
| 11 | + | const nonce = makeNonce(); | |
| 12 | + | assert.match(nonce, /^[A-Za-z0-9+/]{22}==$/); | |
| 13 | + | assert.notEqual(nonce, makeNonce()); | |
| 14 | + | const page = directives(pagePolicy(nonce)); | |
| 15 | + | assert.equal(page.get("script-src"), `script-src 'self' 'nonce-${nonce}'`); | |
| 16 | + | assert.equal(page.get("connect-src"), "connect-src 'self'"); | |
| 17 | + | assert.doesNotMatch(pagePolicy(nonce), /unsafe-|strict-dynamic|https?:|\*/); | |
| 18 | + | // Everything else as strict as an answer that is not a page. | |
| 19 | + | const rest = directives(CONTENT_SECURITY_POLICY); | |
| 20 | + | for (const [name, value] of rest) if (name !== "script-src") assert.equal(page.get(name), value, name); | |
| 21 | + | assert.deepEqual([...page.keys()].filter((name) => !rest.has(name)), ["connect-src"]); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("an answer that is not a page runs nothing", () => { | |
| 25 | + | assert.equal(directives(CONTENT_SECURITY_POLICY).get("script-src"), "script-src 'none'"); | |
| 26 | + | assert.equal(directives(CONTENT_SECURITY_POLICY).get("default-src"), "default-src 'none'"); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("a page keeps its own policy; anything else gets the strict one", () => { | |
| 30 | + | const nonce = makeNonce(); | |
| 31 | + | const page = secure(new Response("<html></html>", { headers: { "content-security-policy": pagePolicy(nonce) } })); | |
| 32 | + | assert.equal(page.headers.get("content-security-policy"), pagePolicy(nonce)); | |
| 33 | + | assert.equal(page.headers.get("cache-control"), "no-store"); | |
| 34 | + | const data = secure(new Response("{}")); | |
| 35 | + | assert.equal(data.headers.get("content-security-policy"), CONTENT_SECURITY_POLICY); | |
| 36 | + | }); |
| 4 | 4 | */ | |
| 5 | 5 | ||
| 6 | 6 | /** | |
| 7 | − | * The pages ship no JavaScript, so no script may run at all; styles and | |
| 8 | − | * images and fonts come only from sudo itself, and forms post only back | |
| 9 | − | * to sudo. | |
| 7 | + | * The policy of every answer that is not a page (data, redirects, files): | |
| 8 | + | * no script may run at all; styles, images and fonts come only from sudo | |
| 9 | + | * itself, and forms post only back to sudo. | |
| 10 | 10 | */ | |
| 11 | 11 | export const CONTENT_SECURITY_POLICY = [ | |
| 12 | 12 | "default-src 'none'", | |
| ⋯ | |||
| 20 | 20 | "upgrade-insecure-requests", | |
| 21 | 21 | ].join("; "); | |
| 22 | 22 | ||
| 23 | + | /** A fresh nonce for one page: 128 random bits, base64. */ | |
| 24 | + | export function makeNonce(): string { | |
| 25 | + | const bytes = crypto.getRandomValues(new Uint8Array(16)); | |
| 26 | + | return btoa(String.fromCharCode(...bytes)); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * The policy of a page rendered with `nonce` (entry.server.tsx): as strict | |
| 31 | + | * as every other answer's, except that the page runs sudo's own scripts, | |
| 32 | + | * its files from /assets and the inline scripts React Router writes, each | |
| 33 | + | * carrying the page's nonce, and nothing else, no other site's and no | |
| 34 | + | * other inline script. Those scripts fetch the next page's data from sudo | |
| 35 | + | * itself and nowhere else. Still no inline style: React sets a style | |
| 36 | + | * through the DOM, which the policy allows. | |
| 37 | + | */ | |
| 38 | + | export function pagePolicy(nonce: string): string { | |
| 39 | + | return [ | |
| 40 | + | "default-src 'none'", | |
| 41 | + | `script-src 'self' 'nonce-${nonce}'`, | |
| 42 | + | "style-src 'self'", | |
| 43 | + | "font-src 'self'", | |
| 44 | + | "img-src 'self' data:", | |
| 45 | + | "connect-src 'self'", | |
| 46 | + | "form-action 'self'", | |
| 47 | + | "frame-ancestors 'none'", | |
| 48 | + | "base-uri 'none'", | |
| 49 | + | "upgrade-insecure-requests", | |
| 50 | + | ].join("; "); | |
| 51 | + | } | |
| 52 | + | ||
| 23 | 53 | const HEADERS: Record<string, string> = { | |
| 24 | 54 | "cache-control": "no-store", | |
| 25 | 55 | "x-robots-tag": "noindex, nofollow, noarchive", | |
| ⋯ | |||
| 33 | 63 | "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()", | |
| 34 | 64 | }; | |
| 35 | 65 | ||
| 36 | − | /** The response with sudo's headers; a policy it already set is kept. */ | |
| 66 | + | /** | |
| 67 | + | * The response with sudo's headers. A policy it already set is kept: a | |
| 68 | + | * page's, with its nonce, or a refusal's. | |
| 69 | + | */ | |
| 37 | 70 | export function secure(response: Response): Response { | |
| 38 | 71 | const secured = new Response(response.body, response); | |
| 39 | 72 | for (const [name, value] of Object.entries(HEADERS)) secured.headers.set(name, value); | |
| 1 | 1 | /** | |
| 2 | 2 | * Times in the staff member's own zone, on every page (components/ui.tsx | |
| 3 | − | * `When`) and in the forms that take a time. sudo ships no script, so the | |
| 4 | − | * zone comes from the request: the `sudo_tz` cookie when someone chose one | |
| 3 | + | * `When`) and in the forms that take a time. Pages are drawn on the | |
| 4 | + | * server first, so the zone comes from the request: the `sudo_tz` cookie when someone chose one | |
| 5 | 5 | * (/timezone), else Cloudflare's guess (`request.cf.timezone`), else UTC. | |
| 6 | 6 | * Every time shown says its zone's abbreviation, and `<time datetime>` | |
| 7 | 7 | * plus the hover title keep the UTC instant. No Workers or React imports, |
| 1 | 1 | import { privacy } from "./lib/services.server"; | |
| 2 | − | import { isRouteErrorResponse, Links, Meta, Outlet, useRouteLoaderData } from "react-router"; | |
| 2 | + | import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/root"; | |
| 5 | 5 | import "./app.css"; | |
| 6 | 6 | import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url"; | |
| 7 | 7 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 8 | + | import { Progress, useLeaving, useSettledForms } from "./components/progress"; | |
| 8 | 9 | import { MobileBar, Sidebar } from "./components/shell"; | |
| 9 | 10 | import { ButtonLink } from "./components/ui"; | |
| 10 | 11 | import type { NavCounts } from "./lib/nav"; | |
| ⋯ | |||
| 54 | 55 | ||
| 55 | 56 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 56 | 57 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 58 | + | const leaving = useLeaving(); | |
| 59 | + | useSettledForms(); | |
| 57 | 60 | return ( | |
| 58 | 61 | <html lang="en"> | |
| 59 | 62 | <head> | |
| ⋯ | |||
| 64 | 67 | <Links /> | |
| 65 | 68 | </head> | |
| 66 | 69 | <body className="min-h-screen"> | |
| 70 | + | <Progress /> | |
| 67 | 71 | <Sidebar email={root?.email} counts={root?.counts} /> | |
| 68 | 72 | <MobileBar email={root?.email} counts={root?.counts} /> | |
| 69 | 73 | <div className="lg:pl-60"> | |
| ⋯ | |||
| 92 | 96 | </a> | |
| 93 | 97 | </div> | |
| 94 | 98 | )} | |
| 95 | − | {children} | |
| 99 | + | <div {...leaving}>{children}</div> | |
| 96 | 100 | </div> | |
| 97 | − | {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */} | |
| 101 | + | {/* Each takes the page's nonce from ServerRouter (entry.server.tsx). */} | |
| 102 | + | <ScrollRestoration /> | |
| 103 | + | <Scripts /> | |
| 98 | 104 | </body> | |
| 99 | 105 | </html> | |
| 100 | 106 | ); | |
| 287 | 287 | ||
| 288 | 288 | /** | |
| 289 | 289 | * Deleting the account, or, once it is deleted, restoring or purging it | |
| 290 | − | * and the workspaces deleted with it. Every form is plain HTML: sudo ships | |
| 291 | − | * no JavaScript. | |
| 290 | + | * and the workspaces deleted with it. Every form works as plain HTML, | |
| 291 | + | * before any script has loaded. | |
| 292 | 292 | */ | |
| 293 | 293 | function AccountSection({ | |
| 294 | 294 | user, |
| 1 | 1 | import type { Config } from "@react-router/dev/config"; | |
| 2 | 2 | ||
| 3 | 3 | export default { | |
| 4 | − | // Rendered on the server only: the pages ship no JavaScript at all (root | |
| 5 | − | // leaves out <Scripts />), so the content security policy can forbid | |
| 6 | − | // every script. Forms are plain HTML forms posting to route actions. | |
| 4 | + | // Rendered on the server, then hydrated in the browser. The page's policy | |
| 5 | + | // lets only sudo's own scripts run, by a nonce made for each page | |
| 6 | + | // (entry.server.tsx, lib/guard.ts). Forms post to route actions. | |
| 7 | 7 | ssr: true, | |
| 8 | 8 | } satisfies Config; |
Comments
Sign in to comment.