Graphical PDS migrator for AT Protocol
1import { useEffect, useState } from "preact/hooks"; 2 3/** 4 * The migration state info. 5 * @type {MigrationStateInfo} 6 */ 7interface MigrationStateInfo { 8 state: "up" | "issue" | "maintenance"; 9 message: string; 10 allowMigration: boolean; 11} 12 13/** 14 * The migration progress props. 15 * @type {MigrationProgressProps} 16 */ 17interface MigrationProgressProps { 18 service: string; 19 handle: string; 20 email: string; 21 password: string; 22 invite?: string; 23} 24 25/** 26 * The migration step. 27 * @type {MigrationStep} 28 */ 29interface MigrationStep { 30 name: string; 31 status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 32 error?: string; 33 isVerificationError?: boolean; 34} 35 36/** 37 * The migration progress component. 38 * @param props - The migration progress props 39 * @returns The migration progress component 40 * @component 41 */ 42export default function MigrationProgress(props: MigrationProgressProps) { 43 const [token, setToken] = useState(""); 44 const [migrationState, setMigrationState] = useState< 45 MigrationStateInfo | null 46 >(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 ); 86 }; 87 88 const validateParams = () => { 89 if (!props.service?.trim()) { 90 updateStepStatus(0, "error", "Missing service URL"); 91 return false; 92 } 93 if (!props.handle?.trim()) { 94 updateStepStatus(0, "error", "Missing handle"); 95 return false; 96 } 97 if (!props.email?.trim()) { 98 updateStepStatus(0, "error", "Missing email"); 99 return false; 100 } 101 if (!props.password?.trim()) { 102 updateStepStatus(0, "error", "Missing password"); 103 return false; 104 } 105 return true; 106 }; 107 108 useEffect(() => { 109 console.log("Starting migration with props:", { 110 service: props.service, 111 handle: props.handle, 112 email: props.email, 113 hasPassword: !!props.password, 114 invite: props.invite, 115 }); 116 117 // Check migration state first 118 const checkMigrationState = async () => { 119 try { 120 const migrationResponse = await fetch("/api/migration-state"); 121 if (migrationResponse.ok) { 122 const migrationData = await migrationResponse.json(); 123 setMigrationState(migrationData); 124 125 if (!migrationData.allowMigration) { 126 updateStepStatus(0, "error", migrationData.message); 127 return; 128 } 129 } 130 } catch (error) { 131 console.error("Failed to check migration state:", error); 132 updateStepStatus(0, "error", "Unable to verify migration availability"); 133 return; 134 } 135 136 if (!validateParams()) { 137 console.log("Parameter validation failed"); 138 return; 139 } 140 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 }); 149 }; 150 151 checkMigrationState(); 152 }, []); 153 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 })); 415 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; 479 } 480 }; 481 482 const retryVerification = async (stepNum: number) => { 483 console.log(`Retrying verification for step ${stepNum + 1}`); 484 await verifyStep(stepNum); 485 }; 486 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 } 496 }; 497 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 } 513 }; 514 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 }; 729 730 return ( 731 <div class="space-y-8"> 732 {/* Migration state alert */} 733 {migrationState && !migrationState.allowMigration && ( 734 <div 735 class={`p-4 rounded-lg border ${ 736 migrationState.state === "maintenance" 737 ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 738 : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 739 }`} 740 > 741 <div class="flex items-center"> 742 <div 743 class={`mr-3 ${ 744 migrationState.state === "maintenance" 745 ? "text-yellow-600 dark:text-yellow-400" 746 : "text-red-600 dark:text-red-400" 747 }`} 748 > 749 {migrationState.state === "maintenance" ? "⚠️" : "🚫"} 750 </div> 751 <div> 752 <h3 class="font-semibold mb-1"> 753 {migrationState.state === "maintenance" 754 ? "Maintenance Mode" 755 : "Service Unavailable"} 756 </h3> 757 <p class="text-sm">{migrationState.message}</p> 758 </div> 759 </div> 760 </div> 761 )} 762 763 <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 ))} 846 </div> 847 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 )} 913 </div> 914 ); 915}