Round four, on round three's diff. 47 -> 34 -> 15 -> 7 raw findings. **The printed restart command could strand the restored data.** Round three replaced `start` with `up -d` because `start` ignores a newly pinned tag — but printed it bare. In the git-clone layout BOTH compose files exist, so the scripts' COMPOSE_FILE fallback never fires and a bare `docker compose up -d` reads `docker-compose.yml`: different volume names, locally built images, no IMAGE_TAG at all. `stop` and `exec` had been fine there (they resolve running containers by project and service), but `up -d` RECREATES — so the operator pastes the hint straight after a successful restore and gets `db` on a new empty volume and a first-run setup wizard, with their data in an orphaned volume. Verified: the two files resolve `siftlode_pgdata` vs `pgdata` and different images. The scripts no longer print a command that recreates from a file they did not resolve. They name the compose file when they resolved one, and otherwise say to start it the way the operator normally does — with the caveats on their own lines rather than in a parenthetical at the end of a very long one. **The reset flag is now decided where the live hash is already parsed.** Round three cleared it in the token effect, which only runs when a NEW verify token arrives — so the back button, or re-pasting an already-consumed verify link, still left the banner promising a recovery whose token was gone. Setting it from the hash on every fragment change is both smaller and closes those. Also: the doc said `start` "still carries the old tag" four lines after "pin the older IMAGE_TAG", where "old" then reads as the tag being rolled back TO — it now reuses the scripts' unambiguous wording; the Windows recipe removes a partially-copied dump before throwing, so a mid-stream `cp` failure cannot leave a truncated file under the final name; and `$WarningPreference` is pinned beside `$ErrorActionPreference` so an inherited SilentlyContinue cannot drop the half-restored warning, with the comment corrected to say what the warning stream actually is. Gate green: vitest 685, pytest 950.
125 lines
6.7 KiB
TypeScript
125 lines
6.7 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { api } from "./api";
|
|
import { HttpError } from "./api/core";
|
|
import { clearEntryUrl, entryHash } from "./entryUrl";
|
|
|
|
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
|
|
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
|
|
* error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from
|
|
* `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling
|
|
* someone their link is spent when the server was merely restarting sends them to request a fresh one
|
|
* on the strength of a false statement. The same rule is already written one file away, for the
|
|
* reset-link probe: "a network blip must never be cached as 'this link is dead'". */
|
|
type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable";
|
|
|
|
export type VerifyEntry = {
|
|
outcome: VerifyOutcome | null;
|
|
/** The address that was confirmed, when the server named it. */
|
|
email: string | null;
|
|
/** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card
|
|
* consumes a reset token, so nothing here can act on it — but silence is what made the verify half
|
|
* a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately
|
|
* left in the address bar (E9.5 review round 4), so signing out still recovers it. */
|
|
resetPending: boolean;
|
|
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
|
|
dismiss: () => void;
|
|
};
|
|
|
|
/**
|
|
* Confirm a `#verify=` token from the entry URL, wherever the user opened it.
|
|
*
|
|
* App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be
|
|
* consumed inside Welcome.tsx, and `App.tsx` renders <Welcome /> only when there is no session. Open
|
|
* the emailed link in a browser that already holds a Siftlode session and the token was never posted,
|
|
* the fragment was then stripped, and the user got no signal at all — the link simply did nothing.
|
|
* Incognito worked, which is why it survived so long.
|
|
*
|
|
* The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL
|
|
* differs only in the fragment, the browser performs a same-document navigation — no reload, so the
|
|
* import-time snapshot never sees the new token. Pasting the emailed link into a running tab's address
|
|
* bar does exactly that, and our verification mails were plain text until 0.78.8, which makes pasting
|
|
* the normal way to use one. Without the `hashchange` listener below the token would sit unconsumed in
|
|
* the address bar: the same silence signed-in users met before, in a second disguise.
|
|
*
|
|
* Each token is posted exactly once. It is single-use, so a repeat POST (a StrictMode remount, any
|
|
* re-render) would 400 and wrongly flip a successful confirmation to "invalid".
|
|
*/
|
|
export function useVerifyEntry(): VerifyEntry {
|
|
const [token, setToken] = useState<string | null>(() => entryHash.get("verify"));
|
|
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
|
const [email, setEmail] = useState<string | null>(null);
|
|
const [resetPending, setResetPending] = useState<boolean>(() => !!entryHash.get("reset"));
|
|
const consumed = useRef(new Set<string>());
|
|
|
|
useEffect(() => {
|
|
const onHashChange = () => {
|
|
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
|
const next = hash.get("verify");
|
|
if (next) setToken(next);
|
|
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
|
|
// same-document navigation the import-time snapshot never sees. The verify half got this
|
|
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
|
|
//
|
|
// Set from the live hash rather than one-way, because the flag must not outlive the token it
|
|
// describes: navigating away from a `#reset=` (a pasted verify link, the back button) leaves
|
|
// the banner promising that signing out will resume a reset whose token is gone — and, being
|
|
// checked first, hiding whatever arrived in its place. `clearEntryUrl`'s own replaceState
|
|
// fires no hashchange, so this cannot undo the deliberate keep-the-reset-fragment behaviour.
|
|
setResetPending(!!hash.get("reset"));
|
|
};
|
|
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
|
// feed itself.
|
|
window.addEventListener("hashchange", onHashChange);
|
|
return () => window.removeEventListener("hashchange", onHashChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Clear the address bar even with no token to consume, so a spent one leaves no trace on a
|
|
// reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a
|
|
// signed-in tree reads it, so erasing it there is a dead end rather than a cleanup.
|
|
clearEntryUrl();
|
|
if (!token || consumed.current.has(token)) return;
|
|
consumed.current.add(token);
|
|
// A second link arriving over an unread first outcome must not leave the OLD banner standing.
|
|
setOutcome(null);
|
|
setEmail(null);
|
|
api
|
|
.verifyEmail(token)
|
|
.then((r) => {
|
|
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
|
|
setEmail(r.email ?? null);
|
|
})
|
|
// Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent
|
|
// or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped
|
|
// connection (no HttpError at all), says nothing about the token and must not be reported as
|
|
// if it did. The token itself is unspent in that case — but the fragment has already been
|
|
// cleared, so the honest message is "we could not reach the server", not "request a new link".
|
|
.catch((e: unknown) => {
|
|
const spent =
|
|
e instanceof HttpError &&
|
|
e.status === 400 &&
|
|
(e.detailData as { code?: string } | undefined)?.code === "verify";
|
|
setOutcome(spent ? "invalid" : "unreachable");
|
|
});
|
|
}, [token]);
|
|
|
|
return {
|
|
outcome,
|
|
email,
|
|
resetPending,
|
|
// Dismiss peels ONE layer. The two states are independent — a reset link can arrive over an
|
|
// unread confirmation, and a slow /auth/verify can resolve after one did — so clearing both at
|
|
// once silently discards whichever the user had not read yet. The banner shows the reset
|
|
// guidance first (it is the one with something to do), and dismissing it reveals the outcome
|
|
// underneath rather than taking it with it.
|
|
dismiss: () => {
|
|
if (resetPending) {
|
|
setResetPending(false);
|
|
return;
|
|
}
|
|
setOutcome(null);
|
|
setEmail(null);
|
|
},
|
|
};
|
|
}
|