fix(panel): op-login 轮询按请求截止时间停止并显示倒计时,过期可一键重新发起,失败指数退避,服务端拒绝即停并提示
This commit is contained in:
7 files changed
+375
-46
No files matched your search
+41
-1
@@ -139,7 +139,7 @@ const LOGIN_HINT_STYLE = `
|
|||||||
const LOGIN_HINT_SCRIPT = `
|
const LOGIN_HINT_SCRIPT = `
|
||||||
(() => {
|
(() => {
|
||||||
const id = "felis-mock-login-hint";
|
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 sync = () => {
|
||||||
const existing = document.getElementById(id);
|
const existing = document.getElementById(id);
|
||||||
if (location.pathname === "/login") {
|
if (location.pathname === "/login") {
|
||||||
@@ -444,6 +444,7 @@ function mockStartupMessage(): string {
|
|||||||
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
|
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
|
||||||
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
|
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
|
||||||
" Passkey: any assertion accepted → owner (admin, 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}`,
|
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -665,8 +666,47 @@ function sendCreateError(res: ServerResponse, code: CreateError): void {
|
|||||||
sendError(res, status, code, code);
|
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> {
|
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)) {
|
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": {
|
case "POST auth/bind": {
|
||||||
const body = await readJSON<{ code?: string }>(ctx.req);
|
const body = await readJSON<{ code?: string }>(ctx.req);
|
||||||
const code = body.code?.trim().toUpperCase();
|
const code = body.code?.trim().toUpperCase();
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
"op_waiting": "Waiting for in-game approval…",
|
"op_waiting": "Waiting for in-game approval…",
|
||||||
"op_approved": "Approved — enter the code from your email.",
|
"op_approved": "Approved — enter the code from your email.",
|
||||||
"op_restart": "Start over",
|
"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…",
|
"saving": "Saving…",
|
||||||
"setup_title": "Set up your account",
|
"setup_title": "Set up your account",
|
||||||
"setup_welcome": "Welcome, {{name}}",
|
"setup_welcome": "Welcome, {{name}}",
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
"op_waiting": "等待游戏内批准…",
|
"op_waiting": "等待游戏内批准…",
|
||||||
"op_approved": "已批准——请输入邮件中的验证码。",
|
"op_approved": "已批准——请输入邮件中的验证码。",
|
||||||
"op_restart": "重新开始",
|
"op_restart": "重新开始",
|
||||||
|
"op_expires_in": "此请求将在 {{time}} 后过期。",
|
||||||
|
"op_expired": "此请求在使用前已过期,请重新发起登录。",
|
||||||
|
"op_request_again": "重新发起",
|
||||||
|
"op_poll_retrying": "暂时连不上 Felis,仍在等待,查询会放慢…",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"setup_title": "初始化你的账户",
|
"setup_title": "初始化你的账户",
|
||||||
"setup_welcome": "欢迎,{{name}}",
|
"setup_welcome": "欢迎,{{name}}",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
@@ -13,6 +13,8 @@ const calls = vi.hoisted(() => ({
|
|||||||
authPasskeyDiscoverableFinish: vi.fn(),
|
authPasskeyDiscoverableFinish: vi.fn(),
|
||||||
authPasskeyLoginBegin: vi.fn(),
|
authPasskeyLoginBegin: vi.fn(),
|
||||||
authPasskeyLoginFinish: vi.fn(),
|
authPasskeyLoginFinish: vi.fn(),
|
||||||
|
opLoginStart: vi.fn(),
|
||||||
|
opLoginStatus: vi.fn(),
|
||||||
credentialsGet: vi.fn(),
|
credentialsGet: vi.fn(),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -35,6 +37,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
authPasskeyDiscoverableFinish: calls.authPasskeyDiscoverableFinish,
|
authPasskeyDiscoverableFinish: calls.authPasskeyDiscoverableFinish,
|
||||||
authPasskeyLoginBegin: calls.authPasskeyLoginBegin,
|
authPasskeyLoginBegin: calls.authPasskeyLoginBegin,
|
||||||
authPasskeyLoginFinish: calls.authPasskeyLoginFinish,
|
authPasskeyLoginFinish: calls.authPasskeyLoginFinish,
|
||||||
|
opLoginStart: calls.opLoginStart,
|
||||||
|
opLoginStatus: calls.opLoginStatus,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -100,3 +104,103 @@ describe("Login", () => {
|
|||||||
expect(calls.authPasskeyLoginFinish).not.toHaveBeenCalled();
|
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
@@ -13,6 +13,7 @@ import { api, clientError, humanizeError } from "@/lib/api";
|
|||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
import { InlineError } from "@/components/MessageLine";
|
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
|
// 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);
|
// 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.
|
// mailed code. request_id doubles as the handle an online admin approves.
|
||||||
const [opEmail, setOpEmail] = useState("");
|
const [opEmail, setOpEmail] = useState("");
|
||||||
const [opRequestId, setOpRequestId] = useState<string | null>(null);
|
const [opRequestId, setOpRequestId] = useState<string | null>(null);
|
||||||
const [opApproved, setOpApproved] = useState(false);
|
const [opDeadline, setOpDeadline] = useState<number | null>(null);
|
||||||
const [opCode, setOpCode] = useState("");
|
const [opCode, setOpCode] = useState("");
|
||||||
const [isOpHost, setIsOpHost] = useState(false);
|
const [isOpHost, setIsOpHost] = useState(false);
|
||||||
const [submitting, setSubmitting] = 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
|
// Polls until the in-game approval lands, the request's deadline passes, or the
|
||||||
// swallowed on purpose: a transient failure just means we ask again.
|
// server refuses outright.
|
||||||
useEffect(() => {
|
const opPoll = useOpLoginPoll(opRequestId, opDeadline);
|
||||||
if (!opRequestId || opApproved) return;
|
const opApproved = opPoll.approved;
|
||||||
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]);
|
|
||||||
|
|
||||||
// Don't flash the form while the boot /me is still in flight: a signed-in visitor
|
// 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.
|
// would briefly see a login form before being redirected away.
|
||||||
@@ -236,13 +227,15 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOpStart(e: FormEvent) {
|
async function handleOpStart(e?: FormEvent) {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
if (!opEmail.trim() || submitting) return;
|
if (!opEmail.trim() || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.opLoginStart(opEmail.trim());
|
const res = await api.opLoginStart(opEmail.trim());
|
||||||
|
setOpCode("");
|
||||||
|
setOpDeadline(opLoginDeadline(res.expires_at, Date.now()));
|
||||||
setOpRequestId(res.request_id);
|
setOpRequestId(res.request_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
@@ -538,15 +531,29 @@ export function Login() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{opApproved ? (
|
{opPoll.expired ? (
|
||||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
|
<p
|
||||||
{t("op_approved")}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<p className="inline-flex items-center gap-2 text-[11px] text-muted-foreground leading-normal">
|
<div className="space-y-1">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
{opApproved ? (
|
||||||
{t("op_waiting")}
|
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 leading-normal">
|
||||||
</p>
|
{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">
|
<div className="space-y-2">
|
||||||
@@ -560,37 +567,53 @@ export function Login() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={submitting}
|
disabled={submitting || opPoll.expired}
|
||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineError message={error} />
|
<InlineError message={error ?? (opPoll.error ? humanizeError(opPoll.error) : null)} />
|
||||||
|
|
||||||
<Button
|
{opPoll.expired ? (
|
||||||
type="submit"
|
<Button type="button" className="w-full" disabled={submitting} onClick={() => void handleOpStart()}>
|
||||||
className="w-full"
|
{submitting ? (
|
||||||
disabled={submitting || !opCode.trim() || !opApproved}
|
<>
|
||||||
>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{submitting ? (
|
{t("sending_otp")}
|
||||||
<>
|
</>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
) : (
|
||||||
{t("signing_in")}
|
<>
|
||||||
</>
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
) : (
|
{t("op_request_again")}
|
||||||
<>
|
</>
|
||||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
)}
|
||||||
{t("otp_btn")}
|
</Button>
|
||||||
</>
|
) : (
|
||||||
)}
|
<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">
|
<div className="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpRequestId(null);
|
setOpRequestId(null);
|
||||||
setOpApproved(false);
|
setOpDeadline(null);
|
||||||
setOpCode("");
|
setOpCode("");
|
||||||
switchTab("op");
|
switchTab("op");
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in new issue
Block a user