feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口
This commit is contained in:
36 files changed
+2735
-288
No files matched your search
+114
-4
@@ -52,6 +52,10 @@ interface MockAccount {
|
||||
updated_at?: string;
|
||||
quota?: QuotaView;
|
||||
sessions?: SessionView[];
|
||||
// Until when this browser's session counts as re-authenticated (ms epoch),
|
||||
// and the address an email-change code was last sent to.
|
||||
reauthUntil?: number;
|
||||
pendingEmail?: string;
|
||||
}
|
||||
|
||||
interface MockServer extends ServerStatus {
|
||||
@@ -568,6 +572,41 @@ function setSessionCookie(res: ServerResponse, accountID: string): void {
|
||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
|
||||
}
|
||||
|
||||
// signInProven opens a session through a door that proved a factor (email code,
|
||||
// passkey, operator login), which like the real API counts as a re-auth.
|
||||
function signInProven(ctx: RequestContext, accountID: AccountID): void {
|
||||
setSessionCookie(ctx.res, accountID);
|
||||
const acc = ctx.state.accounts[accountID];
|
||||
if (acc) acc.reauthUntil = Date.now() + REAUTH_MS;
|
||||
}
|
||||
|
||||
const REAUTH_MS = 5 * 60_000;
|
||||
|
||||
/** reauthFactors mirrors reauthState: a passkey, an email code to a verified
|
||||
* address (users) or a fresh sign-in (operators). None → nothing to re-prove. */
|
||||
function reauthFactors(state: MockState, acc: MockAccount): string[] {
|
||||
const hasPasskey = (state.passkeys[acc.id] ?? []).length > 0;
|
||||
if (!hasPasskey && !acc.emailVerified) return [];
|
||||
const factors = hasPasskey ? ["passkey"] : [];
|
||||
if (acc.role !== "user") factors.push("sign_in");
|
||||
else if (acc.emailVerified) factors.push("email");
|
||||
return factors;
|
||||
}
|
||||
|
||||
/** refusedForReauth answers 403 reauth_required, as the real API does, for a
|
||||
* change to how the account signs in without a recent proof. */
|
||||
function refusedForReauth(ctx: SessionContext): boolean {
|
||||
const acc = ctx.account;
|
||||
if (reauthFactors(ctx.state, acc).length === 0 || (acc.reauthUntil ?? 0) > Date.now()) return false;
|
||||
sendError(ctx.res, 403, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes");
|
||||
return true;
|
||||
}
|
||||
|
||||
function markReauth(ctx: SessionContext): void {
|
||||
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
|
||||
sendJSON(ctx.res, 200, { ok: true, until: new Date(ctx.account.reauthUntil).toISOString() });
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: ServerResponse): void {
|
||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
||||
}
|
||||
@@ -784,7 +823,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
opLogins.delete(body.request_id!);
|
||||
setSessionCookie(ctx.res, "owner");
|
||||
signInProven(ctx, "owner");
|
||||
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
|
||||
return true;
|
||||
}
|
||||
@@ -828,7 +867,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
|
||||
return true;
|
||||
}
|
||||
setSessionCookie(ctx.res, "owner");
|
||||
signInProven(ctx, "owner");
|
||||
sendJSON(ctx.res, 200, {
|
||||
user_id: "mock-owner",
|
||||
role: "owner"
|
||||
@@ -857,7 +896,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
|
||||
return true;
|
||||
}
|
||||
setSessionCookie(ctx.res, "owner");
|
||||
signInProven(ctx, "owner");
|
||||
sendJSON(ctx.res, 200, {
|
||||
user_id: "mock-owner",
|
||||
role: "owner"
|
||||
@@ -879,7 +918,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||
return true;
|
||||
}
|
||||
setSessionCookie(ctx.res, "owner");
|
||||
signInProven(ctx, "owner");
|
||||
sendJSON(ctx.res, 200, {
|
||||
user_id: "mock-owner",
|
||||
role: "owner"
|
||||
@@ -993,6 +1032,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendError(ctx.res, 400, "bad_request", "invalid email");
|
||||
return true;
|
||||
}
|
||||
if (refusedForReauth(ctx)) return true;
|
||||
ctx.account.pendingEmail = body.email.trim();
|
||||
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
|
||||
return true;
|
||||
}
|
||||
@@ -1002,11 +1043,22 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||
return true;
|
||||
}
|
||||
const next = ctx.account.pendingEmail ?? ctx.account.email;
|
||||
// Like the real API: replacing a verified address signs the other devices
|
||||
// out, and proving a code counts as a re-auth.
|
||||
if (ctx.account.emailVerified && next.toLowerCase() !== ctx.account.email.toLowerCase()) {
|
||||
const here = thisSessionHash(ctx.account);
|
||||
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
|
||||
}
|
||||
ctx.account.email = next;
|
||||
ctx.account.pendingEmail = undefined;
|
||||
ctx.account.emailVerified = true;
|
||||
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
|
||||
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
|
||||
return true;
|
||||
}
|
||||
case "POST account/passkey/register/begin": {
|
||||
if (refusedForReauth(ctx)) return true;
|
||||
sendJSON(ctx.res, 200, {
|
||||
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
|
||||
rp: { name: "Felis Dev" },
|
||||
@@ -1034,6 +1086,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
ctx.state.passkeys[ctx.account.id] = [];
|
||||
}
|
||||
ctx.state.passkeys[ctx.account.id].unshift(newCred);
|
||||
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
|
||||
sendJSON(ctx.res, 201, newCred);
|
||||
return true;
|
||||
}
|
||||
@@ -1042,6 +1095,61 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendJSON(ctx.res, 200, { credentials: list });
|
||||
return true;
|
||||
}
|
||||
case "GET account/reauth": {
|
||||
const factors = reauthFactors(ctx.state, ctx.account);
|
||||
const until = ctx.account.reauthUntil ?? 0;
|
||||
if (factors.length === 0) sendJSON(ctx.res, 200, { needed: false, factors });
|
||||
else if (until > Date.now()) sendJSON(ctx.res, 200, { needed: false, until: new Date(until).toISOString(), factors });
|
||||
else sendJSON(ctx.res, 200, { needed: true, factors });
|
||||
return true;
|
||||
}
|
||||
case "POST account/reauth/passkey/begin": {
|
||||
const list = ctx.state.passkeys[ctx.account.id] ?? [];
|
||||
if (list.length === 0) {
|
||||
sendError(ctx.res, 409, "no_passkey", "no passkey enrolled; confirm with an email code instead");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, {
|
||||
publicKey: {
|
||||
challenge: "c29tZV9yZWF1dGhfY2hhbGxlbmdl",
|
||||
rpId: "dev.felis.localhost",
|
||||
allowCredentials: list.map(() => ({ type: "public-key", id: "cGstMQ" })),
|
||||
userVerification: "preferred",
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "POST account/reauth/passkey/finish": {
|
||||
const body = await readJSON<{ assertion?: unknown }>(ctx.req);
|
||||
if (!body.assertion) {
|
||||
sendError(ctx.res, 400, "bad_request", "assertion is required");
|
||||
return true;
|
||||
}
|
||||
markReauth(ctx);
|
||||
return true;
|
||||
}
|
||||
case "POST account/reauth/email/start": {
|
||||
if (ctx.account.role !== "user") {
|
||||
sendError(ctx.res, 403, "staff_reauth", "operators confirm with a passkey or by signing in again");
|
||||
return true;
|
||||
}
|
||||
if (!ctx.account.emailVerified) {
|
||||
sendError(ctx.res, 409, "no_step_up_factor", "no verified email to send a code to");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
|
||||
return true;
|
||||
}
|
||||
case "POST account/reauth/email/verify": {
|
||||
const body = await readJSON<{ code?: string }>(ctx.req);
|
||||
if (body.code?.trim() !== MOCK_OTP_CODE) {
|
||||
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
|
||||
return true;
|
||||
}
|
||||
markReauth(ctx);
|
||||
return true;
|
||||
}
|
||||
case "GET account/migrate":
|
||||
// No migration pending: the real API's answer until one is started in-game.
|
||||
sendJSON(ctx.res, 200, { active: false });
|
||||
@@ -1076,6 +1184,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
|
||||
// The real API asks for the re-auth before it looks the passkey up.
|
||||
if (refusedForReauth(ctx)) return true;
|
||||
const id = ctx.parts[5];
|
||||
const list = ctx.state.passkeys[ctx.account.id] ?? [];
|
||||
const idx = list.findIndex((k) => k.id === id);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Fingerprint, Loader2, LogIn, Mail, ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api, clientError, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { useTier } from "@/lib/tier";
|
||||
|
||||
// A change to how the account signs in (a passkey added or removed, the email
|
||||
// changed) needs this session to have proven a factor in the last few minutes,
|
||||
// so a browser left signed in cannot quietly swap the account's ways in. The API
|
||||
// refuses such a change with 403 reauth_required; useReauth catches that, asks
|
||||
// for the proof in this dialog and runs the change again once it is given.
|
||||
|
||||
export function isReauthRequired(e: unknown): boolean {
|
||||
return (e as { code?: string } | null)?.code === "reauth_required";
|
||||
}
|
||||
|
||||
/** isReauthCancelled: the confirmation was closed without a proof. Nothing
|
||||
* failed, so callers show no error for it. */
|
||||
export function isReauthCancelled(e: unknown): boolean {
|
||||
return (e as { code?: string } | null)?.code === "reauth_cancelled";
|
||||
}
|
||||
|
||||
export function useReauth() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const settle = useRef<((ok: boolean) => void) | null>(null);
|
||||
|
||||
const finish = useCallback((ok: boolean) => {
|
||||
setOpen(false);
|
||||
const resolve = settle.current;
|
||||
settle.current = null;
|
||||
resolve?.(ok);
|
||||
}, []);
|
||||
|
||||
// confirm opens the dialog and resolves true once a factor is proven, false
|
||||
// when it is closed without one.
|
||||
const confirm = useCallback(() => {
|
||||
settle.current?.(false);
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
settle.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// guard runs a change. Refused for want of a fresh proof, it asks for one and
|
||||
// runs the change a second time; closing the dialog rejects with
|
||||
// reauth_cancelled. A change that needs a user gesture of its own (a WebAuthn
|
||||
// create) uses confirm and lets the user press its button again instead.
|
||||
const guard = useCallback(
|
||||
async <T,>(change: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await change();
|
||||
} catch (e) {
|
||||
if (!isReauthRequired(e)) throw e;
|
||||
if (!(await confirm())) throw clientError("reauth_cancelled");
|
||||
return change();
|
||||
}
|
||||
},
|
||||
[confirm],
|
||||
);
|
||||
|
||||
const dialog = <ReauthDialog open={open} onDone={() => finish(true)} onCancel={() => finish(false)} />;
|
||||
return { guard, confirm, dialog };
|
||||
}
|
||||
|
||||
function ReauthDialog({ open, onDone, onCancel }: { open: boolean; onDone: () => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation("account");
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
{t("reauth_title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("reauth_desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Mounted per opening, so every confirmation starts from a fresh status. */}
|
||||
{open && <ReauthBody onDone={onDone} onCancel={onCancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending = "passkey" | "send" | "verify" | "sign_in";
|
||||
|
||||
function ReauthBody({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
||||
const { t } = useTranslation("account");
|
||||
const { identity, refresh } = useTier();
|
||||
const status = useAsync(() => api.reauthStatus(), []);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
// Proven meanwhile (in another tab, say): there is nothing to ask.
|
||||
const proven = status.data?.needed === false;
|
||||
useEffect(() => {
|
||||
if (proven) onDone();
|
||||
}, [proven, onDone]);
|
||||
|
||||
async function act(kind: Pending, fn: () => Promise<void>) {
|
||||
if (pending) return;
|
||||
setPending(kind);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(humanizeError(e));
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
const withPasskey = () =>
|
||||
act("passkey", async () => {
|
||||
const options = await api.reauthPasskeyBegin();
|
||||
await api.reauthPasskeyFinish(await requestAssertion(options.publicKey));
|
||||
onDone();
|
||||
});
|
||||
|
||||
const sendCode = () =>
|
||||
act("send", async () => {
|
||||
await api.reauthEmailStart();
|
||||
setCodeSent(true);
|
||||
setCode("");
|
||||
});
|
||||
|
||||
const verifyCode = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
void act("verify", async () => {
|
||||
await api.reauthEmailVerify(trimmed);
|
||||
onDone();
|
||||
});
|
||||
};
|
||||
|
||||
// A fresh sign-in counts as the proof. Signing out flips the session to
|
||||
// signed-out and the route guard takes the browser to the sign-in page.
|
||||
const signInAgain = () =>
|
||||
act("sign_in", async () => {
|
||||
await api.logout();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
if (status.loading && !status.data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("reauth_checking")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.error || !status.data || proven) {
|
||||
return (
|
||||
<>
|
||||
<InlineError message={status.error ? humanizeError(status.error) : null} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onCancel}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
{status.error ? (
|
||||
<Button size="sm" onClick={status.reload}>
|
||||
{t("common:try_again")}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const factors = status.data.factors;
|
||||
const email = identity?.email ?? "";
|
||||
const options: React.ReactNode[] = [];
|
||||
|
||||
if (factors.includes("passkey")) {
|
||||
options.push(
|
||||
<Button key="passkey" className="w-full" onClick={() => void withPasskey()} disabled={pending !== null}>
|
||||
{pending === "passkey" ? <Loader2 className="animate-spin" /> : <Fingerprint />}
|
||||
{pending === "passkey" ? t("reauth_passkey_waiting") : t("reauth_passkey_btn")}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
if (factors.includes("email")) {
|
||||
options.push(
|
||||
codeSent ? (
|
||||
<form key="email" onSubmit={verifyCode} className="space-y-2">
|
||||
<Label htmlFor="reauth-code">{t("reauth_code_label", { email })}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reauth-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t("otp_code_placeholder")}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
disabled={pending !== null}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
className="font-mono text-center tracking-[0.2em]"
|
||||
/>
|
||||
<Button type="submit" disabled={pending !== null || code.trim().length !== 6} className="shrink-0">
|
||||
{pending === "verify" && <Loader2 className="animate-spin" />}
|
||||
{pending === "verify" ? t("reauth_confirming") : t("reauth_confirm_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => void sendCode()}
|
||||
disabled={pending !== null}
|
||||
className="h-auto p-0 font-normal"
|
||||
>
|
||||
{pending === "send" ? t("sending_code") : t("reauth_resend")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
key="email"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void sendCode()}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
{pending === "send" ? <Loader2 className="animate-spin" /> : <Mail />}
|
||||
<span className="truncate">{pending === "send" ? t("sending_code") : t("reauth_email_btn", { email })}</span>
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
}
|
||||
if (factors.includes("sign_in")) {
|
||||
options.push(
|
||||
<div key="sign_in" className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t("reauth_sign_in_desc")}</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => void signInAgain()} disabled={pending !== null}>
|
||||
{pending === "sign_in" ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{t("reauth_sign_in_btn")}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3 py-2">
|
||||
{options.length === 0 ? (
|
||||
<InlineError message={t("errors:no_step_up_factor")} />
|
||||
) : (
|
||||
options.flatMap((option, i) =>
|
||||
i === 0
|
||||
? [option]
|
||||
: [
|
||||
<div key={`or-${i}`} className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("reauth_or")}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>,
|
||||
option,
|
||||
],
|
||||
)
|
||||
)}
|
||||
<InlineError message={error} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -89,5 +89,20 @@
|
||||
"migration_redeem_placeholder": "Transfer code",
|
||||
"migration_redeeming": "Redeeming…",
|
||||
"migration_redeem_btn": "Redeem",
|
||||
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account."
|
||||
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account.",
|
||||
"reauth_title": "Confirm it's you",
|
||||
"reauth_desc": "Adding or removing a passkey and changing the email need a fresh check with a way in you already have. The check lasts 5 minutes.",
|
||||
"reauth_checking": "Checking how you can confirm…",
|
||||
"reauth_passkey_btn": "Use a passkey",
|
||||
"reauth_passkey_waiting": "Waiting for your passkey…",
|
||||
"reauth_or": "or",
|
||||
"reauth_email_btn": "Email a code to {{email}}",
|
||||
"reauth_code_label": "Code sent to {{email}}",
|
||||
"reauth_confirm_btn": "Confirm",
|
||||
"reauth_confirming": "Confirming…",
|
||||
"reauth_resend": "Send another code",
|
||||
"reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.",
|
||||
"reauth_sign_in_btn": "Sign out and sign in again",
|
||||
"reauth_done_continue": "Confirmed. Press Continue to add the passkey.",
|
||||
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out."
|
||||
}
|
||||
@@ -78,6 +78,9 @@
|
||||
"passkey_login_failed": "Passkey verification failed — try again.",
|
||||
"passkey_login_invalid": "That Passkey sign-in request is invalid or expired — start it again.",
|
||||
"too_many_challenges": "Too many verification attempts right now — try again shortly.",
|
||||
"reauth_required": "Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
|
||||
"staff_reauth": "Operator accounts confirm with a Passkey or by signing in again.",
|
||||
"no_session": "This only works in a browser signed in to Felis.",
|
||||
"op_login_invalid": "This operator sign-in couldn't be completed — restart the sign-in.",
|
||||
"op_login_not_found": "No pending operator sign-in with that id.",
|
||||
"too_many_streams": "Too many live streams are open — close some pages and try again.",
|
||||
|
||||
@@ -88,5 +88,20 @@
|
||||
"migration_redeem_placeholder": "转移码",
|
||||
"migration_redeeming": "兑换中…",
|
||||
"migration_redeem_btn": "兑换",
|
||||
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。"
|
||||
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。",
|
||||
"reauth_title": "确认是你本人",
|
||||
"reauth_desc": "添加或删除 Passkey、修改邮箱前,需要用你已有的登录方式再验证一次,验证后 5 分钟内有效。",
|
||||
"reauth_checking": "正在查看可用的验证方式…",
|
||||
"reauth_passkey_btn": "用 Passkey 验证",
|
||||
"reauth_passkey_waiting": "等待 Passkey…",
|
||||
"reauth_or": "或",
|
||||
"reauth_email_btn": "发送验证码到 {{email}}",
|
||||
"reauth_code_label": "验证码已发送到 {{email}}",
|
||||
"reauth_confirm_btn": "确认",
|
||||
"reauth_confirming": "确认中…",
|
||||
"reauth_resend": "重新发送验证码",
|
||||
"reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。",
|
||||
"reauth_sign_in_btn": "退出并重新登录",
|
||||
"reauth_done_continue": "已确认。点“继续”添加 Passkey。",
|
||||
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
|
||||
}
|
||||
@@ -78,6 +78,9 @@
|
||||
"passkey_login_failed": "Passkey 验证失败——请重试。",
|
||||
"passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。",
|
||||
"too_many_challenges": "验证请求过于频繁——请稍后再试。",
|
||||
"reauth_required": "请先确认是你本人:这项更改需要你在几分钟内用 Passkey 或邮箱验证码验证过。",
|
||||
"staff_reauth": "管理员账户须用 Passkey 或重新登录来确认身份。",
|
||||
"no_session": "只能在已登录 Felis 的浏览器中进行此操作。",
|
||||
"op_login_invalid": "本次管理员登录未能完成——请重新发起登录。",
|
||||
"op_login_not_found": "找不到该管理员登录请求。",
|
||||
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
|
||||
|
||||
@@ -1015,4 +1015,16 @@ describe("copy for the generic server codes", () => {
|
||||
expect(err.status).toBe(0);
|
||||
expect(humanizeError(err)).toBe("The browser returned no passkey. Try again.");
|
||||
});
|
||||
|
||||
it("words the re-authentication refusals", () => {
|
||||
expect(humanizeError({ status: 403, code: "reauth_required", message: "raw" })).toBe(
|
||||
"Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
|
||||
);
|
||||
expect(humanizeError({ status: 403, code: "staff_reauth", message: "raw" })).toBe(
|
||||
"Operator accounts confirm with a Passkey or by signing in again.",
|
||||
);
|
||||
expect(humanizeError({ status: 400, code: "no_session", message: "raw" })).toBe(
|
||||
"This only works in a browser signed in to Felis.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -590,6 +590,26 @@ export const api = rejectingSync({
|
||||
revokeMyOtherSessions: () =>
|
||||
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
|
||||
|
||||
// Re-authentication. Adding or removing a passkey and changing the email are
|
||||
// refused with 403 reauth_required unless this session proved a factor in the
|
||||
// last few minutes. Status names the factors that can prove it: "passkey",
|
||||
// "email" (a code to the verified address) or "sign_in" (operators sign in
|
||||
// again). Like the migrate begin, the passkey begin returns the raw
|
||||
// {"publicKey": {...}} document.
|
||||
reauthStatus: () =>
|
||||
request<{ needed: boolean; until?: string; factors: string[] }>("GET", "/account/reauth"),
|
||||
|
||||
reauthPasskeyBegin: () => request<any>("POST", "/account/reauth/passkey/begin"),
|
||||
|
||||
reauthPasskeyFinish: (assertion: any) =>
|
||||
request<{ ok: boolean; until: string }>("POST", "/account/reauth/passkey/finish", { assertion }),
|
||||
|
||||
reauthEmailStart: () =>
|
||||
request<{ sent: boolean; expires_at: string }>("POST", "/account/reauth/email/start"),
|
||||
|
||||
reauthEmailVerify: (code: string) =>
|
||||
request<{ ok: boolean; until: string }>("POST", "/account/reauth/email/verify", { code }),
|
||||
|
||||
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
|
||||
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
|
||||
// otherwise) → issue-code (source names the target account and reads the one-time
|
||||
@@ -982,6 +1002,13 @@ export function humanizeError(e: unknown): string {
|
||||
return t("passkey_login_invalid");
|
||||
case "too_many_challenges":
|
||||
return t("too_many_challenges");
|
||||
// Re-authentication before a change to how the account signs in.
|
||||
case "reauth_required":
|
||||
return t("reauth_required");
|
||||
case "staff_reauth":
|
||||
return t("staff_reauth");
|
||||
case "no_session":
|
||||
return t("no_session");
|
||||
// Operator-login approvals, live streams, and the remaining auth doors.
|
||||
case "op_login_invalid":
|
||||
return t("op_login_invalid");
|
||||
|
||||
@@ -1363,7 +1363,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
|
||||
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code.
|
||||
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required).
|
||||
*/
|
||||
post: operations["emailOtpStart"];
|
||||
delete?: never;
|
||||
@@ -1383,7 +1383,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Redeem an email one-time code and mark the caller's email verified (spec §B2).
|
||||
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
|
||||
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends; the old address is mailed a notice with the new one masked. A verified code also counts as a reauth for this session. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
|
||||
*/
|
||||
post: operations["emailOtpVerify"];
|
||||
delete?: never;
|
||||
@@ -1403,7 +1403,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
|
||||
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify.
|
||||
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify. Clearing a verified address strips a factor, so once the account has one the session must have reauthed within 5 minutes (403 reauth_required).
|
||||
*/
|
||||
post: operations["setEmail"];
|
||||
delete?: never;
|
||||
@@ -1423,7 +1423,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
|
||||
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Enrollment only — passkey login is a deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
|
||||
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required). 503 when the WebAuthn verifier is not configured on this instance.
|
||||
*/
|
||||
post: operations["passkeyRegisterBegin"];
|
||||
delete?: never;
|
||||
@@ -1443,7 +1443,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
|
||||
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. 503 when the WebAuthn verifier is not configured.
|
||||
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. The verified email is mailed a notice, and the ceremony counts as a reauth for this session. 503 when the WebAuthn verifier is not configured.
|
||||
*/
|
||||
post: operations["passkeyRegisterFinish"];
|
||||
delete?: never;
|
||||
@@ -1484,7 +1484,7 @@ export interface paths {
|
||||
post?: never;
|
||||
/**
|
||||
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
|
||||
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it.
|
||||
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it, and mails the verified email a notice. The session must have reauthed within 5 minutes (403 reauth_required).
|
||||
*/
|
||||
delete: operations["passkeyDelete"];
|
||||
options?: never;
|
||||
@@ -1492,6 +1492,103 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/reauth": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Say whether a passkey or email change needs a reauth first, and how to give one.
|
||||
* @description needed is true when the account has a passkey or a verified email and this session has not proven one within the last 5 minutes. until is when the current proof stops counting. factors lists the ways this caller can reauth, best first: passkey (an enrolled passkey), email (a player's verified address), sign_in (an operator signs out and back in through op-login or a passkey).
|
||||
*/
|
||||
get: operations["reauthStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/reauth/passkey/begin": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Begin a passkey assertion that reauths this session.
|
||||
* @description Returns WebAuthn assertion request options over the caller's own passkeys, bound to a fresh reauth-purpose challenge.
|
||||
*/
|
||||
post: operations["reauthPasskeyBegin"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/reauth/passkey/finish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Finish the passkey assertion and mark this session reauthed for 5 minutes.
|
||||
* @description Verifies the assertion against the reauth challenge with the login door's clone check (a cloned authenticator is 400 passkey_login_invalid).
|
||||
*/
|
||||
post: operations["reauthPasskeyFinish"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/reauth/email/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Mail a reauth code to the caller's verified address.
|
||||
* @description For players with a verified email. Operators reauth with a passkey or by signing in again (403 staff_reauth).
|
||||
*/
|
||||
post: operations["reauthEmailStart"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/reauth/email/verify": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Redeem the reauth code and mark this session reauthed for 5 minutes. */
|
||||
post: operations["reauthEmailVerify"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/account/sessions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2339,6 +2436,29 @@ export interface components {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description This session is reauthed until the returned time. */
|
||||
Reauthed: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @constant */
|
||||
ok: true;
|
||||
/** Format: date-time */
|
||||
until: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description reauth_required: this change adds, removes or moves a way into the account, and the account has a passkey or a verified email, so the session must have proven one of them within the last 5 minutes. Signing in by passkey, email code, op-login or the setup token counts; a bind-code sign-in does not. GET /api/v1/account/reauth lists the factors that can give the proof, then retry the change. */
|
||||
ReauthRequired: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The configured SMTP relay refused the message (code mail_undeliverable), so no code was delivered. Distinct from 500 because the fault is in the install's [smtp] settings, not in the request or the platform — most often a From address the relay will not let this account send as. The relay's own text is deliberately withheld (it names the SMTP account) and written to the felis-api log instead, keyed by the same request_id this response carries. Retrying the same address changes nothing until an operator fixes the relay. */
|
||||
MailUndeliverable: {
|
||||
headers: {
|
||||
@@ -5765,6 +5885,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["ReauthRequired"];
|
||||
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
|
||||
429: {
|
||||
headers: {
|
||||
@@ -5865,6 +5986,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["ReauthRequired"];
|
||||
};
|
||||
};
|
||||
passkeyRegisterBegin: {
|
||||
@@ -5886,6 +6008,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["ReauthRequired"];
|
||||
/** @description Passkey subsystem is not configured. */
|
||||
503: {
|
||||
headers: {
|
||||
@@ -5997,6 +6120,7 @@ export interface operations {
|
||||
content?: never;
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["ReauthRequired"];
|
||||
/** @description No such passkey for this caller. */
|
||||
404: {
|
||||
headers: {
|
||||
@@ -6017,6 +6141,211 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
reauthStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Where the caller stands. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
needed: boolean;
|
||||
/** Format: date-time */
|
||||
until?: string;
|
||||
factors: ("passkey" | "email" | "sign_in")[];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
reauthPasskeyBegin: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
/** @description The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
reauthPasskeyFinish: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description The navigator.credentials.get() PublicKeyCredential assertion. */
|
||||
assertion: Record<string, never>;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["Reauthed"];
|
||||
/** @description Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
reauthEmailStart: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Code minted and dispatched. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @constant */
|
||||
sent: true;
|
||||
/** Format: date-time */
|
||||
expires_at: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description No browser session to mark (no_session). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description Operators cannot reauth by email (staff_reauth). */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The account has no verified email (no_step_up_factor). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
502: components["responses"]["MailUndeliverable"];
|
||||
};
|
||||
};
|
||||
reauthEmailVerify: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
code: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: components["responses"]["Reauthed"];
|
||||
/** @description Invalid or expired code (invalid_code), or no browser session (no_session). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description Operators cannot reauth by email (staff_reauth). */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The account has no verified email (no_step_up_factor). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listMySessions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { clientError } from "@/lib/api";
|
||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||
|
||||
/** requestAssertion runs the WebAuthn get() ceremony for the server's request
|
||||
* options (their JSON form: challenge and credential ids base64url) and returns
|
||||
* the assertion in the JSON form the API's finish endpoints decode. Begins that
|
||||
* answer with go-webauthn's {"publicKey": {...}} envelope pass the inner object. */
|
||||
export async function requestAssertion(options: any) {
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
...options,
|
||||
challenge: base64urlToBytes(options.challenge),
|
||||
allowCredentials: options.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64urlToBytes(cred.id),
|
||||
})),
|
||||
};
|
||||
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
|
||||
if (!credential) throw clientError("passkey_no_credential");
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64url(response.authenticatorData),
|
||||
signature: bytesToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
+59
-31
@@ -14,8 +14,10 @@ import { formatAbsolute } from "@/lib/format";
|
||||
import type { PasskeyCredential } from "@/lib/types";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
||||
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,8 +38,12 @@ export function Account() {
|
||||
const status = useAsync(() => api.linkStatus(), []);
|
||||
const { identity, refresh } = useTier();
|
||||
const { t, i18n } = useTranslation("account");
|
||||
// Adding or removing a passkey and changing the email ask for a fresh proof
|
||||
// of a factor first (ReauthDialog).
|
||||
const reauth = useReauth();
|
||||
|
||||
// Email verification state
|
||||
// Email verification state. A verified address is changed through the same
|
||||
// two steps, opened with changingEmail.
|
||||
const [emailInput, setEmailInput] = useState("");
|
||||
const [otpCodeInput, setOtpCodeInput] = useState("");
|
||||
const [emailSending, setEmailSending] = useState(false);
|
||||
@@ -46,6 +52,7 @@ export function Account() {
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [sentEmailAddress, setSentEmailAddress] = useState("");
|
||||
const [initializedEmail, setInitializedEmail] = useState(false);
|
||||
const [changingEmail, setChangingEmail] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (identity?.email && !initializedEmail) {
|
||||
@@ -61,16 +68,30 @@ export function Account() {
|
||||
setEmailSending(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
await api.emailStart(trimmed);
|
||||
await reauth.guard(() => api.emailStart(trimmed));
|
||||
setEmailSent(true);
|
||||
setSentEmailAddress(trimmed);
|
||||
} catch (err) {
|
||||
setEmailError(humanizeError(err));
|
||||
if (!isReauthCancelled(err)) setEmailError(humanizeError(err));
|
||||
} finally {
|
||||
setEmailSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startChangeEmail() {
|
||||
setChangingEmail(true);
|
||||
setEmailInput("");
|
||||
setEmailSent(false);
|
||||
setOtpCodeInput("");
|
||||
setEmailError(null);
|
||||
}
|
||||
|
||||
function cancelChangeEmail() {
|
||||
setChangingEmail(false);
|
||||
setEmailSent(false);
|
||||
setEmailError(null);
|
||||
}
|
||||
|
||||
async function verifyEmailOtp(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmedCode = otpCodeInput.trim();
|
||||
@@ -80,6 +101,11 @@ export function Account() {
|
||||
try {
|
||||
await api.emailVerify(trimmedCode);
|
||||
await refresh();
|
||||
if (changingEmail) {
|
||||
// Replacing a verified address signs the other devices out.
|
||||
setChangingEmail(false);
|
||||
setSessionsVersion((v) => v + 1);
|
||||
}
|
||||
setEmailSent(false);
|
||||
setEmailInput("");
|
||||
setOtpCodeInput("");
|
||||
@@ -97,6 +123,7 @@ export function Account() {
|
||||
const [passkeyNickname, setPasskeyNickname] = useState("");
|
||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
||||
const [passkeyNotice, setPasskeyNotice] = useState<string | null>(null);
|
||||
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||||
// Deleting a passkey goes through a confirm dialog that names it. The API
|
||||
// refuses to remove the only passkey of an account whose email is unverified
|
||||
@@ -118,6 +145,7 @@ export function Account() {
|
||||
setRegisterDialogOpen(false);
|
||||
setPasskeyNickname("");
|
||||
setPasskeyError(null);
|
||||
setPasskeyNotice(null);
|
||||
setRegisteringPasskey(false);
|
||||
}
|
||||
|
||||
@@ -127,12 +155,22 @@ export function Account() {
|
||||
if (!name || registeringPasskey) return;
|
||||
setRegisteringPasskey(true);
|
||||
setPasskeyError(null);
|
||||
setPasskeyNotice(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const options = await api.passkeyRegisterBegin();
|
||||
let options;
|
||||
try {
|
||||
options = await api.passkeyRegisterBegin();
|
||||
} catch (err) {
|
||||
if (!isReauthRequired(err)) throw err;
|
||||
// The browser lets navigator.credentials.create run only right after a
|
||||
// click, which the confirmation used up: the user presses Continue again.
|
||||
if (await reauth.confirm()) setPasskeyNotice(t("reauth_done_continue"));
|
||||
return;
|
||||
}
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
...options,
|
||||
challenge: base64urlToBytes(options.challenge),
|
||||
@@ -189,12 +227,13 @@ export function Account() {
|
||||
setDeletingPasskey(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.passkeyDelete(pendingDelete.id);
|
||||
await reauth.guard(() => api.passkeyDelete(pendingDelete.id));
|
||||
setPendingDelete(null);
|
||||
// The server signed the other devices out along with the passkey.
|
||||
setSessionsVersion((v) => v + 1);
|
||||
await passkeys.reload();
|
||||
} catch (err) {
|
||||
if (isReauthCancelled(err)) return;
|
||||
// Another device may have changed the list meanwhile: refresh it. A 404
|
||||
// means the passkey is already gone, which is what was asked for.
|
||||
void passkeys.reload();
|
||||
@@ -290,17 +329,20 @@ export function Account() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{identity?.email_verified ? (
|
||||
{identity?.email_verified && !changingEmail ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
{t("email_verified")}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t("email_desc")}</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
{identity.email}
|
||||
</code>
|
||||
<Button variant="link" size="sm" onClick={startChangeEmail} className="h-auto p-0 font-normal">
|
||||
{t("change_email")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -309,7 +351,7 @@ export function Account() {
|
||||
<StepBadge n={1} />
|
||||
<div className="w-full space-y-2">
|
||||
<p className="font-medium text-foreground">{t("email_step1")}</p>
|
||||
<p className="text-muted-foreground">{t("email_step1_desc")}</p>
|
||||
<p className="text-muted-foreground">{t(changingEmail ? "email_change_desc" : "email_step1_desc")}</p>
|
||||
{!emailSent ? (
|
||||
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
|
||||
<Input
|
||||
@@ -324,6 +366,11 @@ export function Account() {
|
||||
<Button type="submit" disabled={emailSending || !emailInput}>
|
||||
{emailSending ? t("sending_code") : t("send_code")}
|
||||
</Button>
|
||||
{changingEmail && (
|
||||
<Button type="button" variant="ghost" onClick={cancelChangeEmail} disabled={emailSending}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
|
||||
@@ -409,6 +456,7 @@ export function Account() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{passkeyNotice && <MessageLine kind="success" message={passkeyNotice} />}
|
||||
<InlineError message={passkeyError} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -518,6 +566,8 @@ export function Account() {
|
||||
onSignOut={() => void signOut()}
|
||||
signingOut={signingOut}
|
||||
/>
|
||||
|
||||
{reauth.dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -654,29 +704,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
|
||||
function confirmWithPasskey() {
|
||||
void run(async () => {
|
||||
const options = await api.migrateConfirmPasskeyBegin();
|
||||
const pk = options.publicKey;
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
...pk,
|
||||
challenge: base64urlToBytes(pk.challenge),
|
||||
allowCredentials: pk.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64urlToBytes(cred.id),
|
||||
})),
|
||||
};
|
||||
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
|
||||
if (!credential) throw clientError("passkey_no_credential");
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
await api.migrateConfirmPasskeyFinish({
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64url(response.authenticatorData),
|
||||
signature: bytesToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
|
||||
},
|
||||
});
|
||||
await api.migrateConfirmPasskeyFinish(await requestAssertion(options.publicKey));
|
||||
await mig.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Identity, PasskeyCredential } from "@/lib/types";
|
||||
import { Account } from "./Account";
|
||||
|
||||
// The Account page's changes to how the account signs in (delete or add a
|
||||
// passkey, change the email) meet 403 reauth_required when the session has not
|
||||
// proven a factor lately. These drive the page through that refusal: the check
|
||||
// dialog, each factor, and the change running again afterwards.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
passkeyList: vi.fn(),
|
||||
passkeyDelete: vi.fn(),
|
||||
passkeyRegisterBegin: vi.fn(),
|
||||
listMySessions: vi.fn(),
|
||||
emailStart: vi.fn(),
|
||||
emailVerify: vi.fn(),
|
||||
reauthStatus: vi.fn(),
|
||||
reauthPasskeyBegin: vi.fn(),
|
||||
reauthPasskeyFinish: vi.fn(),
|
||||
reauthEmailStart: vi.fn(),
|
||||
reauthEmailVerify: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
credentialsGet: vi.fn(),
|
||||
credentialsCreate: vi.fn(),
|
||||
identity: null as Identity | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
linkStatus: () => Promise.resolve({ linked: true }),
|
||||
migrateStatus: () => Promise.resolve({ active: false }),
|
||||
passkeyList: mocks.passkeyList,
|
||||
passkeyDelete: mocks.passkeyDelete,
|
||||
passkeyRegisterBegin: mocks.passkeyRegisterBegin,
|
||||
listMySessions: mocks.listMySessions,
|
||||
emailStart: mocks.emailStart,
|
||||
emailVerify: mocks.emailVerify,
|
||||
reauthStatus: mocks.reauthStatus,
|
||||
reauthPasskeyBegin: mocks.reauthPasskeyBegin,
|
||||
reauthPasskeyFinish: mocks.reauthPasskeyFinish,
|
||||
reauthEmailStart: mocks.reauthEmailStart,
|
||||
reauthEmailVerify: mocks.reauthEmailVerify,
|
||||
logout: mocks.logout,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({ identity: mocks.identity, refresh: mocks.refresh }),
|
||||
}));
|
||||
|
||||
const refused = { status: 403, code: "reauth_required", message: "confirm it's you first" };
|
||||
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
|
||||
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
|
||||
|
||||
function identity(role: Identity["role"] = "user"): Identity {
|
||||
return {
|
||||
user_id: "u-1",
|
||||
email: "[email protected]",
|
||||
role,
|
||||
is_admin: role !== "user",
|
||||
is_owner: role === "owner",
|
||||
email_verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
const bytes = (s: string) => new TextEncoder().encode(s).buffer;
|
||||
|
||||
function renderAccount() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Account />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const checkDialog = () => screen.findByRole("dialog", { name: "Confirm it's you" });
|
||||
|
||||
async function askToDeleteLaptop() {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Delete passkey “Laptop”" }));
|
||||
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
|
||||
await userEvent.click(within(confirmDelete).getByRole("button", { name: "Delete" }));
|
||||
}
|
||||
|
||||
async function proveByEmail(dialog: HTMLElement) {
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: "Email a code to [email protected]" }));
|
||||
await userEvent.type(await within(dialog).findByLabelText("Code sent to [email protected]"), "123456");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset();
|
||||
mocks.identity = identity();
|
||||
mocks.listMySessions.mockResolvedValue([]);
|
||||
mocks.reauthEmailStart.mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
|
||||
mocks.reauthEmailVerify.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
|
||||
mocks.reauthPasskeyFinish.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
|
||||
Object.defineProperty(navigator, "credentials", {
|
||||
value: { get: mocks.credentialsGet, create: mocks.credentialsCreate },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account re-authentication", () => {
|
||||
it("asks for an email code before deleting a passkey, then deletes it", async () => {
|
||||
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
|
||||
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
const dialog = await checkDialog();
|
||||
expect(within(dialog).queryByRole("button", { name: "Use a passkey" })).toBeNull();
|
||||
await proveByEmail(dialog);
|
||||
|
||||
expect(mocks.reauthEmailStart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.reauthEmailVerify).toHaveBeenCalledWith("123456");
|
||||
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.passkeyDelete).toHaveBeenLastCalledWith("pk-1");
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Delete passkey “Laptop”" })).toBeNull());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the check keeps the passkey and the delete dialog, with no error", async () => {
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
|
||||
mocks.passkeyDelete.mockRejectedValue(refused);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
const dialog = await checkDialog();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Confirm it's you" })).toBeNull());
|
||||
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
|
||||
expect(within(confirmDelete).queryByRole("alert")).toBeNull();
|
||||
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.passkeyList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a wrong code keeps the check open with the reason and changes nothing", async () => {
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
|
||||
mocks.passkeyDelete.mockRejectedValue(refused);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
|
||||
mocks.reauthEmailVerify.mockRejectedValue({ status: 400, code: "invalid_code", message: "raw" });
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
const dialog = await checkDialog();
|
||||
await proveByEmail(dialog);
|
||||
|
||||
const alert = await within(dialog).findByRole("alert");
|
||||
expect(alert.textContent).toBe("That code is invalid or expired — request a fresh one and try again.");
|
||||
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("proves with a passkey from the envelope the begin returns", async () => {
|
||||
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
|
||||
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["passkey", "email"] });
|
||||
mocks.reauthPasskeyBegin.mockResolvedValue({
|
||||
publicKey: { challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "cGstMQ" }] },
|
||||
});
|
||||
mocks.credentialsGet.mockResolvedValue({
|
||||
id: "cred-1",
|
||||
rawId: bytes("raw"),
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: bytes("cd"),
|
||||
authenticatorData: bytes("ad"),
|
||||
signature: bytes("sig"),
|
||||
userHandle: null,
|
||||
},
|
||||
});
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
const dialog = await checkDialog();
|
||||
// Both factors are offered, the passkey first.
|
||||
const buttons = within(dialog).getAllByRole("button").map((b) => b.textContent);
|
||||
expect(buttons.indexOf("Use a passkey")).toBeLessThan(buttons.indexOf("Email a code to [email protected]"));
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Use a passkey" }));
|
||||
|
||||
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
|
||||
const publicKey = mocks.credentialsGet.mock.calls[0][0].publicKey;
|
||||
expect(new TextDecoder().decode(publicKey.challenge)).toBe("challenge");
|
||||
expect(new TextDecoder().decode(publicKey.allowCredentials[0].id)).toBe("pk-1");
|
||||
expect(mocks.reauthPasskeyFinish).toHaveBeenCalledWith({
|
||||
id: "cred-1",
|
||||
rawId: "cmF3",
|
||||
type: "public-key",
|
||||
response: { clientDataJSON: "Y2Q", authenticatorData: "YWQ", signature: "c2ln", userHandle: null },
|
||||
});
|
||||
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers operators a fresh sign-in instead of an email code", async () => {
|
||||
mocks.identity = identity("admin");
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
|
||||
mocks.passkeyDelete.mockRejectedValue(refused);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["sign_in"] });
|
||||
mocks.logout.mockResolvedValue(undefined);
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
const dialog = await checkDialog();
|
||||
expect(
|
||||
within(dialog).getByText("Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes."),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).queryByRole("button", { name: /Email a code/ })).toBeNull();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Sign out and sign in again" }));
|
||||
|
||||
expect(mocks.logout).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(mocks.refresh).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("goes straight on when the session was proven meanwhile", async () => {
|
||||
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
|
||||
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: false, until: "2026-09-25T10:05:00Z", factors: ["email"] });
|
||||
renderAccount();
|
||||
|
||||
await askToDeleteLaptop();
|
||||
|
||||
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("after the check, adding a passkey waits for Continue instead of starting the browser prompt", async () => {
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
|
||||
mocks.passkeyRegisterBegin.mockRejectedValue(refused);
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
|
||||
renderAccount();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add Passkey" }));
|
||||
const register = screen.getByRole("dialog", { name: "Add Passkey" });
|
||||
await userEvent.type(within(register).getByLabelText("Device Nickname"), "Phone");
|
||||
await userEvent.click(within(register).getByRole("button", { name: "Continue" }));
|
||||
await proveByEmail(await checkDialog());
|
||||
|
||||
const status = await within(screen.getByRole("dialog", { name: "Add Passkey" })).findByRole("status");
|
||||
expect(status.textContent).toBe("Confirmed. Press Continue to add the passkey.");
|
||||
expect(mocks.passkeyRegisterBegin).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.credentialsCreate).not.toHaveBeenCalled();
|
||||
expect(within(screen.getByRole("dialog", { name: "Add Passkey" })).getByRole("button", { name: "Continue" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("changes a verified email after the check and refreshes the devices it signs out", async () => {
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [] });
|
||||
mocks.emailStart.mockRejectedValueOnce(refused).mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
|
||||
mocks.emailVerify.mockResolvedValue({ verified: true, email: "[email protected]" });
|
||||
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
|
||||
renderAccount();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
const address = screen.getByPlaceholderText("[email protected]");
|
||||
expect((address as HTMLInputElement).value).toBe("");
|
||||
await userEvent.type(address, "[email protected]");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send Code" }));
|
||||
await proveByEmail(await checkDialog());
|
||||
|
||||
await waitFor(() => expect(mocks.emailStart).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.emailStart).toHaveBeenLastCalledWith("[email protected]");
|
||||
expect(await screen.findByText(/Verification code sent\./)).toBeTruthy();
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("6-digit code"), "654321");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verify" }));
|
||||
|
||||
expect(mocks.emailVerify).toHaveBeenCalledWith("654321");
|
||||
await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancelling a change of email returns to the verified address", async () => {
|
||||
mocks.passkeyList.mockResolvedValue({ credentials: [] });
|
||||
renderAccount();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(screen.getByText("[email protected]")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("[email protected]")).toBeNull();
|
||||
expect(mocks.emailStart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { loginReturnPath } from "@/lib/auth";
|
||||
import { api, clientError, humanizeError } from "@/lib/api";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||
import { requestAssertion } from "@/lib/passkey";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
|
||||
|
||||
@@ -143,41 +143,10 @@ export function Login() {
|
||||
|
||||
const identifier = email.trim();
|
||||
try {
|
||||
let assertion: any;
|
||||
if (!identifier) {
|
||||
// Discoverable (Usernameless) passkey login
|
||||
const options = await api.authPasskeyDiscoverableBegin();
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
...options.publicKey,
|
||||
challenge: base64urlToBytes(options.publicKey.challenge),
|
||||
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64urlToBytes(cred.id),
|
||||
})),
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
})) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
throw clientError("passkey_no_credential");
|
||||
}
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
assertion = {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64url(response.authenticatorData),
|
||||
signature: bytesToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
|
||||
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
|
||||
await api.authPasskeyDiscoverableFinish(options.login_id, await requestAssertion(options.publicKey));
|
||||
} else {
|
||||
// Email-first passkey login
|
||||
if (!identifier.includes("@")) {
|
||||
@@ -185,37 +154,7 @@ export function Login() {
|
||||
}
|
||||
|
||||
const options = await api.authPasskeyLoginBegin(identifier);
|
||||
const publicKey: PublicKeyCredentialRequestOptions = {
|
||||
...options,
|
||||
challenge: base64urlToBytes(options.challenge),
|
||||
allowCredentials: options.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64urlToBytes(cred.id),
|
||||
})),
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
})) as PublicKeyCredential;
|
||||
|
||||
if (!credential) {
|
||||
throw clientError("passkey_no_credential");
|
||||
}
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
assertion = {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64url(response.authenticatorData),
|
||||
signature: bytesToBase64url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
|
||||
await api.authPasskeyLoginFinish(identifier, assertion);
|
||||
await api.authPasskeyLoginFinish(identifier, await requestAssertion(options));
|
||||
}
|
||||
|
||||
await refresh();
|
||||
|
||||
Reference in new issue
Block a user