Deep-review round 4, targeted at round 3's changes (f569b19c..e7f38396). Accounts - Switching, adding or signing out an account moved the tab's pin before the page unloaded, so the players' resume beacons fired on the way out under the NEW account: one account's playback position saved into the other. The pin is now handed to the next page (pinOnNextPage) and taken over at import; clearActiveAccount is gone. - Switching to a suspended account was refused but left it in the switcher, refused on every click; a deactivated one was not refused at all, and made the default it signed every account in the browser out on the next request. Every account _session_user would refuse — gone, deactivated, suspended, revoked — is refused and dropped from the list. Share page (/watch) - An IPv6 client is limited by its /64 (client_ip becomes client_key, for every limiter): keyed on the full address, one subscriber could rotate through endless identities. The comments now state what the per-link ceiling costs: guessing from five networks can keep a link shut for its window, and guesses already running when it fills are still checked. - The link is looked up before the limiter records the attempt, so made-up tokens no longer grow it by an entry each. Forms - Failing the same way twice is announced again: the sign-in, passphrase and setup errors are keyed on the attempt (a clear and a set in one handler left the alert node untouched). - The install wizard's own validation showed the generic error — it threw plain objects its catch did not read. It sets the step's message. Tests: a re-pin leaving the previous account's notices behind; the pin hand-over; switching to an ended account; the /64 viewer; made-up tokens; the repeated alerts. Each mutation-checked RED for its own reason.
183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
import { useState } from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
|
import { api, HttpError } from "../lib/api";
|
|
import * as e2ee from "../lib/e2ee";
|
|
import { Button } from "./ui/Button";
|
|
import { inputCls } from "./ui/form";
|
|
|
|
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
|
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
|
// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also
|
|
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
|
|
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const keyQ = useQuery({
|
|
queryKey: qk.messageKey(),
|
|
queryFn: api.messageMyKey,
|
|
staleTime: 60_000,
|
|
});
|
|
const configured = keyQ.data?.configured ?? false;
|
|
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
|
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
|
|
// so hasPassword follows the cache when a refetch changes it.
|
|
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
const hasPassword = meQ.data?.has_password ?? false;
|
|
|
|
const [view, setView] = useState<"gate" | "reset">("gate");
|
|
const [pass, setPass] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
const [curPass, setCurPass] = useState("");
|
|
const [err, setErr] = useState<string | null>(null);
|
|
// Keys the error: failing the same way twice is a new node, so a screen reader announces it again (a clear
|
|
// and a set in one handler commit together, leaving the node untouched).
|
|
const [attempt, setAttempt] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function submit() {
|
|
setAttempt((n) => n + 1);
|
|
setErr(null);
|
|
if (pass.length < 6) return setErr(t("messages.passTooShort"));
|
|
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
|
|
setBusy(true);
|
|
try {
|
|
if (configured) {
|
|
await e2ee.unlock(meId, pass, keyQ.data!);
|
|
} else {
|
|
const payload = await e2ee.setup(meId, pass);
|
|
await api.setupMessageKey(payload);
|
|
}
|
|
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
|
} catch {
|
|
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function toReset() {
|
|
setErr(null);
|
|
setPass("");
|
|
setConfirm("");
|
|
setView("reset");
|
|
}
|
|
function cancelReset() {
|
|
setErr(null);
|
|
setCurPass("");
|
|
setView("gate");
|
|
}
|
|
|
|
async function doReset() {
|
|
setAttempt((n) => n + 1);
|
|
setErr(null);
|
|
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
|
|
setBusy(true);
|
|
try {
|
|
await api.resetMessageKey(hasPassword ? curPass : undefined);
|
|
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
|
|
// in setup mode. Invalidate last so the refetch sees configured:false.
|
|
await e2ee.resetLocal(meId);
|
|
setCurPass("");
|
|
setView("gate");
|
|
qc.invalidateQueries({ queryKey: qk.messageKey() });
|
|
} catch (e) {
|
|
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (keyQ.isLoading)
|
|
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
|
|
|
|
if (view === "reset")
|
|
return (
|
|
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
|
|
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
|
</div>
|
|
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
|
|
{hasPassword && (
|
|
<input
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={curPass}
|
|
onChange={(e) => setCurPass(e.target.value)}
|
|
placeholder={t("messages.resetCurrentPass")}
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
{err && (
|
|
<div key={attempt} role="alert" className="text-sm text-danger">
|
|
{err}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={cancelReset} disabled={busy}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
onClick={doReset}
|
|
disabled={busy}
|
|
icon={<AlertTriangle className="w-4 h-4" />}
|
|
>
|
|
{t("messages.resetButton")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
|
|
<div className="font-semibold text-sm">
|
|
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
|
|
</div>
|
|
</div>
|
|
{!compact && (
|
|
<p className="text-sm text-muted">
|
|
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
|
|
</p>
|
|
)}
|
|
<input
|
|
type="password"
|
|
value={pass}
|
|
onChange={(e) => setPass(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
|
placeholder={t("messages.passphrase")}
|
|
className={inputCls}
|
|
/>
|
|
{!configured && (
|
|
<input
|
|
type="password"
|
|
value={confirm}
|
|
onChange={(e) => setConfirm(e.target.value)}
|
|
placeholder={t("messages.passphraseConfirm")}
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
{err && (
|
|
<div key={attempt} role="alert" className="text-sm text-danger">
|
|
{err}
|
|
</div>
|
|
)}
|
|
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
|
|
<Lock className="w-4 h-4" />
|
|
{configured ? t("messages.unlock") : t("messages.setUp")}
|
|
</Button>
|
|
{configured && (
|
|
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
|
{t("messages.forgotPass")}
|
|
</button>
|
|
)}
|
|
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
|
</div>
|
|
);
|
|
}
|