Graphical PDS migrator for AT Protocol

verify & kiosk

Changed files
+422 -177
islands
lib
routes
api
migrate
migrate
static
+129 -42
islands/MigrationProgress.tsx
···
interface MigrationStep {
name: string;
-
status: "pending" | "in-progress" | "completed" | "error";
+
status: "pending" | "in-progress" | "verifying" | "completed" | "error";
error?: string;
}
···
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;
};
···
console.log("Create account response:", responseText);
if (!createRes.ok) {
-
throw new Error(responseText || "Failed to create account");
+
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 {
···
console.log("Response is not JSON or lacks success field:", e);
}
-
updateStepStatus(0, "completed");
+
updateStepStatus(0, "verifying");
+
const verified = await verifyStep(0);
+
if (!verified) {
+
throw new Error("Account creation verification failed");
+
}
} catch (error) {
updateStepStatus(
0,
···
console.log("Data migration response:", dataText);
if (!dataRes.ok) {
-
throw new Error(dataText || "Failed to migrate data");
+
try {
+
const json = JSON.parse(dataText);
+
throw new Error(json.message || "Failed to migrate data");
+
} catch {
+
throw new Error(dataText || "Failed to migrate data");
+
}
}
try {
···
throw new Error("Invalid response from server during data migration");
}
-
updateStepStatus(1, "completed");
+
updateStepStatus(1, "verifying");
+
const verified = await verifyStep(1);
+
if (!verified) {
+
throw new Error("Data migration verification failed");
+
}
} catch (error) {
updateStepStatus(
1,
···
console.log("Identity request response:", requestText);
if (!requestRes.ok) {
-
throw new Error(
-
requestText || "Failed to request identity migration",
-
);
+
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 {
···
}
console.log("Identity migration requested successfully");
-
// Don't mark step as completed yet since we need token input
-
steps[2].name = "Enter the token sent to your email to complete identity migration";
-
setSteps([...steps]);
+
// 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(
···
const identityData = await identityRes.text();
if (!identityRes.ok) {
-
throw new Error(
-
identityData || "Failed to complete identity migration",
-
);
+
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;
···
}
setRecoveryKey(data.recoveryKey);
-
updateStepStatus(2, "completed");
-
steps[2].name = "Migrate Identity"; // Reset to default name after completion
-
setSteps([...steps]);
+
updateStepStatus(2, "verifying");
+
const verified = await verifyStep(2);
+
if (!verified) {
+
throw new Error("Identity migration verification failed");
+
}
// Step 4: Finalize Migration
updateStepStatus(3, "in-progress");
···
const finalizeData = await finalizeRes.text();
if (!finalizeRes.ok) {
-
throw new Error(finalizeData || "Failed to finalize migration");
+
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 {
···
throw new Error("Invalid response from server during finalization");
}
-
updateStepStatus(3, "completed");
+
updateStepStatus(3, "verifying");
+
const verified = await verifyStep(3);
+
if (!verified) {
+
throw new Error("Migration finalization verification failed");
+
}
} catch (error) {
updateStepStatus(
3,
···
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 `${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":
···
}
};
+
// Helper to verify a step after completion
+
const verifyStep = async (stepNum: number) => {
+
updateStepStatus(stepNum, "verifying");
+
try {
+
const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
+
const data = await res.json();
+
if (data.ready) {
+
updateStepStatus(stepNum, "completed");
+
return true;
+
} else {
+
updateStepStatus(stepNum, "error", data.reason || "Verification failed");
+
return false;
+
}
+
} catch (e) {
+
updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e));
+
return false;
+
}
+
};
+
return (
<div class="space-y-8">
<div class="space-y-4">
···
</p>
{step.error && (
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
-
{step.error}
+
{(() => {
+
try {
+
const err = JSON.parse(step.error);
+
return err.message || step.error;
+
} catch {
+
return step.error;
+
}
+
})()}
</p>
)}
-
{index === 2 && step.status === "in-progress" && (
-
<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-offset-2 focus:ring-blue-500 transition-colors duration-200"
-
>
-
Submit Token
-
</button>
+
{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>
</div>
))}
+244 -107
islands/MigrationSetup.tsx
···
const [availableDomains, setAvailableDomains] = useState<string[]>([]);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
+
const [showConfirmation, setShowConfirmation] = useState(false);
+
const [confirmationText, setConfirmationText] = useState("");
const checkServerDescription = async (serviceUrl: string) => {
try {
···
return;
}
+
setShowConfirmation(true);
+
};
+
+
const handleConfirmation = () => {
+
if (confirmationText !== "MIGRATE") {
+
setError("Please type 'MIGRATE' to confirm");
+
return;
+
}
+
const fullHandle = `${handlePrefix}${
availableDomains.length === 1
? availableDomains[0]
···
};
return (
-
<form onSubmit={handleSubmit} class="space-y-6">
-
{error && (
-
<div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg">
-
<p class="text-red-800 dark:text-red-200">{error}</p>
-
</div>
-
)}
+
<div class="max-w-2xl mx-auto p-6 bg-gradient-to-b from-blue-50 to-white dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl relative overflow-hidden">
+
{/* Decorative airport elements */}
+
<div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div>
+
<div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
+
<div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
-
<div class="space-y-4">
-
<div>
-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
Service URL
-
</label>
-
<input
-
type="url"
-
value={service}
-
onChange={(e) => handleServiceChange(e.currentTarget.value)}
-
placeholder="https://example.com"
-
required
-
disabled={isLoading}
-
class="mt-1 block w-full 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 disabled:opacity-50 disabled:cursor-not-allowed"
-
/>
-
{isLoading && (
-
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
-
Checking server configuration...
+
<div class="text-center mb-8 relative">
+
<p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
+
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
+
</div>
+
+
<form onSubmit={handleSubmit} class="space-y-6">
+
{error && (
+
<div class="bg-red-50 dark:bg-red-900 rounded-lg">
+
<p class="text-red-800 dark:text-red-200 flex items-center">
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
+
</svg>
+
{error}
</p>
-
)}
-
</div>
+
</div>
+
)}
-
<div>
-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
New Handle
-
</label>
-
<div class="mt-1 flex rounded-md shadow-sm">
-
<input
-
type="text"
-
value={handlePrefix}
-
onChange={(e) => setHandlePrefix(e.currentTarget.value)}
-
placeholder="username"
-
required
-
class="flex-1 rounded-l-md border-r-0 border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
-
/>
-
{availableDomains.length > 0
-
? (
-
availableDomains.length === 1
-
? (
-
<span class="inline-flex items-center px-3 rounded-r-md bg-white text-gray-500 dark:bg-gray-700 dark:text-gray-400">
-
{availableDomains[0]}
+
<div class="space-y-4">
+
<div>
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+
Destination Server
+
<span class="text-xs text-gray-500 ml-1">(Final Destination)</span>
+
</label>
+
<div class="relative">
+
<input
+
type="url"
+
value={service}
+
onChange={(e) => handleServiceChange(e.currentTarget.value)}
+
placeholder="https://example.com"
+
required
+
disabled={isLoading}
+
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed pl-10"
+
/>
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
+
</svg>
+
</div>
+
</div>
+
{isLoading && (
+
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center">
+
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24">
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+
</svg>
+
Verifying destination server...
+
</p>
+
)}
+
</div>
+
+
<div>
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+
New Account Handle
+
<span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
+
</label>
+
<div class="mt-1 relative w-full">
+
<div class="flex rounded-md shadow-sm w-full">
+
<div class="relative flex-1">
+
<input
+
type="text"
+
value={handlePrefix}
+
onChange={(e) => setHandlePrefix(e.currentTarget.value)}
+
placeholder="username"
+
required
+
class="w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10 pr-32"
+
style={{ fontFamily: 'inherit' }}
+
/>
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
+
</svg>
+
</div>
+
{/* Suffix for domain ending */}
+
{availableDomains.length > 0 ? (
+
availableDomains.length === 1 ? (
+
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
+
{availableDomains[0]}
+
</span>
+
) : (
+
<span class="absolute inset-y-0 right-0 flex items-center pr-1">
+
<select
+
value={selectedDomain}
+
onChange={(e) => setSelectedDomain(e.currentTarget.value)}
+
class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2"
+
style={{ appearance: 'none' }}
+
>
+
{availableDomains.map((domain) => (
+
<option key={domain} value={domain}>{domain}</option>
+
))}
+
</select>
+
</span>
+
)
+
) : (
+
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
+
.example.com
</span>
-
)
-
: (
-
<select
-
value={selectedDomain}
-
onChange={(e) => setSelectedDomain(e.currentTarget.value)}
-
class="rounded-r-md border-l-0 border-gray-300 bg-gray-50 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
-
>
-
{availableDomains.map((domain) => (
-
<option key={domain} value={domain}>{domain}</option>
-
))}
-
</select>
-
)
-
)
-
: (
-
<span class="inline-flex items-center px-3 rounded-r-md bg-white text-gray-500 dark:bg-gray-700 dark:text-gray-400">
-
.example.com
-
</span>
-
)}
+
)}
+
</div>
+
</div>
+
</div>
</div>
-
</div>
-
<div>
-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
Email
-
</label>
-
<input
-
type="email"
-
value={email}
-
onChange={(e) => setEmail(e.currentTarget.value)}
-
required
-
class="mt-1 block w-full 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"
-
/>
-
</div>
-
-
<div>
-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
New Password
-
</label>
-
<input
-
type="password"
-
value={password}
-
onChange={(e) => setPassword(e.currentTarget.value)}
-
required
-
class="mt-1 block w-full 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"
-
/>
-
</div>
+
<div>
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+
Contact Email
+
<span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span>
+
</label>
+
<div class="relative">
+
<input
+
type="email"
+
value={email}
+
onChange={(e) => setEmail(e.currentTarget.value)}
+
required
+
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
+
/>
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
+
</svg>
+
</div>
+
</div>
+
</div>
-
{inviteRequired && (
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
-
Invite Code
+
New Account Password
+
<span class="text-xs text-gray-500 ml-1">(Security Clearance)</span>
</label>
-
<input
-
type="text"
-
value={invite}
-
onChange={(e) => setInvite(e.currentTarget.value)}
-
required
-
class="mt-1 block w-full 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"
-
/>
+
<div class="relative">
+
<input
+
type="password"
+
value={password}
+
onChange={(e) => setPassword(e.currentTarget.value)}
+
required
+
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
+
/>
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
+
</svg>
+
</div>
+
</div>
</div>
-
)}
-
</div>
+
+
{inviteRequired && (
+
<div>
+
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
+
Invitation Code
+
<span class="text-xs text-gray-500 ml-1">(Boarding Pass)</span>
+
</label>
+
<div class="relative">
+
<input
+
type="text"
+
value={invite}
+
onChange={(e) => setInvite(e.currentTarget.value)}
+
required
+
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
+
/>
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
+
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
+
</svg>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
-
<button
-
type="submit"
-
disabled={isLoading}
-
class="w-full flex justify-center py-2 px-4 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-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
-
>
-
Start Migration
-
</button>
-
</form>
+
<button
+
type="submit"
+
disabled={isLoading}
+
class="w-full flex justify-center items-center py-3 px-4 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-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
+
>
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
+
</svg>
+
Proceed to Check-in
+
</button>
+
</form>
+
+
{showConfirmation && (
+
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
+
<div
+
class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin"
+
style={{ boxShadow: '0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)' }}
+
>
+
<div class="absolute -top-8 left-1/2 -translate-x-1/2">
+
<div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short">
+
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+
</svg>
+
</div>
+
</div>
+
<div class="text-center mb-4 mt-6">
+
<h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
+
<p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
+
<span class="font-semibold text-red-500">Warning:</span> This migration process can be <strong>irreversible</strong>.<br />Once completed, you may not be able to return to your previous account state.
+
</p>
+
<p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
+
Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+
</p>
+
</div>
+
<div class="relative">
+
<input
+
type="text"
+
value={confirmationText}
+
onInput={(e) => setConfirmationText(e.currentTarget.value)}
+
placeholder="Type MIGRATE to confirm"
+
class="w-full p-3 rounded-md bg-white dark:bg-gray-700 shadow focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 dark:text-white text-center font-mono text-lg border border-red-200 dark:border-red-700 transition"
+
autoFocus
+
/>
+
</div>
+
<div class="flex justify-end space-x-4 mt-6">
+
<button
+
onClick={() => setShowConfirmation(false)}
+
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md flex items-center transition"
+
type="button"
+
>
+
<svg class="w-5 h-5 mr-2" 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"></path>
+
</svg>
+
Cancel
+
</button>
+
<button
+
onClick={handleConfirmation}
+
class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${confirmationText.trim().toLowerCase() === 'migrate' ? 'bg-red-600 text-white hover:bg-red-700 cursor-pointer' : 'bg-red-300 text-white cursor-not-allowed'}`}
+
type="button"
+
disabled={confirmationText.trim().toLowerCase() !== 'migrate'}
+
>
+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
+
</svg>
+
Confirm Migration
+
</button>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
);
}
+4 -11
lib/sessions.ts
···
isMigration: boolean = false
): Promise<Agent | null> {
if (isMigration) {
-
console.log("godgo")
return await getCredentialSessionAgent(req, res, isMigration);
}
const oauthAgent = await getOauthSessionAgent(req);
const credentialAgent = await getCredentialSessionAgent(req, res, isMigration);
-
console.log("Made it")
if (oauthAgent) {
-
console.log("oauthing")
return oauthAgent;
-
} else {
-
console.log("boog")
}
+
if (credentialAgent) {
-
console.log("creding")
return credentialAgent;
-
} else {
-
console.log("soop")
}
return null;
···
const migrationSession = await getCredentialSession(req, new Response(), true);
if (oauthSession.did) {
-
await oauthSession.destroy();
+
oauthSession.destroy();
}
if (credentialSession.did) {
-
await credentialSession.destroy();
+
credentialSession.destroy();
}
if (migrationSession.did) {
-
await migrationSession.destroy();
+
migrationSession.destroy();
}
}
+28 -15
routes/api/migrate/status.ts
···
const oldAgent = await getSessionAgent(ctx.req);
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
-
if (!oldAgent || !newAgent) return new Response("Unauthorized", { status: 401 });
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
···
const readyToContinue = () => {
if (step) {
switch (step) {
-
case "1":
+
case "1": {
if (newStatus.data) {
-
return true;
+
return { ready: true };
}
-
return false;
-
case "2":
+
return { ready: false, reason: "New account status not available" };
+
}
+
case "2": {
if (newStatus.data.repoCommit &&
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
newStatus.data.importedBlobs === oldStatus.data.importedBlobs) {
-
return true;
+
return { ready: true };
}
-
return false;
-
case "3":
+
const reasons = [];
+
if (!newStatus.data.repoCommit) reasons.push("Repository not imported.");
+
if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords)
+
reasons.push("Not all records imported.");
+
if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues)
+
reasons.push("Not all private state values imported.");
+
if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs)
+
reasons.push("Expected blobs not fully imported.");
+
if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs)
+
reasons.push("Not all blobs imported.");
+
return { ready: false, reason: reasons.join(", ") };
+
}
+
case "3": {
if (newStatus.data.validDid) {
-
return true;
+
return { ready: true };
}
-
return false;
-
case "4":
+
return { ready: false, reason: "DID not valid" };
+
}
+
case "4": {
if (newStatus.data.activated === true && oldStatus.data.activated === false) {
-
return true;
+
return { ready: true };
}
-
return false;
+
return { ready: false, reason: "Account not activated" };
+
}
}
} else {
-
return true;
+
return { ready: true };
}
}
···
privateStateValues: newStatus.data.privateStateValues,
expectedBlobs: newStatus.data.expectedBlobs,
importedBlobs: newStatus.data.importedBlobs,
-
readyToContinue: readyToContinue()
+
...readyToContinue()
}
return Response.json(status);
+2 -2
routes/migrate/index.tsx
···
const invite = props.url.searchParams.get("invite");
return (
-
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4">
+
<div class=" bg-gray-50 dark:bg-gray-900 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8">
-
Account Migration
+
Account Migration Self-Service Kiosk
</h1>
<MigrationSetup
service={service}
+15
static/styles.css
···
.airport-sign + div {
background-blend-mode: overlay;
}
+
+
@keyframes popin {
+
0% { opacity: 0; transform: scale(0.95); }
+
100% { opacity: 1; transform: scale(1); }
+
}
+
.animate-popin {
+
animation: popin 0.25s cubic-bezier(0.4,0,0.2,1);
+
}
+
@keyframes bounce-short {
+
0%, 100% { transform: translateY(0); }
+
50% { transform: translateY(-8px); }
+
}
+
.animate-bounce-short {
+
animation: bounce-short 0.5s;
+
}