Graphical PDS migrator for AT Protocol

Compare changes

Choose any two refs to compare.

+77
components/MigrationCompletion.tsx
···
+
export interface MigrationCompletionProps {
+
isVisible: boolean;
+
}
+
+
export default function MigrationCompletion(
+
{ isVisible }: MigrationCompletionProps,
+
) {
+
if (!isVisible) return null;
+
+
const handleLogout = async () => {
+
try {
+
const response = await fetch("/api/logout", {
+
method: "POST",
+
credentials: "include",
+
});
+
if (!response.ok) {
+
throw new Error("Logout failed");
+
}
+
globalThis.location.href = "/";
+
} catch (error) {
+
console.error("Failed to logout:", error);
+
}
+
};
+
+
return (
+
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
+
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
+
Migration completed successfully! Sign out to finish the process and
+
return home.<br />
+
Please consider donating to Airport to support server and development
+
costs.
+
</p>
+
<div class="flex space-x-4">
+
<button
+
type="button"
+
onClick={handleLogout}
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
+
/>
+
</svg>
+
<span>Sign Out</span>
+
</button>
+
<a
+
href="https://ko-fi.com/knotbin"
+
target="_blank"
+
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
+
>
+
<svg
+
class="w-5 h-5"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
+
/>
+
</svg>
+
<span>Support Us</span>
+
</a>
+
</div>
+
</div>
+
);
+
}
+208
components/MigrationStep.tsx
···
+
import { IS_BROWSER } from "fresh/runtime";
+
import { ComponentChildren } from "preact";
+
+
export type StepStatus =
+
| "pending"
+
| "in-progress"
+
| "verifying"
+
| "completed"
+
| "error";
+
+
export interface MigrationStepProps {
+
name: string;
+
status: StepStatus;
+
error?: string;
+
isVerificationError?: boolean;
+
index: number;
+
onRetryVerification?: (index: number) => void;
+
children?: ComponentChildren;
+
}
+
+
export function MigrationStep({
+
name,
+
status,
+
error,
+
isVerificationError,
+
index,
+
onRetryVerification,
+
children,
+
}: MigrationStepProps) {
+
return (
+
<div key={name} class={getStepClasses(status)}>
+
{getStepIcon(status)}
+
<div class="flex-1">
+
<p
+
class={`font-medium ${
+
status === "error"
+
? "text-red-900 dark:text-red-200"
+
: status === "completed"
+
? "text-green-900 dark:text-green-200"
+
: status === "in-progress"
+
? "text-blue-900 dark:text-blue-200"
+
: "text-gray-900 dark:text-gray-200"
+
}`}
+
>
+
{getStepDisplayName(
+
{ name, status, error, isVerificationError },
+
index,
+
)}
+
</p>
+
{error && (
+
<div class="mt-1">
+
<p class="text-sm text-red-600 dark:text-red-400">
+
{(() => {
+
try {
+
const err = JSON.parse(error);
+
return err.message || error;
+
} catch {
+
return error;
+
}
+
})()}
+
</p>
+
{isVerificationError && onRetryVerification && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={() => onRetryVerification(index)}
+
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
+
disabled={!IS_BROWSER}
+
>
+
Retry Verification
+
</button>
+
</div>
+
)}
+
</div>
+
)}
+
{children}
+
</div>
+
</div>
+
);
+
}
+
+
function getStepDisplayName(
+
step: Pick<
+
MigrationStepProps,
+
"name" | "status" | "error" | "isVerificationError"
+
>,
+
index: number,
+
) {
+
if (step.status === "completed") {
+
switch (index) {
+
case 0:
+
return "Account Created";
+
case 1:
+
return "Data Migrated";
+
case 2:
+
return "Identity Migrated";
+
case 3:
+
return "Migration Finalized";
+
}
+
}
+
+
if (step.status === "in-progress") {
+
switch (index) {
+
case 0:
+
return "Creating your new account...";
+
case 1:
+
return "Migrating your data...";
+
case 2:
+
return step.name ===
+
"Enter the token sent to your email to complete identity migration"
+
? step.name
+
: "Migrating your identity...";
+
case 3:
+
return "Finalizing migration...";
+
}
+
}
+
+
if (step.status === "verifying") {
+
switch (index) {
+
case 0:
+
return "Verifying account creation...";
+
case 1:
+
return "Verifying data migration...";
+
case 2:
+
return "Verifying identity migration...";
+
case 3:
+
return "Verifying migration completion...";
+
}
+
}
+
+
return step.name;
+
}
+
+
function getStepIcon(status: StepStatus) {
+
switch (status) {
+
case "pending":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
+
</div>
+
);
+
case "in-progress":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-blue-500" />
+
</div>
+
);
+
case "verifying":
+
return (
+
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
+
<div class="w-3 h-3 rounded-full bg-yellow-500" />
+
</div>
+
);
+
case "completed":
+
return (
+
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
+
<svg
+
class="w-5 h-5 text-white"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M5 13l4 4L19 7"
+
/>
+
</svg>
+
</div>
+
);
+
case "error":
+
return (
+
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
+
<svg
+
class="w-5 h-5 text-white"
+
fill="none"
+
stroke="currentColor"
+
viewBox="0 0 24 24"
+
>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</div>
+
);
+
}
+
}
+
+
function getStepClasses(status: StepStatus) {
+
const baseClasses =
+
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
+
switch (status) {
+
case "pending":
+
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
+
case "in-progress":
+
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
+
case "verifying":
+
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
+
case "completed":
+
return `${baseClasses} bg-green-50 dark:bg-green-900`;
+
case "error":
+
return `${baseClasses} bg-red-50 dark:bg-red-900`;
+
}
+
}
+6 -7
islands/DidPlcProgress.tsx
···
error?: string;
}
+
interface KeyJson {
+
publicKeyDid: string;
+
[key: string]: unknown;
+
}
+
// Content chunks for the description
const contentChunks = [
{
···
{ name: "Complete PLC update", status: "pending" },
]);
const [generatedKey, setGeneratedKey] = useState<string>("");
-
const [keyJson, setKeyJson] = useState<any>(null);
+
const [keyJson, setKeyJson] = useState<KeyJson | null>(null);
const [emailToken, setEmailToken] = useState<string>("");
-
const [updateResult, setUpdateResult] = useState<string>("");
-
const [showDownload, setShowDownload] = useState(false);
const [hasDownloadedKey, setHasDownloadedKey] = useState(false);
const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null);
···
// Only proceed if we have a successful response
console.log("Update completed successfully!");
-
setUpdateResult("PLC update completed successfully!");
// Add a delay before marking steps as completed for better UX
updateStepStatus(2, "verifying");
···
error instanceof Error ? error.message : String(error),
);
updateStepStatus(2, "pending"); // Reset the final step
-
setUpdateResult(error instanceof Error ? error.message : String(error));
// If token is invalid, we should clear it so user can try again
if (
···
const handleGenerateKey = async () => {
console.log("=== Generate Key Debug ===");
updateStepStatus(0, "in-progress");
-
setShowDownload(false);
setKeyJson(null);
setGeneratedKey("");
setHasDownloadedKey(false);
···
setGeneratedKey(data.publicKeyDid);
setKeyJson(data);
-
setShowDownload(true);
updateStepStatus(0, "completed");
} catch (error) {
console.error("Key generation failed:", error);
+74 -787
islands/MigrationProgress.tsx
···
import { useEffect, useState } from "preact/hooks";
-
-
/**
-
* The migration state info.
-
* @type {MigrationStateInfo}
-
*/
-
interface MigrationStateInfo {
-
state: "up" | "issue" | "maintenance";
-
message: string;
-
allowMigration: boolean;
-
}
+
import { MigrationStateInfo } from "../lib/migration-types.ts";
+
import AccountCreationStep from "./migration-steps/AccountCreationStep.tsx";
+
import DataMigrationStep from "./migration-steps/DataMigrationStep.tsx";
+
import IdentityMigrationStep from "./migration-steps/IdentityMigrationStep.tsx";
+
import FinalizationStep from "./migration-steps/FinalizationStep.tsx";
+
import MigrationCompletion from "../components/MigrationCompletion.tsx";
/**
* The migration progress props.
···
invite?: string;
}
-
/**
-
* The migration step.
-
* @type {MigrationStep}
-
*/
-
interface MigrationStep {
-
name: string;
-
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
-
error?: string;
-
isVerificationError?: boolean;
-
}
-
/**
* The migration progress component.
* @param props - The migration progress props
···
* @component
*/
export default function MigrationProgress(props: MigrationProgressProps) {
-
const [token, setToken] = useState("");
const [migrationState, setMigrationState] = useState<
MigrationStateInfo | null
>(null);
-
const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
-
{},
-
);
-
const [showContinueAnyway, setShowContinueAnyway] = useState<
-
Record<number, boolean>
-
>({});
-
-
const [steps, setSteps] = useState<MigrationStep[]>([
-
{ name: "Create Account", status: "pending" },
-
{ name: "Migrate Data", status: "pending" },
-
{ name: "Migrate Identity", status: "pending" },
-
{ name: "Finalize Migration", status: "pending" },
-
]);
-
-
const updateStepStatus = (
-
index: number,
-
status: MigrationStep["status"],
-
error?: string,
-
isVerificationError?: boolean,
-
) => {
-
console.log(
-
`Updating step ${index} to ${status}${
-
error ? ` with error: ${error}` : ""
-
}`,
-
);
-
setSteps((prevSteps) =>
-
prevSteps.map((step, i) =>
-
i === index
-
? { ...step, status, error, isVerificationError }
-
: i > index
-
? {
-
...step,
-
status: "pending",
-
error: undefined,
-
isVerificationError: undefined,
-
}
-
: step
-
)
-
);
+
const [currentStep, setCurrentStep] = useState(0);
+
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
+
const [hasError, setHasError] = useState(false);
+
+
const credentials = {
+
service: props.service,
+
handle: props.handle,
+
email: props.email,
+
password: props.password,
+
invite: props.invite,
};
const validateParams = () => {
if (!props.service?.trim()) {
-
updateStepStatus(0, "error", "Missing service URL");
+
setHasError(true);
return false;
}
if (!props.handle?.trim()) {
-
updateStepStatus(0, "error", "Missing handle");
+
setHasError(true);
return false;
}
if (!props.email?.trim()) {
-
updateStepStatus(0, "error", "Missing email");
+
setHasError(true);
return false;
}
if (!props.password?.trim()) {
-
updateStepStatus(0, "error", "Missing password");
+
setHasError(true);
return false;
}
return true;
···
setMigrationState(migrationData);
if (!migrationData.allowMigration) {
-
updateStepStatus(0, "error", migrationData.message);
+
setHasError(true);
return;
}
}
} catch (error) {
console.error("Failed to check migration state:", error);
-
updateStepStatus(0, "error", "Unable to verify migration availability");
+
setHasError(true);
return;
}
···
return;
}
-
startMigration().catch((error) => {
-
console.error("Unhandled migration error:", error);
-
updateStepStatus(
-
0,
-
"error",
-
error.message || "Unknown error occurred",
-
);
-
});
+
// Start with the first step
+
setCurrentStep(0);
};
checkMigrationState();
}, []);
-
const getStepDisplayName = (step: MigrationStep, index: number) => {
-
if (step.status === "completed") {
-
switch (index) {
-
case 0:
-
return "Account Created";
-
case 1:
-
return "Data Migrated";
-
case 2:
-
return "Identity Migrated";
-
case 3:
-
return "Migration Finalized";
-
}
-
}
-
-
if (step.status === "in-progress") {
-
switch (index) {
-
case 0:
-
return "Creating your new account...";
-
case 1:
-
return "Migrating your data...";
-
case 2:
-
return step.name ===
-
"Enter the token sent to your email to complete identity migration"
-
? step.name
-
: "Migrating your identity...";
-
case 3:
-
return "Finalizing migration...";
-
}
-
}
-
-
if (step.status === "verifying") {
-
switch (index) {
-
case 0:
-
return "Verifying account creation...";
-
case 1:
-
return "Verifying data migration...";
-
case 2:
-
return "Verifying identity migration...";
-
case 3:
-
return "Verifying migration completion...";
-
}
-
}
-
-
return step.name;
-
};
-
-
const startMigration = async () => {
-
try {
-
// Step 1: Create Account
-
updateStepStatus(0, "in-progress");
-
console.log("Starting account creation...");
-
-
try {
-
const createRes = await fetch("/api/migrate/create", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({
-
service: props.service,
-
handle: props.handle,
-
password: props.password,
-
email: props.email,
-
...(props.invite ? { invite: props.invite } : {}),
-
}),
-
});
-
-
console.log("Create account response status:", createRes.status);
-
const responseText = await createRes.text();
-
console.log("Create account response:", responseText);
-
-
if (!createRes.ok) {
-
try {
-
const json = JSON.parse(responseText);
-
throw new Error(json.message || "Failed to create account");
-
} catch {
-
throw new Error(responseText || "Failed to create account");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(responseText);
-
if (!jsonData.success) {
-
throw new Error(jsonData.message || "Account creation failed");
-
}
-
} catch (e) {
-
console.log("Response is not JSON or lacks success field:", e);
-
}
-
-
updateStepStatus(0, "verifying");
-
const verified = await verifyStep(0);
-
if (!verified) {
-
console.log(
-
"Account creation: Verification failed, waiting for user action",
-
);
-
return;
-
}
-
-
// If verification succeeds, continue to data migration
-
await startDataMigration();
-
} catch (error) {
-
updateStepStatus(
-
0,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
} catch (error) {
-
console.error("Migration error in try/catch:", error);
-
}
-
};
-
-
const handleIdentityMigration = async () => {
-
if (!token) return;
-
-
try {
-
const identityRes = await fetch(
-
`/api/migrate/identity/sign?token=${encodeURIComponent(token)}`,
-
{
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
},
-
);
-
-
const identityData = await identityRes.text();
-
if (!identityRes.ok) {
-
try {
-
const json = JSON.parse(identityData);
-
throw new Error(
-
json.message || "Failed to complete identity migration",
-
);
-
} catch {
-
throw new Error(
-
identityData || "Failed to complete identity migration",
-
);
-
}
-
}
-
-
let data;
-
try {
-
data = JSON.parse(identityData);
-
if (!data.success) {
-
throw new Error(data.message || "Identity migration failed");
-
}
-
} catch {
-
throw new Error("Invalid response from server");
-
}
-
-
updateStepStatus(2, "verifying");
-
const verified = await verifyStep(2);
-
if (!verified) {
-
console.log(
-
"Identity migration: Verification failed, waiting for user action",
-
);
-
return;
-
}
-
-
// If verification succeeds, continue to finalization
-
await startFinalization();
-
} catch (error) {
-
console.error("Identity migration error:", error);
-
updateStepStatus(
-
2,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
}
-
};
-
-
const getStepIcon = (status: MigrationStep["status"]) => {
-
switch (status) {
-
case "pending":
-
return (
-
<div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
-
<div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
-
</div>
-
);
-
case "in-progress":
-
return (
-
<div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
-
<div class="w-3 h-3 rounded-full bg-blue-500" />
-
</div>
-
);
-
case "verifying":
-
return (
-
<div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
-
<div class="w-3 h-3 rounded-full bg-yellow-500" />
-
</div>
-
);
-
case "completed":
-
return (
-
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
-
<svg
-
class="w-5 h-5 text-white"
-
fill="none"
-
stroke="currentColor"
-
viewBox="0 0 24 24"
-
>
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2"
-
d="M5 13l4 4L19 7"
-
/>
-
</svg>
-
</div>
-
);
-
case "error":
-
return (
-
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
-
<svg
-
class="w-5 h-5 text-white"
-
fill="none"
-
stroke="currentColor"
-
viewBox="0 0 24 24"
-
>
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2"
-
d="M6 18L18 6M6 6l12 12"
-
/>
-
</svg>
-
</div>
-
);
-
}
-
};
-
-
const getStepClasses = (status: MigrationStep["status"]) => {
-
const baseClasses =
-
"flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
-
switch (status) {
-
case "pending":
-
return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
-
case "in-progress":
-
return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
-
case "verifying":
-
return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
-
case "completed":
-
return `${baseClasses} bg-green-50 dark:bg-green-900`;
-
case "error":
-
return `${baseClasses} bg-red-50 dark:bg-red-900`;
-
}
-
};
-
-
// Helper to verify a step after completion
-
const verifyStep = async (stepNum: number) => {
-
console.log(`Verification: Starting step ${stepNum + 1}`);
-
updateStepStatus(stepNum, "verifying");
-
try {
-
console.log(`Verification: Fetching status for step ${stepNum + 1}`);
-
const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
-
console.log(`Verification: Status response status:`, res.status);
-
const data = await res.json();
-
console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
-
-
if (data.ready) {
-
console.log(`Verification: Step ${stepNum + 1} is ready`);
-
updateStepStatus(stepNum, "completed");
-
// Reset retry state on success
-
setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
-
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
+
const handleStepComplete = (stepIndex: number) => {
+
console.log(`Step ${stepIndex} completed`);
+
setCompletedSteps((prev) => new Set([...prev, stepIndex]));
-
// Continue to next step if not the last one
-
if (stepNum < 3) {
-
setTimeout(() => continueToNextStep(stepNum + 1), 500);
-
}
-
-
return true;
-
} else {
-
console.log(
-
`Verification: Step ${stepNum + 1} is not ready:`,
-
data.reason,
-
);
-
const statusDetails = {
-
activated: data.activated,
-
validDid: data.validDid,
-
repoCommit: data.repoCommit,
-
repoRev: data.repoRev,
-
repoBlocks: data.repoBlocks,
-
expectedRecords: data.expectedRecords,
-
indexedRecords: data.indexedRecords,
-
privateStateValues: data.privateStateValues,
-
expectedBlobs: data.expectedBlobs,
-
importedBlobs: data.importedBlobs,
-
};
-
console.log(
-
`Verification: Step ${stepNum + 1} status details:`,
-
statusDetails,
-
);
-
const errorMessage = `${
-
data.reason || "Verification failed"
-
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
-
-
// Track retry attempts
-
const currentAttempts = retryAttempts[stepNum] || 0;
-
setRetryAttempts((prev) => ({
-
...prev,
-
[stepNum]: currentAttempts + 1,
-
}));
-
-
// Show continue anyway option if this is the second failure
-
if (currentAttempts >= 1) {
-
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
-
}
-
-
updateStepStatus(stepNum, "error", errorMessage, true);
-
return false;
-
}
-
} catch (e) {
-
console.error(`Verification: Error in step ${stepNum + 1}:`, e);
-
const currentAttempts = retryAttempts[stepNum] || 0;
-
setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
-
-
// Show continue anyway option if this is the second failure
-
if (currentAttempts >= 1) {
-
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
-
}
-
-
updateStepStatus(
-
stepNum,
-
"error",
-
e instanceof Error ? e.message : String(e),
-
true,
-
);
-
return false;
+
// Move to next step if not the last one
+
if (stepIndex < 3) {
+
setCurrentStep(stepIndex + 1);
}
};
-
const retryVerification = async (stepNum: number) => {
-
console.log(`Retrying verification for step ${stepNum + 1}`);
-
await verifyStep(stepNum);
+
const handleStepError = (
+
stepIndex: number,
+
error: string,
+
isVerificationError?: boolean,
+
) => {
+
console.error(`Step ${stepIndex} error:`, error, { isVerificationError });
+
// Errors are handled within each step component
};
-
const continueAnyway = (stepNum: number) => {
-
console.log(`Continuing anyway for step ${stepNum + 1}`);
-
updateStepStatus(stepNum, "completed");
-
setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
-
-
// Continue with next step if not the last one
-
if (stepNum < 3) {
-
continueToNextStep(stepNum + 1);
-
}
+
const isStepActive = (stepIndex: number) => {
+
return currentStep === stepIndex && !hasError;
};
-
const continueToNextStep = async (stepNum: number) => {
-
switch (stepNum) {
-
case 1:
-
// Continue to data migration
-
await startDataMigration();
-
break;
-
case 2:
-
// Continue to identity migration
-
await startIdentityMigration();
-
break;
-
case 3:
-
// Continue to finalization
-
await startFinalization();
-
break;
-
}
+
const _isStepCompleted = (stepIndex: number) => {
+
return completedSteps.has(stepIndex);
};
-
const startDataMigration = async () => {
-
// Step 2: Migrate Data
-
updateStepStatus(1, "in-progress");
-
console.log("Starting data migration...");
-
-
try {
-
// Step 2.1: Migrate Repo
-
console.log("Data migration: Starting repo migration");
-
const repoRes = await fetch("/api/migrate/data/repo", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Repo migration: Response status:", repoRes.status);
-
const repoText = await repoRes.text();
-
console.log("Repo migration: Raw response:", repoText);
-
-
if (!repoRes.ok) {
-
try {
-
const json = JSON.parse(repoText);
-
console.error("Repo migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate repo");
-
} catch {
-
console.error("Repo migration: Non-JSON error response:", repoText);
-
throw new Error(repoText || "Failed to migrate repo");
-
}
-
}
-
-
// Step 2.2: Migrate Blobs
-
console.log("Data migration: Starting blob migration");
-
const blobsRes = await fetch("/api/migrate/data/blobs", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Blob migration: Response status:", blobsRes.status);
-
const blobsText = await blobsRes.text();
-
console.log("Blob migration: Raw response:", blobsText);
-
-
if (!blobsRes.ok) {
-
try {
-
const json = JSON.parse(blobsText);
-
console.error("Blob migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate blobs");
-
} catch {
-
console.error(
-
"Blob migration: Non-JSON error response:",
-
blobsText,
-
);
-
throw new Error(blobsText || "Failed to migrate blobs");
-
}
-
}
-
-
// Step 2.3: Migrate Preferences
-
console.log("Data migration: Starting preferences migration");
-
const prefsRes = await fetch("/api/migrate/data/prefs", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Preferences migration: Response status:", prefsRes.status);
-
const prefsText = await prefsRes.text();
-
console.log("Preferences migration: Raw response:", prefsText);
-
-
if (!prefsRes.ok) {
-
try {
-
const json = JSON.parse(prefsText);
-
console.error("Preferences migration: Error response:", json);
-
throw new Error(json.message || "Failed to migrate preferences");
-
} catch {
-
console.error(
-
"Preferences migration: Non-JSON error response:",
-
prefsText,
-
);
-
throw new Error(prefsText || "Failed to migrate preferences");
-
}
-
}
-
-
console.log("Data migration: Starting verification");
-
updateStepStatus(1, "verifying");
-
const verified = await verifyStep(1);
-
console.log("Data migration: Verification result:", verified);
-
if (!verified) {
-
console.log(
-
"Data migration: Verification failed, waiting for user action",
-
);
-
return;
-
}
-
-
// If verification succeeds, continue to next step
-
await startIdentityMigration();
-
} catch (error) {
-
console.error("Data migration: Error caught:", error);
-
updateStepStatus(
-
1,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
};
-
-
const startIdentityMigration = async () => {
-
// Step 3: Request Identity Migration
-
updateStepStatus(2, "in-progress");
-
console.log("Requesting identity migration...");
-
-
try {
-
const requestRes = await fetch("/api/migrate/identity/request", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
console.log("Identity request response status:", requestRes.status);
-
const requestText = await requestRes.text();
-
console.log("Identity request response:", requestText);
-
-
if (!requestRes.ok) {
-
try {
-
const json = JSON.parse(requestText);
-
throw new Error(
-
json.message || "Failed to request identity migration",
-
);
-
} catch {
-
throw new Error(
-
requestText || "Failed to request identity migration",
-
);
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(requestText);
-
if (!jsonData.success) {
-
throw new Error(
-
jsonData.message || "Identity migration request failed",
-
);
-
}
-
console.log("Identity migration requested successfully");
-
-
// Update step name to prompt for token
-
setSteps((prevSteps) =>
-
prevSteps.map((step, i) =>
-
i === 2
-
? {
-
...step,
-
name:
-
"Enter the token sent to your email to complete identity migration",
-
}
-
: step
-
)
-
);
-
// Don't continue with migration - wait for token input
-
return;
-
} catch (e) {
-
console.error("Failed to parse identity request response:", e);
-
throw new Error(
-
"Invalid response from server during identity request",
-
);
-
}
-
} catch (error) {
-
updateStepStatus(
-
2,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
};
-
-
const startFinalization = async () => {
-
// Step 4: Finalize Migration
-
updateStepStatus(3, "in-progress");
-
try {
-
const finalizeRes = await fetch("/api/migrate/finalize", {
-
method: "POST",
-
headers: { "Content-Type": "application/json" },
-
});
-
-
const finalizeData = await finalizeRes.text();
-
if (!finalizeRes.ok) {
-
try {
-
const json = JSON.parse(finalizeData);
-
throw new Error(json.message || "Failed to finalize migration");
-
} catch {
-
throw new Error(finalizeData || "Failed to finalize migration");
-
}
-
}
-
-
try {
-
const jsonData = JSON.parse(finalizeData);
-
if (!jsonData.success) {
-
throw new Error(jsonData.message || "Finalization failed");
-
}
-
} catch {
-
throw new Error("Invalid response from server during finalization");
-
}
-
-
updateStepStatus(3, "verifying");
-
const verified = await verifyStep(3);
-
if (!verified) {
-
console.log(
-
"Finalization: Verification failed, waiting for user action",
-
);
-
return;
-
}
-
} catch (error) {
-
updateStepStatus(
-
3,
-
"error",
-
error instanceof Error ? error.message : String(error),
-
);
-
throw error;
-
}
-
};
+
const allStepsCompleted = completedSteps.size === 4;
return (
<div class="space-y-8">
···
)}
<div class="space-y-4">
-
{steps.map((step, index) => (
-
<div key={step.name} class={getStepClasses(step.status)}>
-
{getStepIcon(step.status)}
-
<div class="flex-1">
-
<p
-
class={`font-medium ${
-
step.status === "error"
-
? "text-red-900 dark:text-red-200"
-
: step.status === "completed"
-
? "text-green-900 dark:text-green-200"
-
: step.status === "in-progress"
-
? "text-blue-900 dark:text-blue-200"
-
: "text-gray-900 dark:text-gray-200"
-
}`}
-
>
-
{getStepDisplayName(step, index)}
-
</p>
-
{step.error && (
-
<div class="mt-1">
-
<p class="text-sm text-red-600 dark:text-red-400">
-
{(() => {
-
try {
-
const err = JSON.parse(step.error);
-
return err.message || step.error;
-
} catch {
-
return step.error;
-
}
-
})()}
-
</p>
-
{step.isVerificationError && (
-
<div class="flex space-x-2 mt-2">
-
<button
-
type="button"
-
onClick={() => retryVerification(index)}
-
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
-
>
-
Retry Verification
-
</button>
-
{showContinueAnyway[index] && (
-
<button
-
type="button"
-
onClick={() => continueAnyway(index)}
-
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
-
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
-
>
-
Continue Anyway
-
</button>
-
)}
-
</div>
-
)}
-
</div>
-
)}
-
{index === 2 && step.status === "in-progress" &&
-
step.name ===
-
"Enter the token sent to your email to complete identity migration" &&
-
(
-
<div class="mt-4 space-y-4">
-
<p class="text-sm text-blue-800 dark:text-blue-200">
-
Please check your email for the migration token and enter
-
it below:
-
</p>
-
<div class="flex space-x-2">
-
<input
-
type="text"
-
value={token}
-
onChange={(e) => setToken(e.currentTarget.value)}
-
placeholder="Enter token"
-
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
-
/>
-
<button
-
type="button"
-
onClick={handleIdentityMigration}
-
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
-
>
-
Submit Token
-
</button>
-
</div>
-
</div>
-
)}
-
</div>
-
</div>
-
))}
+
<AccountCreationStep
+
credentials={credentials}
+
onStepComplete={() => handleStepComplete(0)}
+
onStepError={(error, isVerificationError) =>
+
handleStepError(0, error, isVerificationError)}
+
isActive={isStepActive(0)}
+
/>
+
+
<DataMigrationStep
+
credentials={credentials}
+
onStepComplete={() => handleStepComplete(1)}
+
onStepError={(error, isVerificationError) =>
+
handleStepError(1, error, isVerificationError)}
+
isActive={isStepActive(1)}
+
/>
+
+
<IdentityMigrationStep
+
credentials={credentials}
+
onStepComplete={() => handleStepComplete(2)}
+
onStepError={(error, isVerificationError) =>
+
handleStepError(2, error, isVerificationError)}
+
isActive={isStepActive(2)}
+
/>
+
+
<FinalizationStep
+
credentials={credentials}
+
onStepComplete={() => handleStepComplete(3)}
+
onStepError={(error, isVerificationError) =>
+
handleStepError(3, error, isVerificationError)}
+
isActive={isStepActive(3)}
+
/>
</div>
-
{steps[3].status === "completed" && (
-
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
-
<p class="text-sm text-green-800 dark:text-green-200 pb-2">
-
Migration completed successfully! Sign out to finish the process and
-
return home.<br />
-
Please consider donating to Airport to support server and
-
development costs.
-
</p>
-
<div class="flex space-x-4">
-
<button
-
type="button"
-
onClick={async () => {
-
try {
-
const response = await fetch("/api/logout", {
-
method: "POST",
-
credentials: "include",
-
});
-
if (!response.ok) {
-
throw new Error("Logout failed");
-
}
-
globalThis.location.href = "/";
-
} catch (error) {
-
console.error("Failed to logout:", error);
-
}
-
}}
-
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
-
>
-
<svg
-
class="w-5 h-5"
-
fill="none"
-
stroke="currentColor"
-
viewBox="0 0 24 24"
-
>
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2"
-
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
-
/>
-
</svg>
-
<span>Sign Out</span>
-
</button>
-
<a
-
href="https://ko-fi.com/knotbin"
-
target="_blank"
-
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
-
>
-
<svg
-
class="w-5 h-5"
-
fill="none"
-
stroke="currentColor"
-
viewBox="0 0 24 24"
-
>
-
<path
-
stroke-linecap="round"
-
stroke-linejoin="round"
-
stroke-width="2"
-
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
-
/>
-
</svg>
-
<span>Support Us</span>
-
</a>
-
</div>
-
</div>
-
)}
+
<MigrationCompletion isVisible={allStepsCompleted} />
</div>
);
}
+151
islands/migration-steps/AccountCreationStep.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import { MigrationStep } from "../../components/MigrationStep.tsx";
+
import {
+
parseApiResponse,
+
StepCommonProps,
+
verifyMigrationStep,
+
} from "../../lib/migration-types.ts";
+
+
interface AccountCreationStepProps extends StepCommonProps {
+
isActive: boolean;
+
}
+
+
export default function AccountCreationStep({
+
credentials,
+
onStepComplete,
+
onStepError,
+
isActive,
+
}: AccountCreationStepProps) {
+
const [status, setStatus] = useState<
+
"pending" | "in-progress" | "verifying" | "completed" | "error"
+
>("pending");
+
const [error, setError] = useState<string>();
+
const [retryCount, setRetryCount] = useState(0);
+
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
+
+
useEffect(() => {
+
if (isActive && status === "pending") {
+
startAccountCreation();
+
}
+
}, [isActive]);
+
+
const startAccountCreation = async () => {
+
setStatus("in-progress");
+
setError(undefined);
+
+
try {
+
const createRes = await fetch("/api/migrate/create", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
service: credentials.service,
+
handle: credentials.handle,
+
password: credentials.password,
+
email: credentials.email,
+
...(credentials.invite ? { invite: credentials.invite } : {}),
+
}),
+
});
+
+
const responseText = await createRes.text();
+
+
if (!createRes.ok) {
+
const parsed = parseApiResponse(responseText);
+
throw new Error(parsed.message || "Failed to create account");
+
}
+
+
const parsed = parseApiResponse(responseText);
+
if (!parsed.success) {
+
throw new Error(parsed.message || "Account creation failed");
+
}
+
+
// Verify the account creation
+
await verifyAccountCreation();
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage);
+
}
+
};
+
+
const verifyAccountCreation = async () => {
+
setStatus("verifying");
+
+
try {
+
const result = await verifyMigrationStep(1);
+
+
if (result.ready) {
+
setStatus("completed");
+
setRetryCount(0);
+
setShowContinueAnyway(false);
+
onStepComplete();
+
} else {
+
const statusDetails = {
+
activated: result.activated,
+
validDid: result.validDid,
+
};
+
const errorMessage = `${
+
result.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
};
+
+
const retryVerification = async () => {
+
await verifyAccountCreation();
+
};
+
+
const continueAnyway = () => {
+
setStatus("completed");
+
setShowContinueAnyway(false);
+
onStepComplete();
+
};
+
+
return (
+
<MigrationStep
+
name="Create Account"
+
status={status}
+
error={error}
+
isVerificationError={status === "error" &&
+
error?.includes("Verification failed")}
+
index={0}
+
onRetryVerification={retryVerification}
+
>
+
{status === "error" && showContinueAnyway && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={continueAnyway}
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
</div>
+
)}
+
</MigrationStep>
+
);
+
}
+172
islands/migration-steps/DataMigrationStep.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import { MigrationStep } from "../../components/MigrationStep.tsx";
+
import {
+
parseApiResponse,
+
StepCommonProps,
+
verifyMigrationStep,
+
} from "../../lib/migration-types.ts";
+
+
interface DataMigrationStepProps extends StepCommonProps {
+
isActive: boolean;
+
}
+
+
export default function DataMigrationStep({
+
credentials: _credentials,
+
onStepComplete,
+
onStepError,
+
isActive,
+
}: DataMigrationStepProps) {
+
const [status, setStatus] = useState<
+
"pending" | "in-progress" | "verifying" | "completed" | "error"
+
>("pending");
+
const [error, setError] = useState<string>();
+
const [retryCount, setRetryCount] = useState(0);
+
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
+
+
useEffect(() => {
+
if (isActive && status === "pending") {
+
startDataMigration();
+
}
+
}, [isActive]);
+
+
const startDataMigration = async () => {
+
setStatus("in-progress");
+
setError(undefined);
+
+
try {
+
// Step 1: Migrate Repo
+
const repoRes = await fetch("/api/migrate/data/repo", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const repoText = await repoRes.text();
+
+
if (!repoRes.ok) {
+
const parsed = parseApiResponse(repoText);
+
throw new Error(parsed.message || "Failed to migrate repo");
+
}
+
+
// Step 2: Migrate Blobs
+
const blobsRes = await fetch("/api/migrate/data/blobs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const blobsText = await blobsRes.text();
+
+
if (!blobsRes.ok) {
+
const parsed = parseApiResponse(blobsText);
+
throw new Error(parsed.message || "Failed to migrate blobs");
+
}
+
+
// Step 3: Migrate Preferences
+
const prefsRes = await fetch("/api/migrate/data/prefs", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const prefsText = await prefsRes.text();
+
+
if (!prefsRes.ok) {
+
const parsed = parseApiResponse(prefsText);
+
throw new Error(parsed.message || "Failed to migrate preferences");
+
}
+
+
// Verify the data migration
+
await verifyDataMigration();
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage);
+
}
+
};
+
+
const verifyDataMigration = async () => {
+
setStatus("verifying");
+
+
try {
+
const result = await verifyMigrationStep(2);
+
+
if (result.ready) {
+
setStatus("completed");
+
setRetryCount(0);
+
setShowContinueAnyway(false);
+
onStepComplete();
+
} else {
+
const statusDetails = {
+
repoCommit: result.repoCommit,
+
repoRev: result.repoRev,
+
repoBlocks: result.repoBlocks,
+
expectedRecords: result.expectedRecords,
+
indexedRecords: result.indexedRecords,
+
privateStateValues: result.privateStateValues,
+
expectedBlobs: result.expectedBlobs,
+
importedBlobs: result.importedBlobs,
+
};
+
const errorMessage = `${
+
result.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
};
+
+
const retryVerification = async () => {
+
await verifyDataMigration();
+
};
+
+
const continueAnyway = () => {
+
setStatus("completed");
+
setShowContinueAnyway(false);
+
onStepComplete();
+
};
+
+
return (
+
<MigrationStep
+
name="Migrate Data"
+
status={status}
+
error={error}
+
isVerificationError={status === "error" &&
+
error?.includes("Verification failed")}
+
index={1}
+
onRetryVerification={retryVerification}
+
>
+
{status === "error" && showContinueAnyway && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={continueAnyway}
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
</div>
+
)}
+
</MigrationStep>
+
);
+
}
+143
islands/migration-steps/FinalizationStep.tsx
···
+
import { useEffect, useState } from "preact/hooks";
+
import { MigrationStep } from "../../components/MigrationStep.tsx";
+
import {
+
parseApiResponse,
+
StepCommonProps,
+
verifyMigrationStep,
+
} from "../../lib/migration-types.ts";
+
+
interface FinalizationStepProps extends StepCommonProps {
+
isActive: boolean;
+
}
+
+
export default function FinalizationStep({
+
credentials: _credentials,
+
onStepComplete,
+
onStepError,
+
isActive,
+
}: FinalizationStepProps) {
+
const [status, setStatus] = useState<
+
"pending" | "in-progress" | "verifying" | "completed" | "error"
+
>("pending");
+
const [error, setError] = useState<string>();
+
const [retryCount, setRetryCount] = useState(0);
+
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
+
+
useEffect(() => {
+
if (isActive && status === "pending") {
+
startFinalization();
+
}
+
}, [isActive]);
+
+
const startFinalization = async () => {
+
setStatus("in-progress");
+
setError(undefined);
+
+
try {
+
const finalizeRes = await fetch("/api/migrate/finalize", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const finalizeData = await finalizeRes.text();
+
if (!finalizeRes.ok) {
+
const parsed = parseApiResponse(finalizeData);
+
throw new Error(parsed.message || "Failed to finalize migration");
+
}
+
+
const parsed = parseApiResponse(finalizeData);
+
if (!parsed.success) {
+
throw new Error(parsed.message || "Finalization failed");
+
}
+
+
// Verify the finalization
+
await verifyFinalization();
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage);
+
}
+
};
+
+
const verifyFinalization = async () => {
+
setStatus("verifying");
+
+
try {
+
const result = await verifyMigrationStep(4);
+
+
if (result.ready) {
+
setStatus("completed");
+
setRetryCount(0);
+
setShowContinueAnyway(false);
+
onStepComplete();
+
} else {
+
const statusDetails = {
+
activated: result.activated,
+
validDid: result.validDid,
+
};
+
const errorMessage = `${
+
result.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
};
+
+
const retryVerification = async () => {
+
await verifyFinalization();
+
};
+
+
const continueAnyway = () => {
+
setStatus("completed");
+
setShowContinueAnyway(false);
+
onStepComplete();
+
};
+
+
return (
+
<MigrationStep
+
name="Finalize Migration"
+
status={status}
+
error={error}
+
isVerificationError={status === "error" &&
+
error?.includes("Verification failed")}
+
index={3}
+
onRetryVerification={retryVerification}
+
>
+
{status === "error" && showContinueAnyway && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={continueAnyway}
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
</div>
+
)}
+
</MigrationStep>
+
);
+
}
+294
islands/migration-steps/IdentityMigrationStep.tsx
···
+
import { useEffect, useRef, useState } from "preact/hooks";
+
import { MigrationStep } from "../../components/MigrationStep.tsx";
+
import {
+
parseApiResponse,
+
StepCommonProps,
+
verifyMigrationStep,
+
} from "../../lib/migration-types.ts";
+
+
interface IdentityMigrationStepProps extends StepCommonProps {
+
isActive: boolean;
+
}
+
+
export default function IdentityMigrationStep({
+
credentials: _credentials,
+
onStepComplete,
+
onStepError,
+
isActive,
+
}: IdentityMigrationStepProps) {
+
const [status, setStatus] = useState<
+
"pending" | "in-progress" | "verifying" | "completed" | "error"
+
>("pending");
+
const [error, setError] = useState<string>();
+
const [retryCount, setRetryCount] = useState(0);
+
const [showContinueAnyway, setShowContinueAnyway] = useState(false);
+
const [token, setToken] = useState("");
+
const [identityRequestSent, setIdentityRequestSent] = useState(false);
+
const [identityRequestCooldown, setIdentityRequestCooldown] = useState(0);
+
const [cooldownInterval, setCooldownInterval] = useState<number | null>(null);
+
const [stepName, setStepName] = useState("Migrate Identity");
+
const identityRequestInProgressRef = useRef(false);
+
+
// Clean up interval on unmount
+
useEffect(() => {
+
return () => {
+
if (cooldownInterval !== null) {
+
clearInterval(cooldownInterval);
+
}
+
};
+
}, [cooldownInterval]);
+
+
useEffect(() => {
+
if (isActive && status === "pending") {
+
startIdentityMigration();
+
}
+
}, [isActive]);
+
+
const startIdentityMigration = async () => {
+
// Prevent multiple concurrent calls
+
if (identityRequestInProgressRef.current) {
+
return;
+
}
+
+
identityRequestInProgressRef.current = true;
+
setStatus("in-progress");
+
setError(undefined);
+
+
// Don't send duplicate requests
+
if (identityRequestSent) {
+
setStepName(
+
"Enter the token sent to your email to complete identity migration",
+
);
+
setTimeout(() => {
+
identityRequestInProgressRef.current = false;
+
}, 1000);
+
return;
+
}
+
+
try {
+
const requestRes = await fetch("/api/migrate/identity/request", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
});
+
+
const requestText = await requestRes.text();
+
+
if (!requestRes.ok) {
+
const parsed = parseApiResponse(requestText);
+
throw new Error(
+
parsed.message || "Failed to request identity migration",
+
);
+
}
+
+
const parsed = parseApiResponse(requestText);
+
if (!parsed.success) {
+
throw new Error(parsed.message || "Identity migration request failed");
+
}
+
+
// Mark request as sent
+
setIdentityRequestSent(true);
+
+
// Handle rate limiting
+
const jsonData = JSON.parse(requestText);
+
if (jsonData.rateLimited && jsonData.cooldownRemaining) {
+
setIdentityRequestCooldown(jsonData.cooldownRemaining);
+
+
// Clear any existing interval
+
if (cooldownInterval !== null) {
+
clearInterval(cooldownInterval);
+
}
+
+
// Set up countdown timer
+
const intervalId = setInterval(() => {
+
setIdentityRequestCooldown((prev) => {
+
if (prev <= 1) {
+
clearInterval(intervalId);
+
setCooldownInterval(null);
+
return 0;
+
}
+
return prev - 1;
+
});
+
}, 1000);
+
+
setCooldownInterval(intervalId);
+
}
+
+
// Update step name to prompt for token
+
setStepName(
+
identityRequestCooldown > 0
+
? `Please wait ${identityRequestCooldown}s before requesting another code`
+
: "Enter the token sent to your email to complete identity migration",
+
);
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
// Don't mark as error if it was due to rate limiting
+
if (identityRequestCooldown > 0) {
+
setStatus("in-progress");
+
} else {
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage);
+
}
+
} finally {
+
setTimeout(() => {
+
identityRequestInProgressRef.current = false;
+
}, 1000);
+
}
+
};
+
+
const handleIdentityMigration = async () => {
+
if (!token) return;
+
+
try {
+
const identityRes = await fetch(
+
`/api/migrate/identity/sign?token=${encodeURIComponent(token)}`,
+
{
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
},
+
);
+
+
const identityData = await identityRes.text();
+
if (!identityRes.ok) {
+
const parsed = parseApiResponse(identityData);
+
throw new Error(
+
parsed.message || "Failed to complete identity migration",
+
);
+
}
+
+
const parsed = parseApiResponse(identityData);
+
if (!parsed.success) {
+
throw new Error(parsed.message || "Identity migration failed");
+
}
+
+
// Verify the identity migration
+
await verifyIdentityMigration();
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage);
+
}
+
};
+
+
const verifyIdentityMigration = async () => {
+
setStatus("verifying");
+
+
try {
+
const result = await verifyMigrationStep(3);
+
+
if (result.ready) {
+
setStatus("completed");
+
setRetryCount(0);
+
setShowContinueAnyway(false);
+
onStepComplete();
+
} else {
+
const statusDetails = {
+
activated: result.activated,
+
validDid: result.validDid,
+
};
+
const errorMessage = `${
+
result.reason || "Verification failed"
+
}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
+
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
} catch (error) {
+
const errorMessage = error instanceof Error
+
? error.message
+
: String(error);
+
setRetryCount((prev) => prev + 1);
+
if (retryCount >= 1) {
+
setShowContinueAnyway(true);
+
}
+
+
setError(errorMessage);
+
setStatus("error");
+
onStepError(errorMessage, true);
+
}
+
};
+
+
const retryVerification = async () => {
+
await verifyIdentityMigration();
+
};
+
+
const continueAnyway = () => {
+
setStatus("completed");
+
setShowContinueAnyway(false);
+
onStepComplete();
+
};
+
+
return (
+
<MigrationStep
+
name={stepName}
+
status={status}
+
error={error}
+
isVerificationError={status === "error" &&
+
error?.includes("Verification failed")}
+
index={2}
+
onRetryVerification={retryVerification}
+
>
+
{status === "error" && showContinueAnyway && (
+
<div class="flex space-x-2 mt-2">
+
<button
+
type="button"
+
onClick={continueAnyway}
+
class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
+
dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
+
>
+
Continue Anyway
+
</button>
+
</div>
+
)}
+
+
{(status === "in-progress" || identityRequestSent) &&
+
stepName.includes("Enter the token sent to your email") &&
+
(identityRequestCooldown > 0
+
? (
+
<div class="mt-4">
+
<p class="text-sm text-amber-600 dark:text-amber-400">
+
<span class="font-medium">Rate limit:</span> Please wait{" "}
+
{identityRequestCooldown}{" "}
+
seconds before requesting another code. Check your email inbox
+
and spam folder for a previously sent code.
+
</p>
+
</div>
+
)
+
: (
+
<div class="mt-4 space-y-4">
+
<p class="text-sm text-blue-800 dark:text-blue-200">
+
Please check your email for the migration token and enter it
+
below:
+
</p>
+
<div class="flex space-x-2">
+
<input
+
type="text"
+
value={token}
+
onChange={(e) => setToken(e.currentTarget.value)}
+
placeholder="Enter token"
+
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
+
/>
+
<button
+
type="button"
+
onClick={handleIdentityMigration}
+
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
+
>
+
Submit Token
+
</button>
+
</div>
+
</div>
+
))}
+
</MigrationStep>
+
);
+
}
+63
lib/migration-types.ts
···
+
/**
+
* Shared types for migration components
+
*/
+
+
export interface MigrationStateInfo {
+
state: "up" | "issue" | "maintenance";
+
message: string;
+
allowMigration: boolean;
+
}
+
+
export interface MigrationCredentials {
+
service: string;
+
handle: string;
+
email: string;
+
password: string;
+
invite?: string;
+
}
+
+
export interface StepCommonProps {
+
credentials: MigrationCredentials;
+
onStepComplete: () => void;
+
onStepError: (error: string, isVerificationError?: boolean) => void;
+
}
+
+
export interface VerificationResult {
+
ready: boolean;
+
reason?: string;
+
activated?: boolean;
+
validDid?: boolean;
+
repoCommit?: boolean;
+
repoRev?: boolean;
+
repoBlocks?: number;
+
expectedRecords?: number;
+
indexedRecords?: number;
+
privateStateValues?: number;
+
expectedBlobs?: number;
+
importedBlobs?: number;
+
}
+
+
/**
+
* Helper function to verify a migration step
+
*/
+
export async function verifyMigrationStep(
+
stepNum: number,
+
): Promise<VerificationResult> {
+
const res = await fetch(`/api/migrate/status?step=${stepNum}`);
+
const data = await res.json();
+
return data;
+
}
+
+
/**
+
* Helper function to handle API responses with proper error parsing
+
*/
+
export function parseApiResponse(
+
responseText: string,
+
): { success: boolean; message?: string } {
+
try {
+
const json = JSON.parse(responseText);
+
return { success: json.success !== false, message: json.message };
+
} catch {
+
return { success: responseText.trim() !== "", message: responseText };
+
}
+
}
+52
routes/api/migrate/identity/request.ts
···
import { define } from "../../../../utils.ts";
import { assertMigrationAllowed } from "../../../../lib/migration-state.ts";
+
// Simple in-memory cache for rate limiting
+
// In a production environment, you might want to use Redis or another shared cache
+
const requestCache = new Map<string, number>();
+
const COOLDOWN_PERIOD_MS = 60000; // 1 minute cooldown
+
/**
* Handle identity migration request
* Sends a PLC operation signature request to the old account's email
···
);
}
+
// Check if we've recently sent a request for this DID
+
const did = oldAgent.did || "";
+
const now = Date.now();
+
const lastRequestTime = requestCache.get(did);
+
+
if (lastRequestTime && now - lastRequestTime < COOLDOWN_PERIOD_MS) {
+
console.log(
+
`Rate limiting PLC request for ${did}, last request was ${
+
(now - lastRequestTime) / 1000
+
} seconds ago`,
+
);
+
return new Response(
+
JSON.stringify({
+
success: true,
+
message:
+
"A PLC code was already sent to your email. Please check your inbox and spam folder.",
+
rateLimited: true,
+
cooldownRemaining: Math.ceil(
+
(COOLDOWN_PERIOD_MS - (now - lastRequestTime)) / 1000,
+
),
+
}),
+
{
+
status: 200,
+
headers: {
+
"Content-Type": "application/json",
+
...Object.fromEntries(res.headers),
+
},
+
},
+
);
+
}
+
// Request the signature
console.log("Requesting PLC operation signature...");
try {
await oldAgent.com.atproto.identity.requestPlcOperationSignature();
console.log("Successfully requested PLC operation signature");
+
+
// Store the request time
+
if (did) {
+
requestCache.set(did, now);
+
+
// Optionally, set up cache cleanup for DIDs that haven't been used in a while
+
setTimeout(() => {
+
if (
+
did &&
+
requestCache.has(did) &&
+
Date.now() - requestCache.get(did)! > COOLDOWN_PERIOD_MS * 2
+
) {
+
requestCache.delete(did);
+
}
+
}, COOLDOWN_PERIOD_MS * 2);
+
}
} catch (error) {
console.error("Error requesting PLC operation signature:", {
name: error instanceof Error ? error.name : "Unknown",
-2
routes/api/plc/token.ts
···
import { getSessionAgent } from "../../../lib/sessions.ts";
-
import { setCredentialSession } from "../../../lib/cred/sessions.ts";
-
import { Agent } from "@atproto/api";
import { define } from "../../../utils.ts";
/**
-1
routes/api/plc/update/complete.ts
···
-
import { Agent } from "@atproto/api";
import { getSessionAgent } from "../../../../lib/sessions.ts";
import { define } from "../../../../utils.ts";
+1 -6
routes/ticket-booth/index.tsx
···
-
import { PageProps } from "fresh";
-
import MigrationSetup from "../../islands/MigrationSetup.tsx";
import DidPlcProgress from "../../islands/DidPlcProgress.tsx";
-
export default function TicketBooth(props: PageProps) {
-
const service = props.url.searchParams.get("service");
-
const handle = props.url.searchParams.get("handle");
-
+
export default function TicketBooth() {
return (
<div class=" bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">