Graphical PDS migrator for AT Protocol
at main 15 kB view raw
1import { useEffect, useState } from "preact/hooks"; 2import { 3 MigrationClient, 4 MigrationError, 5 MigrationErrorType, 6 MigrationProgressProps, 7 MigrationStep, 8} from "../lib/client.ts"; 9/** 10 * The migration progress component. 11 * @param props - The migration progress props 12 * @returns The migration progress component 13 * @component 14 */ 15export default function MigrationProgress(props: MigrationProgressProps) { 16 const [token, setToken] = useState(""); 17 const [showContinueAnyway, setShowContinueAnyway] = useState< 18 Record<number, boolean> 19 >({}); 20 21 const [steps, setSteps] = useState<MigrationStep[]>([ 22 { name: "Create Account", status: "pending" }, 23 { name: "Migrate Data", status: "pending" }, 24 { name: "Migrate Identity", status: "pending" }, 25 { name: "Finalize Migration", status: "pending" }, 26 ]); 27 28 const updateStepStatus = ( 29 index: number, 30 status: MigrationStep["status"], 31 error?: string, 32 isVerificationError?: boolean, 33 ) => { 34 console.log( 35 `Updating step ${index} to ${status}${ 36 error ? ` with error: ${error}` : "" 37 }`, 38 ); 39 setSteps((prevSteps) => 40 prevSteps.map((step, i) => { 41 if (i === index) { 42 // Update the current step 43 return { ...step, status, error, isVerificationError }; 44 } else if (i > index) { 45 // Reset future steps to pending only if current step is erroring 46 if (status === "error") { 47 return { 48 ...step, 49 status: "pending", 50 error: undefined, 51 isVerificationError: undefined, 52 }; 53 } 54 // Otherwise keep future steps as they are 55 return step; 56 } else { 57 // Keep previous steps as they are (preserve completed status) 58 return step; 59 } 60 }) 61 ); 62 }; 63 64 const validateParams = () => { 65 if (!props.service?.trim()) { 66 updateStepStatus(0, "error", "Missing service URL"); 67 return false; 68 } 69 if (!props.handle?.trim()) { 70 updateStepStatus(0, "error", "Missing handle"); 71 return false; 72 } 73 if (!props.email?.trim()) { 74 updateStepStatus(0, "error", "Missing email"); 75 return false; 76 } 77 if (!props.password?.trim()) { 78 updateStepStatus(0, "error", "Missing password"); 79 return false; 80 } 81 return true; 82 }; 83 84 const client = new MigrationClient( 85 { 86 updateStepStatus, 87 nextStepHook(stepNum) { 88 if (stepNum === 2) { 89 // Update step name to prompt for token 90 setSteps((prevSteps) => 91 prevSteps.map((step, i) => 92 i === 2 93 ? { 94 ...step, 95 name: 96 "Enter the token sent to your email to complete identity migration", 97 } 98 : step 99 ) 100 ); 101 } 102 }, 103 setShowContinueAnyway, 104 }, 105 ); 106 107 const continueAnyway = (stepNum: number) => { 108 console.log(`Continuing anyway for step ${stepNum + 1}`); 109 updateStepStatus(stepNum, "completed"); 110 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false })); 111 112 // Continue with next step if not the last one 113 if (stepNum < 3) { 114 client.continueToNextStep(stepNum + 1); 115 } 116 }; 117 118 const handleIdentityMigration = async () => { 119 if (!token.trim()) { 120 updateStepStatus(2, "error", "Please enter a valid token"); 121 return; 122 } 123 124 try { 125 await client.handleIdentityMigration(token); 126 // If successful, continue to next step 127 client.continueToNextStep(3); 128 } catch (error) { 129 console.error("Identity migration error:", error); 130 updateStepStatus( 131 2, 132 "error", 133 error instanceof Error ? error.message : String(error), 134 ); 135 } 136 }; 137 138 useEffect(() => { 139 (async () => { 140 if (!validateParams()) { 141 console.log("Parameter validation failed"); 142 return; 143 } 144 145 try { 146 await client.checkState(); 147 } catch (error) { 148 console.error("Failed to check migration state:", error); 149 if ( 150 error instanceof MigrationError && 151 error.type === MigrationErrorType.NOT_ALLOWED 152 ) { 153 updateStepStatus(0, "error", error.message); 154 } else { 155 updateStepStatus( 156 0, 157 "error", 158 "Unable to verify migration availability", 159 ); 160 } 161 return; 162 } 163 164 await client.startMigration(props); 165 })(); 166 }, []); 167 168 const getStepDisplayName = (step: MigrationStep, index: number) => { 169 if (step.status === "completed") { 170 switch (index) { 171 case 0: 172 return "Account Created"; 173 case 1: 174 return "Data Migrated"; 175 case 2: 176 return "Identity Migrated"; 177 case 3: 178 return "Migration Finalized"; 179 } 180 } 181 182 if (step.status === "in-progress") { 183 switch (index) { 184 case 0: 185 return "Creating your new account..."; 186 case 1: 187 return "Migrating your data..."; 188 case 2: 189 return step.name === 190 "Enter the token sent to your email to complete identity migration" 191 ? step.name 192 : "Migrating your identity..."; 193 case 3: 194 return "Finalizing migration..."; 195 } 196 } 197 198 if (step.status === "verifying") { 199 switch (index) { 200 case 0: 201 return "Verifying account creation..."; 202 case 1: 203 return "Verifying data migration..."; 204 case 2: 205 return "Verifying identity migration..."; 206 case 3: 207 return "Verifying migration completion..."; 208 } 209 } 210 211 return step.name; 212 }; 213 214 const getStepIcon = (status: MigrationStep["status"]) => { 215 switch (status) { 216 case "pending": 217 return ( 218 <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 219 <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 220 </div> 221 ); 222 case "in-progress": 223 return ( 224 <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 225 <div class="w-3 h-3 rounded-full bg-blue-500" /> 226 </div> 227 ); 228 case "verifying": 229 return ( 230 <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 231 <div class="w-3 h-3 rounded-full bg-yellow-500" /> 232 </div> 233 ); 234 case "completed": 235 return ( 236 <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 237 <svg 238 class="w-5 h-5 text-white" 239 fill="none" 240 stroke="currentColor" 241 viewBox="0 0 24 24" 242 > 243 <path 244 stroke-linecap="round" 245 stroke-linejoin="round" 246 stroke-width="2" 247 d="M5 13l4 4L19 7" 248 /> 249 </svg> 250 </div> 251 ); 252 case "error": 253 return ( 254 <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 255 <svg 256 class="w-5 h-5 text-white" 257 fill="none" 258 stroke="currentColor" 259 viewBox="0 0 24 24" 260 > 261 <path 262 stroke-linecap="round" 263 stroke-linejoin="round" 264 stroke-width="2" 265 d="M6 18L18 6M6 6l12 12" 266 /> 267 </svg> 268 </div> 269 ); 270 } 271 }; 272 273 const getStepClasses = (status: MigrationStep["status"]) => { 274 const baseClasses = 275 "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 276 switch (status) { 277 case "pending": 278 return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 279 case "in-progress": 280 return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 281 case "verifying": 282 return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 283 case "completed": 284 return `${baseClasses} bg-green-50 dark:bg-green-900`; 285 case "error": 286 return `${baseClasses} bg-red-50 dark:bg-red-900`; 287 } 288 }; 289 290 return ( 291 <div class="space-y-8"> 292 <div class="space-y-4"> 293 {steps.map((step, index) => ( 294 <div key={step.name} class={getStepClasses(step.status)}> 295 {getStepIcon(step.status)} 296 <div class="flex-1"> 297 <p 298 class={`font-medium ${ 299 step.status === "error" 300 ? "text-red-900 dark:text-red-200" 301 : step.status === "completed" 302 ? "text-green-900 dark:text-green-200" 303 : step.status === "in-progress" 304 ? "text-blue-900 dark:text-blue-200" 305 : "text-gray-900 dark:text-gray-200" 306 }`} 307 > 308 {getStepDisplayName(step, index)} 309 </p> 310 {step.error && ( 311 <div class="mt-1"> 312 <p class="text-sm text-red-600 dark:text-red-400"> 313 {(() => { 314 try { 315 const err = JSON.parse(step.error); 316 return err.message || step.error; 317 } catch { 318 return step.error; 319 } 320 })()} 321 </p> 322 {step.isVerificationError && ( 323 <div class="flex space-x-2 mt-2"> 324 <button 325 type="button" 326 onClick={() => client.retryVerification(index, steps)} 327 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" 328 > 329 Retry Verification 330 </button> 331 {showContinueAnyway[index] && ( 332 <button 333 type="button" 334 onClick={() => continueAnyway(index)} 335 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 336 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 337 > 338 Continue Anyway 339 </button> 340 )} 341 </div> 342 )} 343 </div> 344 )} 345 {index === 2 && step.status === "in-progress" && 346 step.name === 347 "Enter the token sent to your email to complete identity migration" && 348 ( 349 <div class="mt-4 space-y-4"> 350 <p class="text-sm text-blue-800 dark:text-blue-200"> 351 Please check your email for the migration token and enter 352 it below: 353 </p> 354 <div class="flex space-x-2"> 355 <input 356 type="text" 357 value={token} 358 onChange={(e) => setToken(e.currentTarget.value)} 359 placeholder="Enter token" 360 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" 361 /> 362 <button 363 type="button" 364 onClick={handleIdentityMigration} 365 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" 366 > 367 Submit Token 368 </button> 369 </div> 370 </div> 371 )} 372 </div> 373 </div> 374 ))} 375 </div> 376 377 {steps[3].status === "completed" && ( 378 <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 379 <p class="text-sm text-green-800 dark:text-green-200 pb-2"> 380 Migration completed successfully! Sign out to finish the process and 381 return home.<br /> 382 Please consider donating to Airport to support server and 383 development costs. 384 </p> 385 <div class="flex space-x-4"> 386 <button 387 type="button" 388 onClick={async () => { 389 try { 390 const response = await fetch("/api/logout", { 391 method: "POST", 392 credentials: "include", 393 }); 394 if (!response.ok) { 395 throw new Error("Logout failed"); 396 } 397 globalThis.location.href = "/"; 398 } catch (error) { 399 console.error("Failed to logout:", error); 400 } 401 }} 402 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" 403 > 404 <svg 405 class="w-5 h-5" 406 fill="none" 407 stroke="currentColor" 408 viewBox="0 0 24 24" 409 > 410 <path 411 stroke-linecap="round" 412 stroke-linejoin="round" 413 stroke-width="2" 414 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" 415 /> 416 </svg> 417 <span>Sign Out</span> 418 </button> 419 <a 420 href="https://ko-fi.com/knotbin" 421 target="_blank" 422 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" 423 > 424 <svg 425 class="w-5 h-5" 426 fill="none" 427 stroke="currentColor" 428 viewBox="0 0 24 24" 429 > 430 <path 431 stroke-linecap="round" 432 stroke-linejoin="round" 433 stroke-width="2" 434 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" 435 /> 436 </svg> 437 <span>Support Us</span> 438 </a> 439 </div> 440 </div> 441 )} 442 </div> 443 ); 444}