fix(migrate): 迁移确认只对做确认的会话在 10 分钟内有效,签发和兑换迁移码都给源账户邮箱发通知

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:14:22 +08:00
1 parent 9a89254146
commit ee4065b986
17 files changed
+496 -122

No files matched your search

@@ -78,6 +78,7 @@
"migration_confirm_otp_btn": "Send a code to my email",
"migration_issue_title": "Name the destination account",
"migration_issue_desc": "Paste the Account ID shown on the destination account's own page here, then issue a one-time transfer code.",
"migration_issue_deadline": "Issue it before {{time}}. The confirmation counts only in this browser; after that, confirm again. A verified email on this account gets a notice once the code is issued.",
"migration_target_placeholder": "Destination Account ID",
"migration_issuing": "Issuing…",
"migration_issue_btn": "Issue code",
+1 -1
View File
@@ -73,7 +73,7 @@
"backup_unavailable": "Backups aren't available right now.",
"account_retired": "This account has been retired — sign in with the account it was migrated to.",
"no_migration": "There is no migration in progress.",
"not_confirmed": "This migration hasn't been confirmed yet.",
"not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.",
"already_confirmed": "This migration has already been confirmed.",
"invalid_target": "The migration target must be a different account.",
"target_not_found": "No account matches that migration target.",
@@ -77,6 +77,7 @@
"migration_confirm_otp_btn": "发送验证码到我的邮箱",
"migration_issue_title": "指定目标账户",
"migration_issue_desc": "将目标账户本页面显示的「账户 ID」粘贴到此处,然后签发一次性转移码。",
"migration_issue_deadline": "请在 {{time}} 前签发。确认只在当前浏览器有效,过时需要重新确认。签发后,已验证的邮箱会收到一封通知。",
"migration_target_placeholder": "目标账户 ID",
"migration_issuing": "签发中…",
"migration_issue_btn": "签发转移码",
+1 -1
View File
@@ -73,7 +73,7 @@
"backup_unavailable": "备份功能当前不可用。",
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
"no_migration": "当前没有进行中的迁移。",
"not_confirmed": "该迁移尚未完成确认。",
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
"already_confirmed": "该迁移已经确认过了。",
"invalid_target": "迁移目标账户不能与来源账户相同。",
"target_not_found": "找不到迁移目标账户。",
+1
View File
@@ -744,6 +744,7 @@ export const api = rejectingSync({
state?: string;
target_user_id?: string;
confirm_factor?: string;
confirm_expires_at?: string;
code_expires_at?: string;
}>("GET", "/account/migrate"),
+8 -3
View File
@@ -1701,7 +1701,7 @@ export interface paths {
};
/**
* Report the caller's active account-migration and where it is in the flow (spec §B3 inherit, web side).
* @description Read-only. Returns the live migration whose source is the authenticated principal, if any, so the web onboarding can resume the flow: whether a confirmation step-up is still needed, which factor confirmed it, the named target, and the one-time code's expiry once issued. active:false when the caller has no live migration.
* @description Read-only. Returns the live migration whose source is the authenticated principal, if any, so the web onboarding can resume the flow: whether a confirmation step-up is still needed, which factor confirmed it and until when, the named target, and the one-time code's expiry once issued. The step-up counts only for the session that gave it and for 10 minutes, so a confirmation made in another session, one that lapsed, and a code that expired unspent all read as initiated. active:false when the caller has no live migration.
*/
get: operations["migrateStatus"];
put?: never;
@@ -1803,7 +1803,7 @@ export interface paths {
put?: never;
/**
* Name the target account and mint the one-time migration code (spec §B3 inherit).
* @description For a confirmed migration, binds the named target account and mints a single one-time code (only its hash is stored) that the target must redeem while logged in AS that target — an intercepted code is useless to anyone else. The target must exist and be neither disabled nor soft-deleted, and cannot be the source.
* @description For a migration confirmed by a step-up in this same session within the last 10 minutes, binds the named target account and mints a single one-time code (only its hash is stored) that the target must redeem while logged in AS that target — an intercepted code is useless to anyone else. The target must exist and be neither disabled nor soft-deleted, and cannot be the source. The source's verified address is sent a notice naming the target and the expiry, and another when the code is redeemed.
*/
post: operations["migrateIssueCode"];
delete?: never;
@@ -7049,6 +7049,11 @@ export interface operations {
target_user_id?: string;
/** @enum {string} */
confirm_factor?: "passkey" | "email_otp";
/**
* Format: date-time
* @description Present while state is confirmed; the code must be issued before it.
*/
confirm_expires_at?: string;
/** Format: date-time */
code_expires_at?: string;
};
@@ -7315,7 +7320,7 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description The migration has not been confirmed by a step-up yet (not_confirmed). */
/** @description No step-up from this session within the last 10 minutes, or a code is already out (not_confirmed). */
409: {
headers: {
[name: string]: unknown;
+29 -1
View File
@@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(),
passkeyDelete: vi.fn(),
listMySessions: vi.fn(),
migrateStatus: vi.fn(),
migrateIssueCode: vi.fn(),
identity: null as Identity | null,
}));
@@ -21,7 +23,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
api: {
...actual.api,
linkStatus: () => Promise.resolve({ linked: true }),
migrateStatus: () => Promise.resolve({ active: false }),
migrateStatus: mocks.migrateStatus,
migrateIssueCode: mocks.migrateIssueCode,
passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete,
listMySessions: mocks.listMySessions,
@@ -69,6 +72,9 @@ beforeEach(() => {
mocks.passkeyDelete.mockReset();
mocks.listMySessions.mockReset();
mocks.listMySessions.mockResolvedValue([thisMac]);
mocks.migrateStatus.mockReset();
mocks.migrateStatus.mockResolvedValue({ active: false });
mocks.migrateIssueCode.mockReset();
mocks.identity = identity(true);
});
@@ -178,3 +184,25 @@ describe("Account passkey delete", () => {
expect(mocks.listMySessions).toHaveBeenCalledTimes(1);
});
});
describe("Account migration", () => {
it("shows until when the confirmation holds, and asks for it again once issuing is refused", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [] });
const until = "2026-09-27T10:10:00Z";
mocks.migrateStatus
.mockResolvedValueOnce({ active: true, state: "confirmed", confirm_factor: "email_otp", confirm_expires_at: until })
.mockResolvedValue({ active: true, state: "initiated" });
mocks.migrateIssueCode.mockRejectedValue({ status: 409, code: "not_confirmed", message: "confirm first" });
renderAccount();
const deadline = t("account:migration_issue_deadline", { time: new Date(until).toLocaleTimeString() });
expect(await screen.findByText(deadline)).toBeTruthy();
await userEvent.type(screen.getByPlaceholderText(t("account:migration_target_placeholder")), "u-2");
await userEvent.click(screen.getByRole("button", { name: t("account:migration_issue_btn") }));
expect(mocks.migrateIssueCode).toHaveBeenCalledWith("u-2");
expect(await screen.findByText(t("account:migration_confirm_title"))).toBeTruthy();
expect(screen.getByText(t("errors:not_confirmed"))).toBeTruthy();
expect(screen.queryByPlaceholderText(t("account:migration_target_placeholder"))).toBeNull();
});
});
+26 -8
View File
@@ -666,10 +666,12 @@ function LinkForm({
* The flow is born in-game (/felis migrate proves the player) and driven here:
* status → step-up confirm (passkey when one is enrolled — the server 409s the
* OTP door in that case — else email-OTP) → issue-code (the source names the
* target account and reads a one-time code) → redeem (the TARGET account spends
* the code; the source's servers move over and the source is retired). Both
* roles render on every account: the redeem form is always offered, and the
* account id is always shown so a target can hand it to the source. */
* target account and reads a one-time code; the confirmation counts only in this
* browser and for 10 minutes, after which the status asks for it again) → redeem
* (the TARGET account spends the code; the source's servers move over and the
* source is retired). Both roles render on every account: the redeem form is
* always offered, and the account id is always shown so a target can hand it to
* the source. */
function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: boolean }) {
const { t } = useTranslation("account");
const mig = useAsync(() => api.migrateStatus(), []);
@@ -775,6 +777,10 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
e.preventDefault();
void run(async () => {
await api.migrateConfirmOTPVerify(otpCode.trim());
// The code is spent; a later step-up (the confirmation lapsed)
// starts from a fresh one.
setOtpSent(false);
setOtpCode("");
await mig.reload();
});
}}
@@ -795,19 +801,31 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
</div>
)}
{state === "confirmed" && !issued && (
{state === "confirmed" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
setIssued(await api.migrateIssueCode(targetId.trim()));
await mig.reload();
// Reload either way: a refusal usually means the confirmation
// lapsed, and the status then asks for the step-up again.
try {
setIssued(await api.migrateIssueCode(targetId.trim()));
} finally {
await mig.reload();
}
});
}}
>
<p className="font-medium text-foreground">{t("migration_issue_title")}</p>
<p className="text-muted-foreground">{t("migration_issue_desc")}</p>
{mig.data?.confirm_expires_at && (
<p className="text-xs text-muted-foreground">
{t("migration_issue_deadline", {
time: new Date(mig.data.confirm_expires_at).toLocaleTimeString(),
})}
</p>
)}
<div className="flex gap-2 max-w-md">
<Input
value={targetId}
@@ -823,7 +841,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
</form>
)}
{issued && (
{issued && state === "code_issued" && (
<div className="space-y-2">
<p className="font-medium text-foreground">{t("migration_code_title")}</p>
<code className="block w-fit rounded bg-muted px-3 py-2 font-mono text-base tracking-[0.2em] text-foreground select-all">