fix(panel): op-login 轮询按请求截止时间停止并显示倒计时,过期可一键重新发起,失败指数退避,服务端拒绝即停并提示

This commit is contained in:
Lemon-miaow committed 2026-09-25 11:58:13 +08:00
1 parent 367ef2678a
commit a5307d44d4
7 files changed
+375 -46

No files matched your search

+41 -1
View File
@@ -139,7 +139,7 @@ const LOGIN_HINT_STYLE = `
const LOGIN_HINT_SCRIPT = `
(() => {
const id = "felis-mock-login-hint";
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in (passwordless)</strong><div>Email OTP: any email / code <code>${MOCK_OTP_CODE}</code> (signs in as <code>owner</code>, admin)</div><div>Link code: <code>${MOCK_LINK_CODE}</code> (signs in as <code>linked</code>, user)</div><div>Passkey: any assertion is accepted (signs in as <code>owner</code>)</div></aside>';
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in (passwordless)</strong><div>Email OTP: any email / code <code>${MOCK_OTP_CODE}</code> (signs in as <code>owner</code>, admin)</div><div>Link code: <code>${MOCK_LINK_CODE}</code> (signs in as <code>linked</code>, user)</div><div>Passkey: any assertion is accepted (signs in as <code>owner</code>)</div><div>Operator: any email / code <code>${MOCK_OTP_CODE}</code>, approved ~9s later; an address starting <code>expire</code> lapses in 20s</div></aside>';
const sync = () => {
const existing = document.getElementById(id);
if (location.pathname === "/login") {
@@ -444,6 +444,7 @@ function mockStartupMessage(): string {
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
" Passkey: any assertion accepted → owner (admin, linked)",
` Op login: any email / code ${MOCK_OTP_CODE}, approved ~9s later; an email starting "expire" lapses in 20s`,
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
@@ -665,8 +666,47 @@ function sendCreateError(res: ServerResponse, code: CreateError): void {
sendError(res, status, code, code);
}
// Op-login requests the mock has handed out. Nobody is in-game to vouch, so each
// one approves itself a few seconds after start; an "expire…" address never does
// and lapses quickly, to show the page's expiry path.
const opLogins = new Map<string, { approveAt: number; expiresAt: number }>();
async function handlePublic(ctx: RequestContext): Promise<boolean> {
const opStatus = route(ctx).match(/^GET auth\/op-login\/status\/(.+)$/);
if (opStatus) {
const req = opLogins.get(opStatus[1]);
const now = Date.now();
sendJSON(ctx.res, 200, { approved: !!req && now >= req.approveAt && now < req.expiresAt });
return true;
}
switch (route(ctx)) {
case "POST auth/op-login/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "email is required");
return true;
}
const lapses = body.email.startsWith("expire");
const id = `op-${opLogins.size + 1}`;
const now = Date.now();
const expiresAt = now + (lapses ? 20_000 : 600_000);
opLogins.set(id, { approveAt: lapses ? Infinity : now + 9_000, expiresAt });
sendJSON(ctx.res, 202, { request_id: id, expires_at: new Date(expiresAt).toISOString() });
return true;
}
case "POST auth/op-login/finish": {
const body = await readJSON<{ request_id?: string; code?: string }>(ctx.req);
const req = body.request_id ? opLogins.get(body.request_id) : undefined;
const now = Date.now();
if (!req || now < req.approveAt || now >= req.expiresAt || body.code !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "op_login_invalid", "operator login could not be completed");
return true;
}
opLogins.delete(body.request_id!);
setSessionCookie(ctx.res, "owner");
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
return true;
}
case "POST auth/bind": {
const body = await readJSON<{ code?: string }>(ctx.req);
const code = body.code?.trim().toUpperCase();
+4
View File
@@ -29,6 +29,10 @@
"op_waiting": "Waiting for in-game approval…",
"op_approved": "Approved — enter the code from your email.",
"op_restart": "Start over",
"op_expires_in": "This request expires in {{time}}.",
"op_expired": "This request expired before it was used. Request a new one to sign in.",
"op_request_again": "Request again",
"op_poll_retrying": "Can't reach Felis right now. Still waiting, checking less often…",
"saving": "Saving…",
"setup_title": "Set up your account",
"setup_welcome": "Welcome, {{name}}",
+4
View File
@@ -29,6 +29,10 @@
"op_waiting": "等待游戏内批准…",
"op_approved": "已批准——请输入邮件中的验证码。",
"op_restart": "重新开始",
"op_expires_in": "此请求将在 {{time}} 后过期。",
"op_expired": "此请求在使用前已过期,请重新发起登录。",
"op_request_again": "重新发起",
"op_poll_retrying": "暂时连不上 Felis,仍在等待,查询会放慢…",
"saving": "保存中…",
"setup_title": "初始化你的账户",
"setup_welcome": "欢迎,{{name}}",
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { OP_LOGIN_TTL_MS, formatCountdown, opLoginDeadline, opPollDelay } from "./opLoginPoll";
const NOW = Date.parse("2026-09-25T12:00:00Z");
describe("opLoginDeadline", () => {
it("counts down to the server's expires_at when the clocks agree", () => {
expect(opLoginDeadline("2026-09-25T12:09:30Z", NOW)).toBe(NOW + 570_000);
});
it("falls back to the TTL from now when the clocks disagree", () => {
// The browser runs 20 minutes fast: taken literally the request died before it was shown.
expect(opLoginDeadline("2026-09-25T11:50:00Z", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
// The browser runs an hour slow: no request lives that long.
expect(opLoginDeadline("2026-09-25T13:10:00Z", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
expect(opLoginDeadline("not a time", NOW)).toBe(NOW + OP_LOGIN_TTL_MS);
});
});
describe("opPollDelay", () => {
it("asks every 3s while calls succeed and doubles per failure up to 30s", () => {
expect([0, 1, 2, 3, 4, 10].map(opPollDelay)).toEqual([3000, 6000, 12000, 24000, 30000, 30000]);
});
});
describe("formatCountdown", () => {
it("writes minutes and zero-padded seconds, rounding a started second up", () => {
expect(formatCountdown(600_000)).toBe("10:00");
expect(formatCountdown(65_001)).toBe("1:06");
expect(formatCountdown(9_000)).toBe("0:09");
expect(formatCountdown(-5)).toBe("0:00");
});
});
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ApiError } from "@/lib/types";
// The op-login status endpoint answers approved:false for an unknown, expired or
// used request alike (it must not tell a stranger which staff addresses exist), so
// it never says a request died. The expires_at that start returned is the only
// end the page gets.
/** OP_LOGIN_TTL_MS mirrors otpTTL in handlers_email_otp.go: how long a request lives. */
export const OP_LOGIN_TTL_MS = 10 * 60 * 1000;
export const OP_POLL_BASE_MS = 3000;
export const OP_POLL_MAX_MS = 30000;
/**
* opLoginDeadline turns start's expires_at into a deadline on this browser's
* clock. One already past, or further off than a request can live, means the two
* clocks disagree; the request was minted just now, so the TTL from now is the
* better estimate then.
*/
export function opLoginDeadline(expiresAt: string, now: number): number {
const remaining = Date.parse(expiresAt) - now;
if (!Number.isFinite(remaining) || remaining <= 0 || remaining > OP_LOGIN_TTL_MS) {
return now + OP_LOGIN_TTL_MS;
}
return now + remaining;
}
/** opPollDelay doubles the wait after each consecutive failure, capped at OP_POLL_MAX_MS. */
export function opPollDelay(failures: number): number {
return Math.min(OP_POLL_BASE_MS * 2 ** failures, OP_POLL_MAX_MS);
}
/** formatCountdown writes a remaining time as m:ss. */
export function formatCountdown(ms: number): string {
const total = Math.ceil(Math.max(0, ms) / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
// A failure worth asking again after: no answer at all, a rate limit, or the server
// side. Anything else (local sessions switched off, a malformed id) answers the
// same way every time.
function retryable(e: unknown): boolean {
const status = (e as Partial<ApiError> | null)?.status;
return status === undefined || status === 0 || status === 429 || status >= 500;
}
export interface OpLoginPoll {
approved: boolean;
expired: boolean;
remainingMs: number;
/** The last status call failed and the next one waits longer. */
retrying: boolean;
/** A refusal that asking again will not change; polling has stopped. */
error: unknown;
}
/**
* useOpLoginPoll asks whether the request was approved in-game until it is, until
* the deadline passes, or until the server refuses outright, backing off while the
* calls fail. The countdown keeps running after approval: the code has to be
* redeemed before the same deadline.
*/
export function useOpLoginPoll(requestId: string | null, deadline: number | null): OpLoginPoll {
const [approved, setApproved] = useState(false);
const [retrying, setRetrying] = useState(false);
const [error, setError] = useState<unknown>(null);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
setApproved(false);
setRetrying(false);
setError(null);
if (!requestId || deadline === null) return;
let cancelled = false;
let failures = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = async () => {
if (Date.now() >= deadline) return;
try {
const s = await api.opLoginStatus(requestId);
if (cancelled) return;
failures = 0;
setRetrying(false);
if (s.approved) {
setApproved(true);
return;
}
} catch (e) {
if (cancelled) return;
if (!retryable(e)) {
setRetrying(false);
setError(e);
return;
}
failures += 1;
setRetrying(true);
}
timer = setTimeout(poll, opPollDelay(failures));
};
timer = setTimeout(poll, OP_POLL_BASE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [requestId, deadline]);
useEffect(() => {
if (deadline === null) return;
setNow(Date.now());
const tick = setInterval(() => {
const n = Date.now();
setNow(n);
if (n >= deadline) clearInterval(tick);
}, 1000);
return () => clearInterval(tick);
}, [deadline]);
const remainingMs = deadline === null ? 0 : Math.max(0, deadline - now);
return { approved, expired: deadline !== null && remainingMs === 0, remainingMs, retrying, error };
}
+105 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
@@ -13,6 +13,8 @@ const calls = vi.hoisted(() => ({
authPasskeyDiscoverableFinish: vi.fn(),
authPasskeyLoginBegin: vi.fn(),
authPasskeyLoginFinish: vi.fn(),
opLoginStart: vi.fn(),
opLoginStatus: vi.fn(),
credentialsGet: vi.fn(),
refresh: vi.fn(),
}));
@@ -35,6 +37,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
authPasskeyDiscoverableFinish: calls.authPasskeyDiscoverableFinish,
authPasskeyLoginBegin: calls.authPasskeyLoginBegin,
authPasskeyLoginFinish: calls.authPasskeyLoginFinish,
opLoginStart: calls.opLoginStart,
opLoginStatus: calls.opLoginStatus,
},
};
});
@@ -100,3 +104,103 @@ describe("Login", () => {
expect(calls.authPasskeyLoginFinish).not.toHaveBeenCalled();
});
});
// The status endpoint says approved:false for a dead request too, so the page ends
// the wait itself: at start's expires_at, or when the server refuses outright.
describe("operator sign-in", () => {
const WAITING = () => screen.getByText(t("auth:op_waiting"));
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date("2026-09-25T12:00:00Z"));
});
afterEach(() => vi.useRealTimers());
async function startOp(expiresInMs: number) {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
calls.opLoginStart.mockImplementation(async () => ({
request_id: `req-${calls.opLoginStart.mock.calls.length}`,
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
}));
renderLogin();
await user.click(screen.getByRole("button", { name: t("auth:tab_op_btn") }));
await user.type(screen.getByLabelText(t("auth:email_address")), "[email protected]");
await user.click(screen.getByRole("button", { name: t("auth:op_start_btn") }));
await screen.findByText("/felis web op approve req-1");
return user;
}
it("stops asking at the deadline and offers a new request", async () => {
calls.opLoginStatus.mockResolvedValue({ approved: false });
const user = await startOp(10_000);
expect(screen.getByText(t("auth:op_expires_in", { time: "0:10" }))).toBeTruthy();
await vi.advanceTimersByTimeAsync(9_500);
expect(calls.opLoginStatus).toHaveBeenCalledTimes(3);
expect(WAITING()).toBeTruthy();
await vi.advanceTimersByTimeAsync(1_000);
expect((await screen.findByRole("alert")).textContent).toBe(t("auth:op_expired"));
expect((screen.getByLabelText(t("auth:otp_code")) as HTMLInputElement).disabled).toBe(true);
await vi.advanceTimersByTimeAsync(60_000);
expect(calls.opLoginStatus).toHaveBeenCalledTimes(3);
await user.click(screen.getByRole("button", { name: t("auth:op_request_again") }));
await screen.findByText("/felis web op approve req-2");
expect(calls.opLoginStart).toHaveBeenLastCalledWith("[email protected]");
expect(screen.queryByRole("alert")).toBeNull();
await vi.advanceTimersByTimeAsync(3_200);
expect(calls.opLoginStatus).toHaveBeenLastCalledWith("req-2");
});
it("backs off while status calls fail and returns to the normal pace after one succeeds", async () => {
const offline = { status: 0, code: "network_error", message: "" };
calls.opLoginStatus
.mockRejectedValueOnce(offline)
.mockRejectedValueOnce(offline)
.mockRejectedValueOnce(offline)
.mockResolvedValue({ approved: false });
await startOp(300_000);
await vi.advanceTimersByTimeAsync(3_200); // t=3s: first failure, next in 6s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(1);
expect(screen.getByText(t("auth:op_poll_retrying"))).toBeTruthy();
await vi.advanceTimersByTimeAsync(5_500); // t=8.7s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(600); // t=9.3s: second failure, next in 12s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(11_400); // t=20.7s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(600); // t=21.3s: third failure, next in 24s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(23_400); // t=44.7s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(600); // t=45.3s: success, next in 3s
expect(calls.opLoginStatus).toHaveBeenCalledTimes(4);
expect(WAITING()).toBeTruthy();
await vi.advanceTimersByTimeAsync(3_000);
expect(calls.opLoginStatus).toHaveBeenCalledTimes(5);
});
it("stops and says why when the server refuses outright", async () => {
calls.opLoginStatus.mockRejectedValue({ status: 403, code: "local_auth_disabled", message: "" });
await startOp(300_000);
await vi.advanceTimersByTimeAsync(3_200);
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:local_auth_disabled"));
await vi.advanceTimersByTimeAsync(60_000);
expect(calls.opLoginStatus).toHaveBeenCalledTimes(1);
});
it("stops asking once approved and keeps the countdown for the code", async () => {
calls.opLoginStatus.mockResolvedValueOnce({ approved: false }).mockResolvedValue({ approved: true });
await startOp(60_000);
await vi.advanceTimersByTimeAsync(6_200);
expect(screen.getByText(t("auth:op_approved"))).toBeTruthy();
expect(screen.getByText(t("auth:op_expires_in", { time: "0:54" }))).toBeTruthy();
await vi.advanceTimersByTimeAsync(30_000);
expect(calls.opLoginStatus).toHaveBeenCalledTimes(2);
});
});
+67 -44
View File
@@ -13,6 +13,7 @@ import { api, clientError, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine";
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
// Login is the passwordless sign-in (spec §B). Passkey and email-OTP are the
// primary doors; a first-time player arrives with an in-game Bind Code (/link);
@@ -44,7 +45,7 @@ export function Login() {
// mailed code. request_id doubles as the handle an online admin approves.
const [opEmail, setOpEmail] = useState("");
const [opRequestId, setOpRequestId] = useState<string | null>(null);
const [opApproved, setOpApproved] = useState(false);
const [opDeadline, setOpDeadline] = useState<number | null>(null);
const [opCode, setOpCode] = useState("");
const [isOpHost, setIsOpHost] = useState(false);
const [submitting, setSubmitting] = useState(false);
@@ -70,20 +71,10 @@ export function Login() {
});
}, []);
// Poll the op-login request until an in-game approval lands. Errors are
// swallowed on purpose: a transient failure just means we ask again.
useEffect(() => {
if (!opRequestId || opApproved) return;
const timer = setInterval(async () => {
try {
const s = await api.opLoginStatus(opRequestId);
if (s.approved) setOpApproved(true);
} catch {
// keep polling
}
}, 3000);
return () => clearInterval(timer);
}, [opRequestId, opApproved]);
// Polls until the in-game approval lands, the request's deadline passes, or the
// server refuses outright.
const opPoll = useOpLoginPoll(opRequestId, opDeadline);
const opApproved = opPoll.approved;
// Don't flash the form while the boot /me is still in flight: a signed-in visitor
// would briefly see a login form before being redirected away.
@@ -236,13 +227,15 @@ export function Login() {
}
}
async function handleOpStart(e: FormEvent) {
e.preventDefault();
async function handleOpStart(e?: FormEvent) {
e?.preventDefault();
if (!opEmail.trim() || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.opLoginStart(opEmail.trim());
setOpCode("");
setOpDeadline(opLoginDeadline(res.expires_at, Date.now()));
setOpRequestId(res.request_id);
} catch (err) {
setError(humanizeError(err));
@@ -538,15 +531,29 @@ export function Login() {
</p>
</div>
{opApproved ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
{t("op_approved")}
{opPoll.expired ? (
<p
role="alert"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 leading-normal"
>
{t("op_expired")}
</p>
) : (
<p className="inline-flex items-center gap-2 text-[11px] text-muted-foreground leading-normal">
<Loader2 className="h-3 w-3 animate-spin" />
{t("op_waiting")}
</p>
<div className="space-y-1">
{opApproved ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
{t("op_approved")}
</p>
) : (
<p className="inline-flex items-center gap-2 text-[11px] text-muted-foreground leading-normal">
<Loader2 className="h-3 w-3 animate-spin" />
{t(opPoll.retrying ? "op_poll_retrying" : "op_waiting")}
</p>
)}
<p className="text-[11px] text-muted-foreground/80 leading-normal">
{t("op_expires_in", { time: formatCountdown(opPoll.remainingMs) })}
</p>
</div>
)}
<div className="space-y-2">
@@ -560,37 +567,53 @@ export function Login() {
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
disabled={submitting}
disabled={submitting || opPoll.expired}
aria-invalid={error ? true : undefined}
/>
</div>
<InlineError message={error} />
<InlineError message={error ?? (opPoll.error ? humanizeError(opPoll.error) : null)} />
<Button
type="submit"
className="w-full"
disabled={submitting || !opCode.trim() || !opApproved}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
{opPoll.expired ? (
<Button type="button" className="w-full" disabled={submitting} onClick={() => void handleOpStart()}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("sending_otp")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("op_request_again")}
</>
)}
</Button>
) : (
<Button
type="submit"
className="w-full"
disabled={submitting || !opCode.trim() || !opApproved}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
)}
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setOpRequestId(null);
setOpApproved(false);
setOpDeadline(null);
setOpCode("");
switchTab("op");
}}