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 ? (
+
+ ) : (
+
+ )}