fix(panel): op-login 轮询按请求截止时间停止并显示倒计时,过期可一键重新发起,失败指数退避,服务端拒绝即停并提示
This commit is contained in:
7 files changed
+375
-46
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user