fix(migrate): 迁移确认只对做确认的会话在 10 分钟内有效,签发和兑换迁移码都给源账户邮箱发通知
This commit is contained in:
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",
|
||||
|
||||
@@ -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": "签发转移码",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"backup_unavailable": "备份功能当前不可用。",
|
||||
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
|
||||
"no_migration": "当前没有进行中的迁移。",
|
||||
"not_confirmed": "该迁移尚未完成确认。",
|
||||
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
|
||||
"already_confirmed": "该迁移已经确认过了。",
|
||||
"invalid_target": "迁移目标账户不能与来源账户相同。",
|
||||
"target_not_found": "找不到迁移目标账户。",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user