Eleven findings survived adversarial verification; these are the fixes.
**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.
**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.
**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.
**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.
**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.
**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import Modal from "./Modal";
|
|
import { Button } from "./ui/Button";
|
|
|
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
|
// if (await confirm({ message: "…", danger: true })) { … }
|
|
//
|
|
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
|
|
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
|
|
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
|
|
// went ahead and deleted).
|
|
interface ConfirmOptions {
|
|
title?: string;
|
|
message: ReactNode;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
interface Choice {
|
|
id: string;
|
|
label: string;
|
|
danger?: boolean;
|
|
}
|
|
|
|
export interface ChoiceOptions {
|
|
title?: string;
|
|
message: ReactNode;
|
|
cancelLabel?: string;
|
|
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
|
|
choices: Choice[];
|
|
}
|
|
|
|
type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
|
|
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
|
|
|
const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
|
|
|
|
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
|
|
export function useConfirm(): ConfirmFn {
|
|
const ask = useContext(ChoiceContext);
|
|
return useCallback(
|
|
async (opts: ConfirmOptions) => {
|
|
const picked = await ask({
|
|
title: opts.title,
|
|
message: opts.message,
|
|
cancelLabel: opts.cancelLabel,
|
|
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
|
|
});
|
|
return picked === "confirm";
|
|
},
|
|
[ask],
|
|
);
|
|
}
|
|
|
|
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
|
|
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
|
|
export function useChoice(): ChoiceFn {
|
|
return useContext(ChoiceContext);
|
|
}
|
|
|
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<{
|
|
opts: ChoiceOptions;
|
|
resolve: (id: string | null) => void;
|
|
} | null>(null);
|
|
|
|
const ask = useCallback<ChoiceFn>(
|
|
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
|
[],
|
|
);
|
|
|
|
function close(id: string | null) {
|
|
state?.resolve(id);
|
|
setState(null);
|
|
}
|
|
|
|
const choices = state?.opts.choices ?? [];
|
|
// Focus Cancel when any offered action is destructive: the confirm button used to take
|
|
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
|
// "Clear audit log" without the user ever looking at it.
|
|
const anyDanger = choices.some((c) => c.danger);
|
|
const multi = choices.length > 1;
|
|
|
|
return (
|
|
<ChoiceContext.Provider value={ask}>
|
|
{children}
|
|
{state && (
|
|
<Modal
|
|
title={state.opts.title ?? t("common.confirmTitle")}
|
|
onClose={() => close(null)}
|
|
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
|
>
|
|
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
|
|
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
|
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
|
these are peers. Ordered least to most destructive, top to bottom. */}
|
|
<div
|
|
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
autoFocus={anyDanger}
|
|
onClick={() => close(null)}
|
|
fullWidth={multi}
|
|
className={multi ? "order-last" : ""}
|
|
>
|
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
|
</Button>
|
|
{choices.map((c, i) => (
|
|
<Button
|
|
key={c.id}
|
|
variant={c.danger ? "danger" : "primary"}
|
|
autoFocus={!anyDanger && i === choices.length - 1}
|
|
onClick={() => close(c.id)}
|
|
fullWidth={multi}
|
|
>
|
|
{c.label || t("common.confirm")}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</ChoiceContext.Provider>
|
|
);
|
|
}
|