The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
125 lines
6.6 KiB
TypeScript
125 lines
6.6 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, 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 first; 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);
|
|
},
|
|
};
|
|
}
|