Graphical PDS migrator for AT Protocol

Compare changes

Choose any two refs to compare.

+77
components/MigrationCompletion.tsx
··· 1 + export interface MigrationCompletionProps { 2 + isVisible: boolean; 3 + } 4 + 5 + export default function MigrationCompletion( 6 + { isVisible }: MigrationCompletionProps, 7 + ) { 8 + if (!isVisible) return null; 9 + 10 + const handleLogout = async () => { 11 + try { 12 + const response = await fetch("/api/logout", { 13 + method: "POST", 14 + credentials: "include", 15 + }); 16 + if (!response.ok) { 17 + throw new Error("Logout failed"); 18 + } 19 + globalThis.location.href = "/"; 20 + } catch (error) { 21 + console.error("Failed to logout:", error); 22 + } 23 + }; 24 + 25 + return ( 26 + <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 27 + <p class="text-sm text-green-800 dark:text-green-200 pb-2"> 28 + Migration completed successfully! Sign out to finish the process and 29 + return home.<br /> 30 + Please consider donating to Airport to support server and development 31 + costs. 32 + </p> 33 + <div class="flex space-x-4"> 34 + <button 35 + type="button" 36 + onClick={handleLogout} 37 + 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" 38 + > 39 + <svg 40 + class="w-5 h-5" 41 + fill="none" 42 + stroke="currentColor" 43 + viewBox="0 0 24 24" 44 + > 45 + <path 46 + stroke-linecap="round" 47 + stroke-linejoin="round" 48 + stroke-width="2" 49 + 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" 50 + /> 51 + </svg> 52 + <span>Sign Out</span> 53 + </button> 54 + <a 55 + href="https://ko-fi.com/knotbin" 56 + target="_blank" 57 + 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" 58 + > 59 + <svg 60 + class="w-5 h-5" 61 + fill="none" 62 + stroke="currentColor" 63 + viewBox="0 0 24 24" 64 + > 65 + <path 66 + stroke-linecap="round" 67 + stroke-linejoin="round" 68 + stroke-width="2" 69 + 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" 70 + /> 71 + </svg> 72 + <span>Support Us</span> 73 + </a> 74 + </div> 75 + </div> 76 + ); 77 + }
+208
components/MigrationStep.tsx
··· 1 + import { IS_BROWSER } from "fresh/runtime"; 2 + import { ComponentChildren } from "preact"; 3 + 4 + export type StepStatus = 5 + | "pending" 6 + | "in-progress" 7 + | "verifying" 8 + | "completed" 9 + | "error"; 10 + 11 + export interface MigrationStepProps { 12 + name: string; 13 + status: StepStatus; 14 + error?: string; 15 + isVerificationError?: boolean; 16 + index: number; 17 + onRetryVerification?: (index: number) => void; 18 + children?: ComponentChildren; 19 + } 20 + 21 + export function MigrationStep({ 22 + name, 23 + status, 24 + error, 25 + isVerificationError, 26 + index, 27 + onRetryVerification, 28 + children, 29 + }: MigrationStepProps) { 30 + return ( 31 + <div key={name} class={getStepClasses(status)}> 32 + {getStepIcon(status)} 33 + <div class="flex-1"> 34 + <p 35 + class={`font-medium ${ 36 + status === "error" 37 + ? "text-red-900 dark:text-red-200" 38 + : status === "completed" 39 + ? "text-green-900 dark:text-green-200" 40 + : status === "in-progress" 41 + ? "text-blue-900 dark:text-blue-200" 42 + : "text-gray-900 dark:text-gray-200" 43 + }`} 44 + > 45 + {getStepDisplayName( 46 + { name, status, error, isVerificationError }, 47 + index, 48 + )} 49 + </p> 50 + {error && ( 51 + <div class="mt-1"> 52 + <p class="text-sm text-red-600 dark:text-red-400"> 53 + {(() => { 54 + try { 55 + const err = JSON.parse(error); 56 + return err.message || error; 57 + } catch { 58 + return error; 59 + } 60 + })()} 61 + </p> 62 + {isVerificationError && onRetryVerification && ( 63 + <div class="flex space-x-2 mt-2"> 64 + <button 65 + type="button" 66 + onClick={() => onRetryVerification(index)} 67 + 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" 68 + disabled={!IS_BROWSER} 69 + > 70 + Retry Verification 71 + </button> 72 + </div> 73 + )} 74 + </div> 75 + )} 76 + {children} 77 + </div> 78 + </div> 79 + ); 80 + } 81 + 82 + function getStepDisplayName( 83 + step: Pick< 84 + MigrationStepProps, 85 + "name" | "status" | "error" | "isVerificationError" 86 + >, 87 + index: number, 88 + ) { 89 + if (step.status === "completed") { 90 + switch (index) { 91 + case 0: 92 + return "Account Created"; 93 + case 1: 94 + return "Data Migrated"; 95 + case 2: 96 + return "Identity Migrated"; 97 + case 3: 98 + return "Migration Finalized"; 99 + } 100 + } 101 + 102 + if (step.status === "in-progress") { 103 + switch (index) { 104 + case 0: 105 + return "Creating your new account..."; 106 + case 1: 107 + return "Migrating your data..."; 108 + case 2: 109 + return step.name === 110 + "Enter the token sent to your email to complete identity migration" 111 + ? step.name 112 + : "Migrating your identity..."; 113 + case 3: 114 + return "Finalizing migration..."; 115 + } 116 + } 117 + 118 + if (step.status === "verifying") { 119 + switch (index) { 120 + case 0: 121 + return "Verifying account creation..."; 122 + case 1: 123 + return "Verifying data migration..."; 124 + case 2: 125 + return "Verifying identity migration..."; 126 + case 3: 127 + return "Verifying migration completion..."; 128 + } 129 + } 130 + 131 + return step.name; 132 + } 133 + 134 + function getStepIcon(status: StepStatus) { 135 + switch (status) { 136 + case "pending": 137 + return ( 138 + <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 139 + <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 140 + </div> 141 + ); 142 + case "in-progress": 143 + return ( 144 + <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 145 + <div class="w-3 h-3 rounded-full bg-blue-500" /> 146 + </div> 147 + ); 148 + case "verifying": 149 + return ( 150 + <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 151 + <div class="w-3 h-3 rounded-full bg-yellow-500" /> 152 + </div> 153 + ); 154 + case "completed": 155 + return ( 156 + <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 157 + <svg 158 + class="w-5 h-5 text-white" 159 + fill="none" 160 + stroke="currentColor" 161 + viewBox="0 0 24 24" 162 + > 163 + <path 164 + stroke-linecap="round" 165 + stroke-linejoin="round" 166 + stroke-width="2" 167 + d="M5 13l4 4L19 7" 168 + /> 169 + </svg> 170 + </div> 171 + ); 172 + case "error": 173 + return ( 174 + <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 175 + <svg 176 + class="w-5 h-5 text-white" 177 + fill="none" 178 + stroke="currentColor" 179 + viewBox="0 0 24 24" 180 + > 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + stroke-width="2" 185 + d="M6 18L18 6M6 6l12 12" 186 + /> 187 + </svg> 188 + </div> 189 + ); 190 + } 191 + } 192 + 193 + function getStepClasses(status: StepStatus) { 194 + const baseClasses = 195 + "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 196 + switch (status) { 197 + case "pending": 198 + return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 199 + case "in-progress": 200 + return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 201 + case "verifying": 202 + return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 203 + case "completed": 204 + return `${baseClasses} bg-green-50 dark:bg-green-900`; 205 + case "error": 206 + return `${baseClasses} bg-red-50 dark:bg-red-900`; 207 + } 208 + }
+6 -7
islands/DidPlcProgress.tsx
··· 7 7 error?: string; 8 8 } 9 9 10 + interface KeyJson { 11 + publicKeyDid: string; 12 + [key: string]: unknown; 13 + } 14 + 10 15 // Content chunks for the description 11 16 const contentChunks = [ 12 17 { ··· 158 163 { name: "Complete PLC update", status: "pending" }, 159 164 ]); 160 165 const [generatedKey, setGeneratedKey] = useState<string>(""); 161 - const [keyJson, setKeyJson] = useState<any>(null); 166 + const [keyJson, setKeyJson] = useState<KeyJson | null>(null); 162 167 const [emailToken, setEmailToken] = useState<string>(""); 163 - const [updateResult, setUpdateResult] = useState<string>(""); 164 - const [showDownload, setShowDownload] = useState(false); 165 168 const [hasDownloadedKey, setHasDownloadedKey] = useState(false); 166 169 const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null); 167 170 ··· 381 384 382 385 // Only proceed if we have a successful response 383 386 console.log("Update completed successfully!"); 384 - setUpdateResult("PLC update completed successfully!"); 385 387 386 388 // Add a delay before marking steps as completed for better UX 387 389 updateStepStatus(2, "verifying"); ··· 422 424 error instanceof Error ? error.message : String(error), 423 425 ); 424 426 updateStepStatus(2, "pending"); // Reset the final step 425 - setUpdateResult(error instanceof Error ? error.message : String(error)); 426 427 427 428 // If token is invalid, we should clear it so user can try again 428 429 if ( ··· 478 479 const handleGenerateKey = async () => { 479 480 console.log("=== Generate Key Debug ==="); 480 481 updateStepStatus(0, "in-progress"); 481 - setShowDownload(false); 482 482 setKeyJson(null); 483 483 setGeneratedKey(""); 484 484 setHasDownloadedKey(false); ··· 516 516 517 517 setGeneratedKey(data.publicKeyDid); 518 518 setKeyJson(data); 519 - setShowDownload(true); 520 519 updateStepStatus(0, "completed"); 521 520 } catch (error) { 522 521 console.error("Key generation failed:", error);
+74 -787
islands/MigrationProgress.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 - 3 - /** 4 - * The migration state info. 5 - * @type {MigrationStateInfo} 6 - */ 7 - interface MigrationStateInfo { 8 - state: "up" | "issue" | "maintenance"; 9 - message: string; 10 - allowMigration: boolean; 11 - } 2 + import { MigrationStateInfo } from "../lib/migration-types.ts"; 3 + import AccountCreationStep from "./migration-steps/AccountCreationStep.tsx"; 4 + import DataMigrationStep from "./migration-steps/DataMigrationStep.tsx"; 5 + import IdentityMigrationStep from "./migration-steps/IdentityMigrationStep.tsx"; 6 + import FinalizationStep from "./migration-steps/FinalizationStep.tsx"; 7 + import MigrationCompletion from "../components/MigrationCompletion.tsx"; 12 8 13 9 /** 14 10 * The migration progress props. ··· 22 18 invite?: string; 23 19 } 24 20 25 - /** 26 - * The migration step. 27 - * @type {MigrationStep} 28 - */ 29 - interface MigrationStep { 30 - name: string; 31 - status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 32 - error?: string; 33 - isVerificationError?: boolean; 34 - } 35 - 36 21 /** 37 22 * The migration progress component. 38 23 * @param props - The migration progress props ··· 40 25 * @component 41 26 */ 42 27 export default function MigrationProgress(props: MigrationProgressProps) { 43 - const [token, setToken] = useState(""); 44 28 const [migrationState, setMigrationState] = useState< 45 29 MigrationStateInfo | null 46 30 >(null); 47 - const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>( 48 - {}, 49 - ); 50 - const [showContinueAnyway, setShowContinueAnyway] = useState< 51 - Record<number, boolean> 52 - >({}); 53 - 54 - const [steps, setSteps] = useState<MigrationStep[]>([ 55 - { name: "Create Account", status: "pending" }, 56 - { name: "Migrate Data", status: "pending" }, 57 - { name: "Migrate Identity", status: "pending" }, 58 - { name: "Finalize Migration", status: "pending" }, 59 - ]); 60 - 61 - const updateStepStatus = ( 62 - index: number, 63 - status: MigrationStep["status"], 64 - error?: string, 65 - isVerificationError?: boolean, 66 - ) => { 67 - console.log( 68 - `Updating step ${index} to ${status}${ 69 - error ? ` with error: ${error}` : "" 70 - }`, 71 - ); 72 - setSteps((prevSteps) => 73 - prevSteps.map((step, i) => 74 - i === index 75 - ? { ...step, status, error, isVerificationError } 76 - : i > index 77 - ? { 78 - ...step, 79 - status: "pending", 80 - error: undefined, 81 - isVerificationError: undefined, 82 - } 83 - : step 84 - ) 85 - ); 31 + const [currentStep, setCurrentStep] = useState(0); 32 + const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set()); 33 + const [hasError, setHasError] = useState(false); 34 + 35 + const credentials = { 36 + service: props.service, 37 + handle: props.handle, 38 + email: props.email, 39 + password: props.password, 40 + invite: props.invite, 86 41 }; 87 42 88 43 const validateParams = () => { 89 44 if (!props.service?.trim()) { 90 - updateStepStatus(0, "error", "Missing service URL"); 45 + setHasError(true); 91 46 return false; 92 47 } 93 48 if (!props.handle?.trim()) { 94 - updateStepStatus(0, "error", "Missing handle"); 49 + setHasError(true); 95 50 return false; 96 51 } 97 52 if (!props.email?.trim()) { 98 - updateStepStatus(0, "error", "Missing email"); 53 + setHasError(true); 99 54 return false; 100 55 } 101 56 if (!props.password?.trim()) { 102 - updateStepStatus(0, "error", "Missing password"); 57 + setHasError(true); 103 58 return false; 104 59 } 105 60 return true; ··· 123 78 setMigrationState(migrationData); 124 79 125 80 if (!migrationData.allowMigration) { 126 - updateStepStatus(0, "error", migrationData.message); 81 + setHasError(true); 127 82 return; 128 83 } 129 84 } 130 85 } catch (error) { 131 86 console.error("Failed to check migration state:", error); 132 - updateStepStatus(0, "error", "Unable to verify migration availability"); 87 + setHasError(true); 133 88 return; 134 89 } 135 90 ··· 138 93 return; 139 94 } 140 95 141 - startMigration().catch((error) => { 142 - console.error("Unhandled migration error:", error); 143 - updateStepStatus( 144 - 0, 145 - "error", 146 - error.message || "Unknown error occurred", 147 - ); 148 - }); 96 + // Start with the first step 97 + setCurrentStep(0); 149 98 }; 150 99 151 100 checkMigrationState(); 152 101 }, []); 153 102 154 - const getStepDisplayName = (step: MigrationStep, index: number) => { 155 - if (step.status === "completed") { 156 - switch (index) { 157 - case 0: 158 - return "Account Created"; 159 - case 1: 160 - return "Data Migrated"; 161 - case 2: 162 - return "Identity Migrated"; 163 - case 3: 164 - return "Migration Finalized"; 165 - } 166 - } 167 - 168 - if (step.status === "in-progress") { 169 - switch (index) { 170 - case 0: 171 - return "Creating your new account..."; 172 - case 1: 173 - return "Migrating your data..."; 174 - case 2: 175 - return step.name === 176 - "Enter the token sent to your email to complete identity migration" 177 - ? step.name 178 - : "Migrating your identity..."; 179 - case 3: 180 - return "Finalizing migration..."; 181 - } 182 - } 183 - 184 - if (step.status === "verifying") { 185 - switch (index) { 186 - case 0: 187 - return "Verifying account creation..."; 188 - case 1: 189 - return "Verifying data migration..."; 190 - case 2: 191 - return "Verifying identity migration..."; 192 - case 3: 193 - return "Verifying migration completion..."; 194 - } 195 - } 196 - 197 - return step.name; 198 - }; 199 - 200 - const startMigration = async () => { 201 - try { 202 - // Step 1: Create Account 203 - updateStepStatus(0, "in-progress"); 204 - console.log("Starting account creation..."); 205 - 206 - try { 207 - const createRes = await fetch("/api/migrate/create", { 208 - method: "POST", 209 - headers: { "Content-Type": "application/json" }, 210 - body: JSON.stringify({ 211 - service: props.service, 212 - handle: props.handle, 213 - password: props.password, 214 - email: props.email, 215 - ...(props.invite ? { invite: props.invite } : {}), 216 - }), 217 - }); 218 - 219 - console.log("Create account response status:", createRes.status); 220 - const responseText = await createRes.text(); 221 - console.log("Create account response:", responseText); 222 - 223 - if (!createRes.ok) { 224 - try { 225 - const json = JSON.parse(responseText); 226 - throw new Error(json.message || "Failed to create account"); 227 - } catch { 228 - throw new Error(responseText || "Failed to create account"); 229 - } 230 - } 231 - 232 - try { 233 - const jsonData = JSON.parse(responseText); 234 - if (!jsonData.success) { 235 - throw new Error(jsonData.message || "Account creation failed"); 236 - } 237 - } catch (e) { 238 - console.log("Response is not JSON or lacks success field:", e); 239 - } 240 - 241 - updateStepStatus(0, "verifying"); 242 - const verified = await verifyStep(0); 243 - if (!verified) { 244 - console.log( 245 - "Account creation: Verification failed, waiting for user action", 246 - ); 247 - return; 248 - } 249 - 250 - // If verification succeeds, continue to data migration 251 - await startDataMigration(); 252 - } catch (error) { 253 - updateStepStatus( 254 - 0, 255 - "error", 256 - error instanceof Error ? error.message : String(error), 257 - ); 258 - throw error; 259 - } 260 - } catch (error) { 261 - console.error("Migration error in try/catch:", error); 262 - } 263 - }; 264 - 265 - const handleIdentityMigration = async () => { 266 - if (!token) return; 267 - 268 - try { 269 - const identityRes = await fetch( 270 - `/api/migrate/identity/sign?token=${encodeURIComponent(token)}`, 271 - { 272 - method: "POST", 273 - headers: { "Content-Type": "application/json" }, 274 - }, 275 - ); 276 - 277 - const identityData = await identityRes.text(); 278 - if (!identityRes.ok) { 279 - try { 280 - const json = JSON.parse(identityData); 281 - throw new Error( 282 - json.message || "Failed to complete identity migration", 283 - ); 284 - } catch { 285 - throw new Error( 286 - identityData || "Failed to complete identity migration", 287 - ); 288 - } 289 - } 290 - 291 - let data; 292 - try { 293 - data = JSON.parse(identityData); 294 - if (!data.success) { 295 - throw new Error(data.message || "Identity migration failed"); 296 - } 297 - } catch { 298 - throw new Error("Invalid response from server"); 299 - } 300 - 301 - updateStepStatus(2, "verifying"); 302 - const verified = await verifyStep(2); 303 - if (!verified) { 304 - console.log( 305 - "Identity migration: Verification failed, waiting for user action", 306 - ); 307 - return; 308 - } 309 - 310 - // If verification succeeds, continue to finalization 311 - await startFinalization(); 312 - } catch (error) { 313 - console.error("Identity migration error:", error); 314 - updateStepStatus( 315 - 2, 316 - "error", 317 - error instanceof Error ? error.message : String(error), 318 - ); 319 - } 320 - }; 321 - 322 - const getStepIcon = (status: MigrationStep["status"]) => { 323 - switch (status) { 324 - case "pending": 325 - return ( 326 - <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 327 - <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 328 - </div> 329 - ); 330 - case "in-progress": 331 - return ( 332 - <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 333 - <div class="w-3 h-3 rounded-full bg-blue-500" /> 334 - </div> 335 - ); 336 - case "verifying": 337 - return ( 338 - <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 339 - <div class="w-3 h-3 rounded-full bg-yellow-500" /> 340 - </div> 341 - ); 342 - case "completed": 343 - return ( 344 - <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 345 - <svg 346 - class="w-5 h-5 text-white" 347 - fill="none" 348 - stroke="currentColor" 349 - viewBox="0 0 24 24" 350 - > 351 - <path 352 - stroke-linecap="round" 353 - stroke-linejoin="round" 354 - stroke-width="2" 355 - d="M5 13l4 4L19 7" 356 - /> 357 - </svg> 358 - </div> 359 - ); 360 - case "error": 361 - return ( 362 - <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 363 - <svg 364 - class="w-5 h-5 text-white" 365 - fill="none" 366 - stroke="currentColor" 367 - viewBox="0 0 24 24" 368 - > 369 - <path 370 - stroke-linecap="round" 371 - stroke-linejoin="round" 372 - stroke-width="2" 373 - d="M6 18L18 6M6 6l12 12" 374 - /> 375 - </svg> 376 - </div> 377 - ); 378 - } 379 - }; 380 - 381 - const getStepClasses = (status: MigrationStep["status"]) => { 382 - const baseClasses = 383 - "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 384 - switch (status) { 385 - case "pending": 386 - return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 387 - case "in-progress": 388 - return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 389 - case "verifying": 390 - return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 391 - case "completed": 392 - return `${baseClasses} bg-green-50 dark:bg-green-900`; 393 - case "error": 394 - return `${baseClasses} bg-red-50 dark:bg-red-900`; 395 - } 396 - }; 397 - 398 - // Helper to verify a step after completion 399 - const verifyStep = async (stepNum: number) => { 400 - console.log(`Verification: Starting step ${stepNum + 1}`); 401 - updateStepStatus(stepNum, "verifying"); 402 - try { 403 - console.log(`Verification: Fetching status for step ${stepNum + 1}`); 404 - const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`); 405 - console.log(`Verification: Status response status:`, res.status); 406 - const data = await res.json(); 407 - console.log(`Verification: Status data for step ${stepNum + 1}:`, data); 408 - 409 - if (data.ready) { 410 - console.log(`Verification: Step ${stepNum + 1} is ready`); 411 - updateStepStatus(stepNum, "completed"); 412 - // Reset retry state on success 413 - setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 })); 414 - setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false })); 103 + const handleStepComplete = (stepIndex: number) => { 104 + console.log(`Step ${stepIndex} completed`); 105 + setCompletedSteps((prev) => new Set([...prev, stepIndex])); 415 106 416 - // Continue to next step if not the last one 417 - if (stepNum < 3) { 418 - setTimeout(() => continueToNextStep(stepNum + 1), 500); 419 - } 420 - 421 - return true; 422 - } else { 423 - console.log( 424 - `Verification: Step ${stepNum + 1} is not ready:`, 425 - data.reason, 426 - ); 427 - const statusDetails = { 428 - activated: data.activated, 429 - validDid: data.validDid, 430 - repoCommit: data.repoCommit, 431 - repoRev: data.repoRev, 432 - repoBlocks: data.repoBlocks, 433 - expectedRecords: data.expectedRecords, 434 - indexedRecords: data.indexedRecords, 435 - privateStateValues: data.privateStateValues, 436 - expectedBlobs: data.expectedBlobs, 437 - importedBlobs: data.importedBlobs, 438 - }; 439 - console.log( 440 - `Verification: Step ${stepNum + 1} status details:`, 441 - statusDetails, 442 - ); 443 - const errorMessage = `${ 444 - data.reason || "Verification failed" 445 - }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 446 - 447 - // Track retry attempts 448 - const currentAttempts = retryAttempts[stepNum] || 0; 449 - setRetryAttempts((prev) => ({ 450 - ...prev, 451 - [stepNum]: currentAttempts + 1, 452 - })); 453 - 454 - // Show continue anyway option if this is the second failure 455 - if (currentAttempts >= 1) { 456 - setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true })); 457 - } 458 - 459 - updateStepStatus(stepNum, "error", errorMessage, true); 460 - return false; 461 - } 462 - } catch (e) { 463 - console.error(`Verification: Error in step ${stepNum + 1}:`, e); 464 - const currentAttempts = retryAttempts[stepNum] || 0; 465 - setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 })); 466 - 467 - // Show continue anyway option if this is the second failure 468 - if (currentAttempts >= 1) { 469 - setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true })); 470 - } 471 - 472 - updateStepStatus( 473 - stepNum, 474 - "error", 475 - e instanceof Error ? e.message : String(e), 476 - true, 477 - ); 478 - return false; 107 + // Move to next step if not the last one 108 + if (stepIndex < 3) { 109 + setCurrentStep(stepIndex + 1); 479 110 } 480 111 }; 481 112 482 - const retryVerification = async (stepNum: number) => { 483 - console.log(`Retrying verification for step ${stepNum + 1}`); 484 - await verifyStep(stepNum); 113 + const handleStepError = ( 114 + stepIndex: number, 115 + error: string, 116 + isVerificationError?: boolean, 117 + ) => { 118 + console.error(`Step ${stepIndex} error:`, error, { isVerificationError }); 119 + // Errors are handled within each step component 485 120 }; 486 121 487 - const continueAnyway = (stepNum: number) => { 488 - console.log(`Continuing anyway for step ${stepNum + 1}`); 489 - updateStepStatus(stepNum, "completed"); 490 - setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false })); 491 - 492 - // Continue with next step if not the last one 493 - if (stepNum < 3) { 494 - continueToNextStep(stepNum + 1); 495 - } 122 + const isStepActive = (stepIndex: number) => { 123 + return currentStep === stepIndex && !hasError; 496 124 }; 497 125 498 - const continueToNextStep = async (stepNum: number) => { 499 - switch (stepNum) { 500 - case 1: 501 - // Continue to data migration 502 - await startDataMigration(); 503 - break; 504 - case 2: 505 - // Continue to identity migration 506 - await startIdentityMigration(); 507 - break; 508 - case 3: 509 - // Continue to finalization 510 - await startFinalization(); 511 - break; 512 - } 126 + const _isStepCompleted = (stepIndex: number) => { 127 + return completedSteps.has(stepIndex); 513 128 }; 514 129 515 - const startDataMigration = async () => { 516 - // Step 2: Migrate Data 517 - updateStepStatus(1, "in-progress"); 518 - console.log("Starting data migration..."); 519 - 520 - try { 521 - // Step 2.1: Migrate Repo 522 - console.log("Data migration: Starting repo migration"); 523 - const repoRes = await fetch("/api/migrate/data/repo", { 524 - method: "POST", 525 - headers: { "Content-Type": "application/json" }, 526 - }); 527 - 528 - console.log("Repo migration: Response status:", repoRes.status); 529 - const repoText = await repoRes.text(); 530 - console.log("Repo migration: Raw response:", repoText); 531 - 532 - if (!repoRes.ok) { 533 - try { 534 - const json = JSON.parse(repoText); 535 - console.error("Repo migration: Error response:", json); 536 - throw new Error(json.message || "Failed to migrate repo"); 537 - } catch { 538 - console.error("Repo migration: Non-JSON error response:", repoText); 539 - throw new Error(repoText || "Failed to migrate repo"); 540 - } 541 - } 542 - 543 - // Step 2.2: Migrate Blobs 544 - console.log("Data migration: Starting blob migration"); 545 - const blobsRes = await fetch("/api/migrate/data/blobs", { 546 - method: "POST", 547 - headers: { "Content-Type": "application/json" }, 548 - }); 549 - 550 - console.log("Blob migration: Response status:", blobsRes.status); 551 - const blobsText = await blobsRes.text(); 552 - console.log("Blob migration: Raw response:", blobsText); 553 - 554 - if (!blobsRes.ok) { 555 - try { 556 - const json = JSON.parse(blobsText); 557 - console.error("Blob migration: Error response:", json); 558 - throw new Error(json.message || "Failed to migrate blobs"); 559 - } catch { 560 - console.error( 561 - "Blob migration: Non-JSON error response:", 562 - blobsText, 563 - ); 564 - throw new Error(blobsText || "Failed to migrate blobs"); 565 - } 566 - } 567 - 568 - // Step 2.3: Migrate Preferences 569 - console.log("Data migration: Starting preferences migration"); 570 - const prefsRes = await fetch("/api/migrate/data/prefs", { 571 - method: "POST", 572 - headers: { "Content-Type": "application/json" }, 573 - }); 574 - 575 - console.log("Preferences migration: Response status:", prefsRes.status); 576 - const prefsText = await prefsRes.text(); 577 - console.log("Preferences migration: Raw response:", prefsText); 578 - 579 - if (!prefsRes.ok) { 580 - try { 581 - const json = JSON.parse(prefsText); 582 - console.error("Preferences migration: Error response:", json); 583 - throw new Error(json.message || "Failed to migrate preferences"); 584 - } catch { 585 - console.error( 586 - "Preferences migration: Non-JSON error response:", 587 - prefsText, 588 - ); 589 - throw new Error(prefsText || "Failed to migrate preferences"); 590 - } 591 - } 592 - 593 - console.log("Data migration: Starting verification"); 594 - updateStepStatus(1, "verifying"); 595 - const verified = await verifyStep(1); 596 - console.log("Data migration: Verification result:", verified); 597 - if (!verified) { 598 - console.log( 599 - "Data migration: Verification failed, waiting for user action", 600 - ); 601 - return; 602 - } 603 - 604 - // If verification succeeds, continue to next step 605 - await startIdentityMigration(); 606 - } catch (error) { 607 - console.error("Data migration: Error caught:", error); 608 - updateStepStatus( 609 - 1, 610 - "error", 611 - error instanceof Error ? error.message : String(error), 612 - ); 613 - throw error; 614 - } 615 - }; 616 - 617 - const startIdentityMigration = async () => { 618 - // Step 3: Request Identity Migration 619 - updateStepStatus(2, "in-progress"); 620 - console.log("Requesting identity migration..."); 621 - 622 - try { 623 - const requestRes = await fetch("/api/migrate/identity/request", { 624 - method: "POST", 625 - headers: { "Content-Type": "application/json" }, 626 - }); 627 - 628 - console.log("Identity request response status:", requestRes.status); 629 - const requestText = await requestRes.text(); 630 - console.log("Identity request response:", requestText); 631 - 632 - if (!requestRes.ok) { 633 - try { 634 - const json = JSON.parse(requestText); 635 - throw new Error( 636 - json.message || "Failed to request identity migration", 637 - ); 638 - } catch { 639 - throw new Error( 640 - requestText || "Failed to request identity migration", 641 - ); 642 - } 643 - } 644 - 645 - try { 646 - const jsonData = JSON.parse(requestText); 647 - if (!jsonData.success) { 648 - throw new Error( 649 - jsonData.message || "Identity migration request failed", 650 - ); 651 - } 652 - console.log("Identity migration requested successfully"); 653 - 654 - // Update step name to prompt for token 655 - setSteps((prevSteps) => 656 - prevSteps.map((step, i) => 657 - i === 2 658 - ? { 659 - ...step, 660 - name: 661 - "Enter the token sent to your email to complete identity migration", 662 - } 663 - : step 664 - ) 665 - ); 666 - // Don't continue with migration - wait for token input 667 - return; 668 - } catch (e) { 669 - console.error("Failed to parse identity request response:", e); 670 - throw new Error( 671 - "Invalid response from server during identity request", 672 - ); 673 - } 674 - } catch (error) { 675 - updateStepStatus( 676 - 2, 677 - "error", 678 - error instanceof Error ? error.message : String(error), 679 - ); 680 - throw error; 681 - } 682 - }; 683 - 684 - const startFinalization = async () => { 685 - // Step 4: Finalize Migration 686 - updateStepStatus(3, "in-progress"); 687 - try { 688 - const finalizeRes = await fetch("/api/migrate/finalize", { 689 - method: "POST", 690 - headers: { "Content-Type": "application/json" }, 691 - }); 692 - 693 - const finalizeData = await finalizeRes.text(); 694 - if (!finalizeRes.ok) { 695 - try { 696 - const json = JSON.parse(finalizeData); 697 - throw new Error(json.message || "Failed to finalize migration"); 698 - } catch { 699 - throw new Error(finalizeData || "Failed to finalize migration"); 700 - } 701 - } 702 - 703 - try { 704 - const jsonData = JSON.parse(finalizeData); 705 - if (!jsonData.success) { 706 - throw new Error(jsonData.message || "Finalization failed"); 707 - } 708 - } catch { 709 - throw new Error("Invalid response from server during finalization"); 710 - } 711 - 712 - updateStepStatus(3, "verifying"); 713 - const verified = await verifyStep(3); 714 - if (!verified) { 715 - console.log( 716 - "Finalization: Verification failed, waiting for user action", 717 - ); 718 - return; 719 - } 720 - } catch (error) { 721 - updateStepStatus( 722 - 3, 723 - "error", 724 - error instanceof Error ? error.message : String(error), 725 - ); 726 - throw error; 727 - } 728 - }; 130 + const allStepsCompleted = completedSteps.size === 4; 729 131 730 132 return ( 731 133 <div class="space-y-8"> ··· 761 163 )} 762 164 763 165 <div class="space-y-4"> 764 - {steps.map((step, index) => ( 765 - <div key={step.name} class={getStepClasses(step.status)}> 766 - {getStepIcon(step.status)} 767 - <div class="flex-1"> 768 - <p 769 - class={`font-medium ${ 770 - step.status === "error" 771 - ? "text-red-900 dark:text-red-200" 772 - : step.status === "completed" 773 - ? "text-green-900 dark:text-green-200" 774 - : step.status === "in-progress" 775 - ? "text-blue-900 dark:text-blue-200" 776 - : "text-gray-900 dark:text-gray-200" 777 - }`} 778 - > 779 - {getStepDisplayName(step, index)} 780 - </p> 781 - {step.error && ( 782 - <div class="mt-1"> 783 - <p class="text-sm text-red-600 dark:text-red-400"> 784 - {(() => { 785 - try { 786 - const err = JSON.parse(step.error); 787 - return err.message || step.error; 788 - } catch { 789 - return step.error; 790 - } 791 - })()} 792 - </p> 793 - {step.isVerificationError && ( 794 - <div class="flex space-x-2 mt-2"> 795 - <button 796 - type="button" 797 - onClick={() => retryVerification(index)} 798 - 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" 799 - > 800 - Retry Verification 801 - </button> 802 - {showContinueAnyway[index] && ( 803 - <button 804 - type="button" 805 - onClick={() => continueAnyway(index)} 806 - 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 807 - dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 808 - > 809 - Continue Anyway 810 - </button> 811 - )} 812 - </div> 813 - )} 814 - </div> 815 - )} 816 - {index === 2 && step.status === "in-progress" && 817 - step.name === 818 - "Enter the token sent to your email to complete identity migration" && 819 - ( 820 - <div class="mt-4 space-y-4"> 821 - <p class="text-sm text-blue-800 dark:text-blue-200"> 822 - Please check your email for the migration token and enter 823 - it below: 824 - </p> 825 - <div class="flex space-x-2"> 826 - <input 827 - type="text" 828 - value={token} 829 - onChange={(e) => setToken(e.currentTarget.value)} 830 - placeholder="Enter token" 831 - 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" 832 - /> 833 - <button 834 - type="button" 835 - onClick={handleIdentityMigration} 836 - 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" 837 - > 838 - Submit Token 839 - </button> 840 - </div> 841 - </div> 842 - )} 843 - </div> 844 - </div> 845 - ))} 166 + <AccountCreationStep 167 + credentials={credentials} 168 + onStepComplete={() => handleStepComplete(0)} 169 + onStepError={(error, isVerificationError) => 170 + handleStepError(0, error, isVerificationError)} 171 + isActive={isStepActive(0)} 172 + /> 173 + 174 + <DataMigrationStep 175 + credentials={credentials} 176 + onStepComplete={() => handleStepComplete(1)} 177 + onStepError={(error, isVerificationError) => 178 + handleStepError(1, error, isVerificationError)} 179 + isActive={isStepActive(1)} 180 + /> 181 + 182 + <IdentityMigrationStep 183 + credentials={credentials} 184 + onStepComplete={() => handleStepComplete(2)} 185 + onStepError={(error, isVerificationError) => 186 + handleStepError(2, error, isVerificationError)} 187 + isActive={isStepActive(2)} 188 + /> 189 + 190 + <FinalizationStep 191 + credentials={credentials} 192 + onStepComplete={() => handleStepComplete(3)} 193 + onStepError={(error, isVerificationError) => 194 + handleStepError(3, error, isVerificationError)} 195 + isActive={isStepActive(3)} 196 + /> 846 197 </div> 847 198 848 - {steps[3].status === "completed" && ( 849 - <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 850 - <p class="text-sm text-green-800 dark:text-green-200 pb-2"> 851 - Migration completed successfully! Sign out to finish the process and 852 - return home.<br /> 853 - Please consider donating to Airport to support server and 854 - development costs. 855 - </p> 856 - <div class="flex space-x-4"> 857 - <button 858 - type="button" 859 - onClick={async () => { 860 - try { 861 - const response = await fetch("/api/logout", { 862 - method: "POST", 863 - credentials: "include", 864 - }); 865 - if (!response.ok) { 866 - throw new Error("Logout failed"); 867 - } 868 - globalThis.location.href = "/"; 869 - } catch (error) { 870 - console.error("Failed to logout:", error); 871 - } 872 - }} 873 - 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" 874 - > 875 - <svg 876 - class="w-5 h-5" 877 - fill="none" 878 - stroke="currentColor" 879 - viewBox="0 0 24 24" 880 - > 881 - <path 882 - stroke-linecap="round" 883 - stroke-linejoin="round" 884 - stroke-width="2" 885 - 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" 886 - /> 887 - </svg> 888 - <span>Sign Out</span> 889 - </button> 890 - <a 891 - href="https://ko-fi.com/knotbin" 892 - target="_blank" 893 - 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" 894 - > 895 - <svg 896 - class="w-5 h-5" 897 - fill="none" 898 - stroke="currentColor" 899 - viewBox="0 0 24 24" 900 - > 901 - <path 902 - stroke-linecap="round" 903 - stroke-linejoin="round" 904 - stroke-width="2" 905 - 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" 906 - /> 907 - </svg> 908 - <span>Support Us</span> 909 - </a> 910 - </div> 911 - </div> 912 - )} 199 + <MigrationCompletion isVisible={allStepsCompleted} /> 913 200 </div> 914 201 ); 915 202 }
+151
islands/migration-steps/AccountCreationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface AccountCreationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function AccountCreationStep({ 14 + credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: AccountCreationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startAccountCreation(); 29 + } 30 + }, [isActive]); 31 + 32 + const startAccountCreation = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + const createRes = await fetch("/api/migrate/create", { 38 + method: "POST", 39 + headers: { "Content-Type": "application/json" }, 40 + body: JSON.stringify({ 41 + service: credentials.service, 42 + handle: credentials.handle, 43 + password: credentials.password, 44 + email: credentials.email, 45 + ...(credentials.invite ? { invite: credentials.invite } : {}), 46 + }), 47 + }); 48 + 49 + const responseText = await createRes.text(); 50 + 51 + if (!createRes.ok) { 52 + const parsed = parseApiResponse(responseText); 53 + throw new Error(parsed.message || "Failed to create account"); 54 + } 55 + 56 + const parsed = parseApiResponse(responseText); 57 + if (!parsed.success) { 58 + throw new Error(parsed.message || "Account creation failed"); 59 + } 60 + 61 + // Verify the account creation 62 + await verifyAccountCreation(); 63 + } catch (error) { 64 + const errorMessage = error instanceof Error 65 + ? error.message 66 + : String(error); 67 + setError(errorMessage); 68 + setStatus("error"); 69 + onStepError(errorMessage); 70 + } 71 + }; 72 + 73 + const verifyAccountCreation = async () => { 74 + setStatus("verifying"); 75 + 76 + try { 77 + const result = await verifyMigrationStep(1); 78 + 79 + if (result.ready) { 80 + setStatus("completed"); 81 + setRetryCount(0); 82 + setShowContinueAnyway(false); 83 + onStepComplete(); 84 + } else { 85 + const statusDetails = { 86 + activated: result.activated, 87 + validDid: result.validDid, 88 + }; 89 + const errorMessage = `${ 90 + result.reason || "Verification failed" 91 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 92 + 93 + setRetryCount((prev) => prev + 1); 94 + if (retryCount >= 1) { 95 + setShowContinueAnyway(true); 96 + } 97 + 98 + setError(errorMessage); 99 + setStatus("error"); 100 + onStepError(errorMessage, true); 101 + } 102 + } catch (error) { 103 + const errorMessage = error instanceof Error 104 + ? error.message 105 + : String(error); 106 + setRetryCount((prev) => prev + 1); 107 + if (retryCount >= 1) { 108 + setShowContinueAnyway(true); 109 + } 110 + 111 + setError(errorMessage); 112 + setStatus("error"); 113 + onStepError(errorMessage, true); 114 + } 115 + }; 116 + 117 + const retryVerification = async () => { 118 + await verifyAccountCreation(); 119 + }; 120 + 121 + const continueAnyway = () => { 122 + setStatus("completed"); 123 + setShowContinueAnyway(false); 124 + onStepComplete(); 125 + }; 126 + 127 + return ( 128 + <MigrationStep 129 + name="Create Account" 130 + status={status} 131 + error={error} 132 + isVerificationError={status === "error" && 133 + error?.includes("Verification failed")} 134 + index={0} 135 + onRetryVerification={retryVerification} 136 + > 137 + {status === "error" && showContinueAnyway && ( 138 + <div class="flex space-x-2 mt-2"> 139 + <button 140 + type="button" 141 + onClick={continueAnyway} 142 + 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 143 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 144 + > 145 + Continue Anyway 146 + </button> 147 + </div> 148 + )} 149 + </MigrationStep> 150 + ); 151 + }
+172
islands/migration-steps/DataMigrationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface DataMigrationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function DataMigrationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: DataMigrationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startDataMigration(); 29 + } 30 + }, [isActive]); 31 + 32 + const startDataMigration = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + // Step 1: Migrate Repo 38 + const repoRes = await fetch("/api/migrate/data/repo", { 39 + method: "POST", 40 + headers: { "Content-Type": "application/json" }, 41 + }); 42 + 43 + const repoText = await repoRes.text(); 44 + 45 + if (!repoRes.ok) { 46 + const parsed = parseApiResponse(repoText); 47 + throw new Error(parsed.message || "Failed to migrate repo"); 48 + } 49 + 50 + // Step 2: Migrate Blobs 51 + const blobsRes = await fetch("/api/migrate/data/blobs", { 52 + method: "POST", 53 + headers: { "Content-Type": "application/json" }, 54 + }); 55 + 56 + const blobsText = await blobsRes.text(); 57 + 58 + if (!blobsRes.ok) { 59 + const parsed = parseApiResponse(blobsText); 60 + throw new Error(parsed.message || "Failed to migrate blobs"); 61 + } 62 + 63 + // Step 3: Migrate Preferences 64 + const prefsRes = await fetch("/api/migrate/data/prefs", { 65 + method: "POST", 66 + headers: { "Content-Type": "application/json" }, 67 + }); 68 + 69 + const prefsText = await prefsRes.text(); 70 + 71 + if (!prefsRes.ok) { 72 + const parsed = parseApiResponse(prefsText); 73 + throw new Error(parsed.message || "Failed to migrate preferences"); 74 + } 75 + 76 + // Verify the data migration 77 + await verifyDataMigration(); 78 + } catch (error) { 79 + const errorMessage = error instanceof Error 80 + ? error.message 81 + : String(error); 82 + setError(errorMessage); 83 + setStatus("error"); 84 + onStepError(errorMessage); 85 + } 86 + }; 87 + 88 + const verifyDataMigration = async () => { 89 + setStatus("verifying"); 90 + 91 + try { 92 + const result = await verifyMigrationStep(2); 93 + 94 + if (result.ready) { 95 + setStatus("completed"); 96 + setRetryCount(0); 97 + setShowContinueAnyway(false); 98 + onStepComplete(); 99 + } else { 100 + const statusDetails = { 101 + repoCommit: result.repoCommit, 102 + repoRev: result.repoRev, 103 + repoBlocks: result.repoBlocks, 104 + expectedRecords: result.expectedRecords, 105 + indexedRecords: result.indexedRecords, 106 + privateStateValues: result.privateStateValues, 107 + expectedBlobs: result.expectedBlobs, 108 + importedBlobs: result.importedBlobs, 109 + }; 110 + const errorMessage = `${ 111 + result.reason || "Verification failed" 112 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 113 + 114 + setRetryCount((prev) => prev + 1); 115 + if (retryCount >= 1) { 116 + setShowContinueAnyway(true); 117 + } 118 + 119 + setError(errorMessage); 120 + setStatus("error"); 121 + onStepError(errorMessage, true); 122 + } 123 + } catch (error) { 124 + const errorMessage = error instanceof Error 125 + ? error.message 126 + : String(error); 127 + setRetryCount((prev) => prev + 1); 128 + if (retryCount >= 1) { 129 + setShowContinueAnyway(true); 130 + } 131 + 132 + setError(errorMessage); 133 + setStatus("error"); 134 + onStepError(errorMessage, true); 135 + } 136 + }; 137 + 138 + const retryVerification = async () => { 139 + await verifyDataMigration(); 140 + }; 141 + 142 + const continueAnyway = () => { 143 + setStatus("completed"); 144 + setShowContinueAnyway(false); 145 + onStepComplete(); 146 + }; 147 + 148 + return ( 149 + <MigrationStep 150 + name="Migrate Data" 151 + status={status} 152 + error={error} 153 + isVerificationError={status === "error" && 154 + error?.includes("Verification failed")} 155 + index={1} 156 + onRetryVerification={retryVerification} 157 + > 158 + {status === "error" && showContinueAnyway && ( 159 + <div class="flex space-x-2 mt-2"> 160 + <button 161 + type="button" 162 + onClick={continueAnyway} 163 + 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 164 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 165 + > 166 + Continue Anyway 167 + </button> 168 + </div> 169 + )} 170 + </MigrationStep> 171 + ); 172 + }
+143
islands/migration-steps/FinalizationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface FinalizationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function FinalizationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: FinalizationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startFinalization(); 29 + } 30 + }, [isActive]); 31 + 32 + const startFinalization = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + const finalizeRes = await fetch("/api/migrate/finalize", { 38 + method: "POST", 39 + headers: { "Content-Type": "application/json" }, 40 + }); 41 + 42 + const finalizeData = await finalizeRes.text(); 43 + if (!finalizeRes.ok) { 44 + const parsed = parseApiResponse(finalizeData); 45 + throw new Error(parsed.message || "Failed to finalize migration"); 46 + } 47 + 48 + const parsed = parseApiResponse(finalizeData); 49 + if (!parsed.success) { 50 + throw new Error(parsed.message || "Finalization failed"); 51 + } 52 + 53 + // Verify the finalization 54 + await verifyFinalization(); 55 + } catch (error) { 56 + const errorMessage = error instanceof Error 57 + ? error.message 58 + : String(error); 59 + setError(errorMessage); 60 + setStatus("error"); 61 + onStepError(errorMessage); 62 + } 63 + }; 64 + 65 + const verifyFinalization = async () => { 66 + setStatus("verifying"); 67 + 68 + try { 69 + const result = await verifyMigrationStep(4); 70 + 71 + if (result.ready) { 72 + setStatus("completed"); 73 + setRetryCount(0); 74 + setShowContinueAnyway(false); 75 + onStepComplete(); 76 + } else { 77 + const statusDetails = { 78 + activated: result.activated, 79 + validDid: result.validDid, 80 + }; 81 + const errorMessage = `${ 82 + result.reason || "Verification failed" 83 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 84 + 85 + setRetryCount((prev) => prev + 1); 86 + if (retryCount >= 1) { 87 + setShowContinueAnyway(true); 88 + } 89 + 90 + setError(errorMessage); 91 + setStatus("error"); 92 + onStepError(errorMessage, true); 93 + } 94 + } catch (error) { 95 + const errorMessage = error instanceof Error 96 + ? error.message 97 + : String(error); 98 + setRetryCount((prev) => prev + 1); 99 + if (retryCount >= 1) { 100 + setShowContinueAnyway(true); 101 + } 102 + 103 + setError(errorMessage); 104 + setStatus("error"); 105 + onStepError(errorMessage, true); 106 + } 107 + }; 108 + 109 + const retryVerification = async () => { 110 + await verifyFinalization(); 111 + }; 112 + 113 + const continueAnyway = () => { 114 + setStatus("completed"); 115 + setShowContinueAnyway(false); 116 + onStepComplete(); 117 + }; 118 + 119 + return ( 120 + <MigrationStep 121 + name="Finalize Migration" 122 + status={status} 123 + error={error} 124 + isVerificationError={status === "error" && 125 + error?.includes("Verification failed")} 126 + index={3} 127 + onRetryVerification={retryVerification} 128 + > 129 + {status === "error" && showContinueAnyway && ( 130 + <div class="flex space-x-2 mt-2"> 131 + <button 132 + type="button" 133 + onClick={continueAnyway} 134 + 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 135 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 136 + > 137 + Continue Anyway 138 + </button> 139 + </div> 140 + )} 141 + </MigrationStep> 142 + ); 143 + }
+294
islands/migration-steps/IdentityMigrationStep.tsx
··· 1 + import { useEffect, useRef, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface IdentityMigrationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function IdentityMigrationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: IdentityMigrationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + const [token, setToken] = useState(""); 26 + const [identityRequestSent, setIdentityRequestSent] = useState(false); 27 + const [identityRequestCooldown, setIdentityRequestCooldown] = useState(0); 28 + const [cooldownInterval, setCooldownInterval] = useState<number | null>(null); 29 + const [stepName, setStepName] = useState("Migrate Identity"); 30 + const identityRequestInProgressRef = useRef(false); 31 + 32 + // Clean up interval on unmount 33 + useEffect(() => { 34 + return () => { 35 + if (cooldownInterval !== null) { 36 + clearInterval(cooldownInterval); 37 + } 38 + }; 39 + }, [cooldownInterval]); 40 + 41 + useEffect(() => { 42 + if (isActive && status === "pending") { 43 + startIdentityMigration(); 44 + } 45 + }, [isActive]); 46 + 47 + const startIdentityMigration = async () => { 48 + // Prevent multiple concurrent calls 49 + if (identityRequestInProgressRef.current) { 50 + return; 51 + } 52 + 53 + identityRequestInProgressRef.current = true; 54 + setStatus("in-progress"); 55 + setError(undefined); 56 + 57 + // Don't send duplicate requests 58 + if (identityRequestSent) { 59 + setStepName( 60 + "Enter the token sent to your email to complete identity migration", 61 + ); 62 + setTimeout(() => { 63 + identityRequestInProgressRef.current = false; 64 + }, 1000); 65 + return; 66 + } 67 + 68 + try { 69 + const requestRes = await fetch("/api/migrate/identity/request", { 70 + method: "POST", 71 + headers: { "Content-Type": "application/json" }, 72 + }); 73 + 74 + const requestText = await requestRes.text(); 75 + 76 + if (!requestRes.ok) { 77 + const parsed = parseApiResponse(requestText); 78 + throw new Error( 79 + parsed.message || "Failed to request identity migration", 80 + ); 81 + } 82 + 83 + const parsed = parseApiResponse(requestText); 84 + if (!parsed.success) { 85 + throw new Error(parsed.message || "Identity migration request failed"); 86 + } 87 + 88 + // Mark request as sent 89 + setIdentityRequestSent(true); 90 + 91 + // Handle rate limiting 92 + const jsonData = JSON.parse(requestText); 93 + if (jsonData.rateLimited && jsonData.cooldownRemaining) { 94 + setIdentityRequestCooldown(jsonData.cooldownRemaining); 95 + 96 + // Clear any existing interval 97 + if (cooldownInterval !== null) { 98 + clearInterval(cooldownInterval); 99 + } 100 + 101 + // Set up countdown timer 102 + const intervalId = setInterval(() => { 103 + setIdentityRequestCooldown((prev) => { 104 + if (prev <= 1) { 105 + clearInterval(intervalId); 106 + setCooldownInterval(null); 107 + return 0; 108 + } 109 + return prev - 1; 110 + }); 111 + }, 1000); 112 + 113 + setCooldownInterval(intervalId); 114 + } 115 + 116 + // Update step name to prompt for token 117 + setStepName( 118 + identityRequestCooldown > 0 119 + ? `Please wait ${identityRequestCooldown}s before requesting another code` 120 + : "Enter the token sent to your email to complete identity migration", 121 + ); 122 + } catch (error) { 123 + const errorMessage = error instanceof Error 124 + ? error.message 125 + : String(error); 126 + // Don't mark as error if it was due to rate limiting 127 + if (identityRequestCooldown > 0) { 128 + setStatus("in-progress"); 129 + } else { 130 + setError(errorMessage); 131 + setStatus("error"); 132 + onStepError(errorMessage); 133 + } 134 + } finally { 135 + setTimeout(() => { 136 + identityRequestInProgressRef.current = false; 137 + }, 1000); 138 + } 139 + }; 140 + 141 + const handleIdentityMigration = async () => { 142 + if (!token) return; 143 + 144 + try { 145 + const identityRes = await fetch( 146 + `/api/migrate/identity/sign?token=${encodeURIComponent(token)}`, 147 + { 148 + method: "POST", 149 + headers: { "Content-Type": "application/json" }, 150 + }, 151 + ); 152 + 153 + const identityData = await identityRes.text(); 154 + if (!identityRes.ok) { 155 + const parsed = parseApiResponse(identityData); 156 + throw new Error( 157 + parsed.message || "Failed to complete identity migration", 158 + ); 159 + } 160 + 161 + const parsed = parseApiResponse(identityData); 162 + if (!parsed.success) { 163 + throw new Error(parsed.message || "Identity migration failed"); 164 + } 165 + 166 + // Verify the identity migration 167 + await verifyIdentityMigration(); 168 + } catch (error) { 169 + const errorMessage = error instanceof Error 170 + ? error.message 171 + : String(error); 172 + setError(errorMessage); 173 + setStatus("error"); 174 + onStepError(errorMessage); 175 + } 176 + }; 177 + 178 + const verifyIdentityMigration = async () => { 179 + setStatus("verifying"); 180 + 181 + try { 182 + const result = await verifyMigrationStep(3); 183 + 184 + if (result.ready) { 185 + setStatus("completed"); 186 + setRetryCount(0); 187 + setShowContinueAnyway(false); 188 + onStepComplete(); 189 + } else { 190 + const statusDetails = { 191 + activated: result.activated, 192 + validDid: result.validDid, 193 + }; 194 + const errorMessage = `${ 195 + result.reason || "Verification failed" 196 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 197 + 198 + setRetryCount((prev) => prev + 1); 199 + if (retryCount >= 1) { 200 + setShowContinueAnyway(true); 201 + } 202 + 203 + setError(errorMessage); 204 + setStatus("error"); 205 + onStepError(errorMessage, true); 206 + } 207 + } catch (error) { 208 + const errorMessage = error instanceof Error 209 + ? error.message 210 + : String(error); 211 + setRetryCount((prev) => prev + 1); 212 + if (retryCount >= 1) { 213 + setShowContinueAnyway(true); 214 + } 215 + 216 + setError(errorMessage); 217 + setStatus("error"); 218 + onStepError(errorMessage, true); 219 + } 220 + }; 221 + 222 + const retryVerification = async () => { 223 + await verifyIdentityMigration(); 224 + }; 225 + 226 + const continueAnyway = () => { 227 + setStatus("completed"); 228 + setShowContinueAnyway(false); 229 + onStepComplete(); 230 + }; 231 + 232 + return ( 233 + <MigrationStep 234 + name={stepName} 235 + status={status} 236 + error={error} 237 + isVerificationError={status === "error" && 238 + error?.includes("Verification failed")} 239 + index={2} 240 + onRetryVerification={retryVerification} 241 + > 242 + {status === "error" && showContinueAnyway && ( 243 + <div class="flex space-x-2 mt-2"> 244 + <button 245 + type="button" 246 + onClick={continueAnyway} 247 + 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 248 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 249 + > 250 + Continue Anyway 251 + </button> 252 + </div> 253 + )} 254 + 255 + {(status === "in-progress" || identityRequestSent) && 256 + stepName.includes("Enter the token sent to your email") && 257 + (identityRequestCooldown > 0 258 + ? ( 259 + <div class="mt-4"> 260 + <p class="text-sm text-amber-600 dark:text-amber-400"> 261 + <span class="font-medium">Rate limit:</span> Please wait{" "} 262 + {identityRequestCooldown}{" "} 263 + seconds before requesting another code. Check your email inbox 264 + and spam folder for a previously sent code. 265 + </p> 266 + </div> 267 + ) 268 + : ( 269 + <div class="mt-4 space-y-4"> 270 + <p class="text-sm text-blue-800 dark:text-blue-200"> 271 + Please check your email for the migration token and enter it 272 + below: 273 + </p> 274 + <div class="flex space-x-2"> 275 + <input 276 + type="text" 277 + value={token} 278 + onChange={(e) => setToken(e.currentTarget.value)} 279 + placeholder="Enter token" 280 + 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" 281 + /> 282 + <button 283 + type="button" 284 + onClick={handleIdentityMigration} 285 + 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" 286 + > 287 + Submit Token 288 + </button> 289 + </div> 290 + </div> 291 + ))} 292 + </MigrationStep> 293 + ); 294 + }
+63
lib/migration-types.ts
··· 1 + /** 2 + * Shared types for migration components 3 + */ 4 + 5 + export interface MigrationStateInfo { 6 + state: "up" | "issue" | "maintenance"; 7 + message: string; 8 + allowMigration: boolean; 9 + } 10 + 11 + export interface MigrationCredentials { 12 + service: string; 13 + handle: string; 14 + email: string; 15 + password: string; 16 + invite?: string; 17 + } 18 + 19 + export interface StepCommonProps { 20 + credentials: MigrationCredentials; 21 + onStepComplete: () => void; 22 + onStepError: (error: string, isVerificationError?: boolean) => void; 23 + } 24 + 25 + export interface VerificationResult { 26 + ready: boolean; 27 + reason?: string; 28 + activated?: boolean; 29 + validDid?: boolean; 30 + repoCommit?: boolean; 31 + repoRev?: boolean; 32 + repoBlocks?: number; 33 + expectedRecords?: number; 34 + indexedRecords?: number; 35 + privateStateValues?: number; 36 + expectedBlobs?: number; 37 + importedBlobs?: number; 38 + } 39 + 40 + /** 41 + * Helper function to verify a migration step 42 + */ 43 + export async function verifyMigrationStep( 44 + stepNum: number, 45 + ): Promise<VerificationResult> { 46 + const res = await fetch(`/api/migrate/status?step=${stepNum}`); 47 + const data = await res.json(); 48 + return data; 49 + } 50 + 51 + /** 52 + * Helper function to handle API responses with proper error parsing 53 + */ 54 + export function parseApiResponse( 55 + responseText: string, 56 + ): { success: boolean; message?: string } { 57 + try { 58 + const json = JSON.parse(responseText); 59 + return { success: json.success !== false, message: json.message }; 60 + } catch { 61 + return { success: responseText.trim() !== "", message: responseText }; 62 + } 63 + }
+52
routes/api/migrate/identity/request.ts
··· 3 3 import { define } from "../../../../utils.ts"; 4 4 import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 5 5 6 + // Simple in-memory cache for rate limiting 7 + // In a production environment, you might want to use Redis or another shared cache 8 + const requestCache = new Map<string, number>(); 9 + const COOLDOWN_PERIOD_MS = 60000; // 1 minute cooldown 10 + 6 11 /** 7 12 * Handle identity migration request 8 13 * Sends a PLC operation signature request to the old account's email ··· 70 75 ); 71 76 } 72 77 78 + // Check if we've recently sent a request for this DID 79 + const did = oldAgent.did || ""; 80 + const now = Date.now(); 81 + const lastRequestTime = requestCache.get(did); 82 + 83 + if (lastRequestTime && now - lastRequestTime < COOLDOWN_PERIOD_MS) { 84 + console.log( 85 + `Rate limiting PLC request for ${did}, last request was ${ 86 + (now - lastRequestTime) / 1000 87 + } seconds ago`, 88 + ); 89 + return new Response( 90 + JSON.stringify({ 91 + success: true, 92 + message: 93 + "A PLC code was already sent to your email. Please check your inbox and spam folder.", 94 + rateLimited: true, 95 + cooldownRemaining: Math.ceil( 96 + (COOLDOWN_PERIOD_MS - (now - lastRequestTime)) / 1000, 97 + ), 98 + }), 99 + { 100 + status: 200, 101 + headers: { 102 + "Content-Type": "application/json", 103 + ...Object.fromEntries(res.headers), 104 + }, 105 + }, 106 + ); 107 + } 108 + 73 109 // Request the signature 74 110 console.log("Requesting PLC operation signature..."); 75 111 try { 76 112 await oldAgent.com.atproto.identity.requestPlcOperationSignature(); 77 113 console.log("Successfully requested PLC operation signature"); 114 + 115 + // Store the request time 116 + if (did) { 117 + requestCache.set(did, now); 118 + 119 + // Optionally, set up cache cleanup for DIDs that haven't been used in a while 120 + setTimeout(() => { 121 + if ( 122 + did && 123 + requestCache.has(did) && 124 + Date.now() - requestCache.get(did)! > COOLDOWN_PERIOD_MS * 2 125 + ) { 126 + requestCache.delete(did); 127 + } 128 + }, COOLDOWN_PERIOD_MS * 2); 129 + } 78 130 } catch (error) { 79 131 console.error("Error requesting PLC operation signature:", { 80 132 name: error instanceof Error ? error.name : "Unknown",
-2
routes/api/plc/token.ts
··· 1 1 import { getSessionAgent } from "../../../lib/sessions.ts"; 2 - import { setCredentialSession } from "../../../lib/cred/sessions.ts"; 3 - import { Agent } from "@atproto/api"; 4 2 import { define } from "../../../utils.ts"; 5 3 6 4 /**
-1
routes/api/plc/update/complete.ts
··· 1 - import { Agent } from "@atproto/api"; 2 1 import { getSessionAgent } from "../../../../lib/sessions.ts"; 3 2 import { define } from "../../../../utils.ts"; 4 3
+1 -6
routes/ticket-booth/index.tsx
··· 1 - import { PageProps } from "fresh"; 2 - import MigrationSetup from "../../islands/MigrationSetup.tsx"; 3 1 import DidPlcProgress from "../../islands/DidPlcProgress.tsx"; 4 2 5 - export default function TicketBooth(props: PageProps) { 6 - const service = props.url.searchParams.get("service"); 7 - const handle = props.url.searchParams.get("handle"); 8 - 3 + export default function TicketBooth() { 9 4 return ( 10 5 <div class=" bg-gray-50 dark:bg-gray-900 p-4"> 11 6 <div class="max-w-2xl mx-auto">