From a5307d44d44d30ffcbfd1de13cc37a52b4282588 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 11:58:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20op-login=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E6=8C=89=E8=AF=B7=E6=B1=82=E6=88=AA=E6=AD=A2=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=B9=B6=E6=98=BE=E7=A4=BA=E5=80=92=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E8=BF=87=E6=9C=9F=E5=8F=AF=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=8F=91=E8=B5=B7=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E9=80=80=E9=81=BF=EF=BC=8C=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=8B=92=E7=BB=9D=E5=8D=B3=E5=81=9C=E5=B9=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/dev/mockApi.ts | 42 +++++++- panel/src/i18n/resources/en-US/auth.json | 4 + panel/src/i18n/resources/zh-CN/auth.json | 4 + panel/src/lib/opLoginPoll.test.ts | 33 +++++++ panel/src/lib/opLoginPoll.ts | 121 +++++++++++++++++++++++ panel/src/pages/Login.test.tsx | 106 +++++++++++++++++++- panel/src/pages/Login.tsx | 111 ++++++++++++--------- 7 files changed, 375 insertions(+), 46 deletions(-) create mode 100644 panel/src/lib/opLoginPoll.test.ts create mode 100644 panel/src/lib/opLoginPoll.ts diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 5da1e51..40bc61a 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -139,7 +139,7 @@ const LOGIN_HINT_STYLE = ` const LOGIN_HINT_SCRIPT = ` (() => { const id = "felis-mock-login-hint"; - const html = ''; + const html = ''; 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(); + async function handlePublic(ctx: RequestContext): Promise { + 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(); diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index 49df9ab..3764e12 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -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}}", diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index ab24497..8d7361a 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -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}}", diff --git a/panel/src/lib/opLoginPoll.test.ts b/panel/src/lib/opLoginPoll.test.ts new file mode 100644 index 0000000..32c6676 --- /dev/null +++ b/panel/src/lib/opLoginPoll.test.ts @@ -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"); + }); +}); diff --git a/panel/src/lib/opLoginPoll.ts b/panel/src/lib/opLoginPoll.ts new file mode 100644 index 0000000..6200c9a --- /dev/null +++ b/panel/src/lib/opLoginPoll.ts @@ -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 | 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(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 | 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 }; +} diff --git a/panel/src/pages/Login.test.tsx b/panel/src/pages/Login.test.tsx index 4d0fe25..5dfa693 100644 --- a/panel/src/pages/Login.test.tsx +++ b/panel/src/pages/Login.test.tsx @@ -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")), "op@example.test"); + 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("op@example.test"); + 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); + }); +}); + diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index 718c1e8..03f3ea3 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -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(null); - const [opApproved, setOpApproved] = useState(false); + const [opDeadline, setOpDeadline] = useState(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() {

- {opApproved ? ( -

- {t("op_approved")} + {opPoll.expired ? ( +

+ {t("op_expired")}

) : ( -

- - {t("op_waiting")} -

+
+ {opApproved ? ( +

+ {t("op_approved")} +

+ ) : ( +

+ + {t(opPoll.retrying ? "op_poll_retrying" : "op_waiting")} +

+ )} +

+ {t("op_expires_in", { time: formatCountdown(opPoll.remainingMs) })} +

+
)}
@@ -560,37 +567,53 @@ export function Login() { autoCapitalize="none" autoCorrect="off" spellCheck={false} - disabled={submitting} + disabled={submitting || opPoll.expired} aria-invalid={error ? true : undefined} />
- + - + {opPoll.expired ? ( + + ) : ( + + )}