diff --git a/install/nginx.conf b/install/nginx.conf index 21381b764..3158c6421 100644 --- a/install/nginx.conf +++ b/install/nginx.conf @@ -47,6 +47,10 @@ server { # Web console: a static build served straight off disk. Everything it # talks to is under /api on this same host, so no CORS is involved. # + location = /app { + return 301 /app/; + } + # The directory is named to match the URL so plain root resolution # applies. "alias" with try_files appends the whole URI to the alias # rather than the remainder, which silently breaks the SPA fallback. @@ -54,13 +58,17 @@ server { include /etc/nginx/mime.types; root NGINX_DIR/web; try_files $uri $uri/ /app/index.html; + # index.html names the hashed bundles, so it must be revalidated or a + # browser keeps loading the previous build after a deploy. "expires" + # rather than add_header: any add_header here would stop the server + # block's HSTS header being inherited. + expires -1; } location ^~ /app/assets/ { include /etc/nginx/mime.types; root NGINX_DIR/web; - expires 30d; # hashed filenames - add_header Cache-Control "public, immutable"; + expires max; # hashed filenames } location / { diff --git a/web/src/components/ResetPassword.tsx b/web/src/components/ResetPassword.tsx index 38a8c011f..626230ac3 100644 --- a/web/src/components/ResetPassword.tsx +++ b/web/src/components/ResetPassword.tsx @@ -1,6 +1,6 @@ import { KeyRound, Loader2 } from "lucide-react"; import { motion } from "motion/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,30 +15,26 @@ import { asset } from "@/lib/utils"; * signed link: they are handed straight back to the API, which is what * checks them. Nothing here can decide whether a link is good. */ +// Read from the URL rather than the router: the link is built by the +// platform's email template, and lands here under either history mode. +function readResetLink(): { uid: number; expires: number; mac: string } | null { + const raw = window.location.href; + const query = raw.slice(raw.indexOf("?") + 1); + const q = new URLSearchParams(raw.includes("?") ? query : ""); + const uid = Number(q.get("uid")); + const expires = Number(q.get("expires")); + const mac = q.get("mac"); + return uid && expires && mac ? { uid, expires, mac } : null; +} + export function ResetPassword() { - const [params, setParams] = useState<{ - uid: number; - expires: number; - mac: string; - } | null>(null); + const [params] = useState(readResetLink); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState(false); - // Read from the URL rather than the router: the link is built by the - // platform's email template, and lands here under either history mode. - useEffect(() => { - const raw = window.location.href; - const query = raw.slice(raw.indexOf("?") + 1); - const q = new URLSearchParams(raw.includes("?") ? query : ""); - const uid = Number(q.get("uid")); - const expires = Number(q.get("expires")); - const mac = q.get("mac"); - if (uid && expires && mac) setParams({ uid, expires, mac }); - }, []); - const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!params) return; diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx index fdfca2240..b197bbf69 100644 --- a/web/src/pages/Account.tsx +++ b/web/src/pages/Account.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { ExternalLink, GitBranch, KeyRound, Loader2, UserRound, UserX } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm"; @@ -61,11 +61,14 @@ function ProfileSection({ name, email }: Readonly<{ name: string; email: string const [saved, setSaved] = useState(false); // The query refetches after a save, so pick the server's values back up - // rather than leaving whatever was typed sitting in the inputs. - useEffect(() => { + // rather than leaving whatever was typed sitting in the inputs. Done while + // rendering, not in an effect, so the stale values never paint. + const [synced, setSynced] = useState({ name, email }); + if (synced.name !== name || synced.email !== email) { + setSynced({ name, email }); setDraftName(name); setDraftEmail(email); - }, [name, email]); + } const emailChanged = draftEmail !== email; const nameChanged = draftName !== name; @@ -279,18 +282,19 @@ function GithubSection() { {busy && } Disconnect ) : ( - + )} - {/* The redirect finishes on the classic site, so this page will - not know about it until it is loaded again. */} + {/* GitHub's callback is registered to the classic site and reads + its own sign-in, so the link is made there. This page will not + know about it until it is loaded again. */}

{data.linked ? "Disconnecting only forgets the platform's copy. Withdraw the authorisation itself from your GitHub applications page." - : "GitHub opens in a new tab. Reload this page once you are done there."} + : "Opens your account on the classic site in a new tab, where GitHub is connected. Reload this page once you are done there."}

)} diff --git a/web/src/router.tsx b/web/src/router.tsx index e1cf931f1..dfae8c471 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -140,7 +140,13 @@ const routeTree = rootRoute.addChildren([ adminRoute, ]); -export const router = createRouter({ routeTree }); +// Served under /app/ in production, so routes are matched below that +// prefix. A relative base (the demo build) is served from the root. +const base = import.meta.env.BASE_URL; +export const router = createRouter({ + routeTree, + basepath: base.startsWith("/") ? base : "/", +}); declare module "@tanstack/react-router" { interface Register {