Graphical PDS migrator for AT Protocol
1import { useEffect, useState } from "preact/hooks"; 2import { IS_BROWSER } from "fresh/runtime"; 3 4/** 5 * The migration setup props. 6 * @type {MigrationSetupProps} 7 */ 8interface MigrationSetupProps { 9 service?: string | null; 10 handle?: string | null; 11 email?: string | null; 12 invite?: string | null; 13} 14 15/** 16 * The server description. 17 * @type {ServerDescription} 18 */ 19interface ServerDescription { 20 inviteCodeRequired: boolean; 21 availableUserDomains: string[]; 22} 23 24/** 25 * The user passport. 26 * @type {UserPassport} 27 */ 28interface UserPassport { 29 did: string; 30 handle: string; 31 pds: string; 32 createdAt?: string; 33} 34 35/** 36 * The migration state info. 37 * @type {MigrationStateInfo} 38 */ 39interface MigrationStateInfo { 40 state: "up" | "issue" | "maintenance"; 41 message: string; 42 allowMigration: boolean; 43} 44 45/** 46 * The migration setup component. 47 * @param props - The migration setup props 48 * @returns The migration setup component 49 * @component 50 */ 51export default function MigrationSetup(props: MigrationSetupProps) { 52 const [service, setService] = useState(props.service || ""); 53 const [handlePrefix, setHandlePrefix] = useState( 54 props.handle?.split(".")[0] || "", 55 ); 56 const [selectedDomain, setSelectedDomain] = useState(""); 57 const [email, setEmail] = useState(props.email || ""); 58 const [password, setPassword] = useState(""); 59 const [invite, setInvite] = useState(props.invite || ""); 60 const [inviteRequired, setInviteRequired] = useState<boolean | null>(null); 61 const [availableDomains, setAvailableDomains] = useState<string[]>([]); 62 const [error, setError] = useState(""); 63 const [isLoading, setIsLoading] = useState(false); 64 const [showConfirmation, setShowConfirmation] = useState(false); 65 const [confirmationText, setConfirmationText] = useState(""); 66 const [passport, setPassport] = useState<UserPassport | null>(null); 67 const [migrationState, setMigrationState] = useState< 68 MigrationStateInfo | null 69 >(null); 70 71 const ensureServiceUrl = (url: string): string => { 72 if (!url) return url; 73 try { 74 // If it already has a protocol, return as is 75 new URL(url); 76 return url; 77 } catch { 78 // If no protocol, add https:// 79 return `https://${url}`; 80 } 81 }; 82 83 useEffect(() => { 84 if (!IS_BROWSER) return; 85 86 const fetchInitialData = async () => { 87 try { 88 // Check migration state first 89 const migrationResponse = await fetch("/api/migration-state"); 90 if (migrationResponse.ok) { 91 const migrationData = await migrationResponse.json(); 92 setMigrationState(migrationData); 93 } 94 95 // Fetch user passport 96 const response = await fetch("/api/me", { 97 credentials: "include", 98 }); 99 if (!response.ok) { 100 throw new Error("Failed to fetch user profile"); 101 } 102 const userData = await response.json(); 103 if (userData) { 104 // Get PDS URL from the current service 105 const pdsResponse = await fetch( 106 `/api/resolve-pds?did=${userData.did}`, 107 ); 108 const pdsData = await pdsResponse.json(); 109 110 setPassport({ 111 did: userData.did, 112 handle: userData.handle, 113 pds: pdsData.pds || "Unknown", 114 createdAt: new Date().toISOString(), // TODO: Get actual creation date from API 115 }); 116 } 117 } catch (error) { 118 console.error("Failed to fetch initial data:", error); 119 } 120 }; 121 122 fetchInitialData(); 123 }, []); 124 125 const checkServerDescription = async (serviceUrl: string) => { 126 try { 127 setIsLoading(true); 128 const response = await fetch( 129 `${serviceUrl}/xrpc/com.atproto.server.describeServer`, 130 ); 131 if (!response.ok) { 132 throw new Error("Failed to fetch server description"); 133 } 134 const data: ServerDescription = await response.json(); 135 setInviteRequired(data.inviteCodeRequired ?? false); 136 const domains = data.availableUserDomains || []; 137 setAvailableDomains(domains); 138 if (domains.length === 1) { 139 setSelectedDomain(domains[0]); 140 } else if (domains.length > 1) { 141 setSelectedDomain(domains[0]); 142 } 143 } catch (err) { 144 console.error("Failed to check server description:", err); 145 setError( 146 "Failed to connect to server. Please check the URL and try again.", 147 ); 148 setInviteRequired(false); 149 setAvailableDomains([]); 150 } finally { 151 setIsLoading(false); 152 } 153 }; 154 155 const handleServiceChange = (value: string) => { 156 const urlWithProtocol = ensureServiceUrl(value); 157 setService(urlWithProtocol); 158 setError(""); 159 if (urlWithProtocol) { 160 checkServerDescription(urlWithProtocol); 161 } else { 162 setAvailableDomains([]); 163 setSelectedDomain(""); 164 } 165 }; 166 167 const handleSubmit = (e: Event) => { 168 e.preventDefault(); 169 170 // Check migration state first 171 if (migrationState && !migrationState.allowMigration) { 172 setError(migrationState.message); 173 return; 174 } 175 176 if (!service || !handlePrefix || !email || !password) { 177 setError("Please fill in all required fields"); 178 return; 179 } 180 181 if (inviteRequired && !invite) { 182 setError("Invite code is required for this server"); 183 return; 184 } 185 186 setShowConfirmation(true); 187 }; 188 189 const handleConfirmation = () => { 190 // Double-check migration state before proceeding 191 if (migrationState && !migrationState.allowMigration) { 192 setError(migrationState.message); 193 return; 194 } 195 196 if (confirmationText !== "MIGRATE") { 197 setError("Please type 'MIGRATE' to confirm"); 198 return; 199 } 200 201 const fullHandle = `${handlePrefix}${ 202 availableDomains.length === 1 203 ? availableDomains[0] 204 : availableDomains.length > 1 205 ? selectedDomain 206 : ".example.com" 207 }`; 208 209 // Redirect to progress page with parameters 210 const params = new URLSearchParams({ 211 service, 212 handle: fullHandle, 213 email, 214 password, 215 ...(invite ? { invite } : {}), 216 }); 217 globalThis.location.href = `/migrate/progress?${params.toString()}`; 218 }; 219 220 return ( 221 <div class="max-w-2xl mx-auto p-6 bg-gradient-to-b from-blue-50 to-white dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl relative overflow-hidden"> 222 {/* Decorative airport elements */} 223 <div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div> 224 <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono"> 225 TERMINAL 1 226 </div> 227 <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono"> 228 GATE M1 229 </div> 230 231 {/* Migration state alert */} 232 {migrationState && !migrationState.allowMigration && ( 233 <div 234 class={`mb-6 mt-4 p-4 rounded-lg border ${ 235 migrationState.state === "maintenance" 236 ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 237 : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 238 }`} 239 > 240 <div class="flex items-center"> 241 <div 242 class={`mr-3 ${ 243 migrationState.state === "maintenance" 244 ? "text-yellow-600 dark:text-yellow-400" 245 : "text-red-600 dark:text-red-400" 246 }`} 247 > 248 {migrationState.state === "maintenance" ? "⚠️" : "🚫"} 249 </div> 250 <div> 251 <h3 class="font-semibold mb-1"> 252 {migrationState.state === "maintenance" 253 ? "Maintenance Mode" 254 : "Service Unavailable"} 255 </h3> 256 <p class="text-sm">{migrationState.message}</p> 257 </div> 258 </div> 259 </div> 260 )} 261 262 <div class="text-center mb-8 relative"> 263 <p class="text-gray-600 dark:text-gray-400 mt-4"> 264 Please complete your migration check-in 265 </p> 266 <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono"> 267 FLIGHT: MIG-2024 268 </div> 269 </div> 270 271 {/* Passport Section */} 272 {passport && ( 273 <div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700"> 274 <div class="flex items-center justify-between mb-4"> 275 <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> 276 Current Passport 277 </h3> 278 <div class="text-xs text-gray-500 dark:text-gray-400 font-mono"> 279 ISSUED: {new Date().toLocaleDateString()} 280 </div> 281 </div> 282 <div class="grid grid-cols-2 gap-4 text-sm"> 283 <div> 284 <div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div> 285 <div class="font-mono text-gray-900 dark:text-white"> 286 {passport.handle} 287 </div> 288 </div> 289 <div> 290 <div class="text-gray-500 dark:text-gray-400 mb-1">DID</div> 291 <div class="font-mono text-gray-900 dark:text-white break-all"> 292 {passport.did} 293 </div> 294 </div> 295 <div> 296 <div class="text-gray-500 dark:text-gray-400 mb-1"> 297 Citizen of PDS 298 </div> 299 <div class="font-mono text-gray-900 dark:text-white break-all"> 300 {passport.pds} 301 </div> 302 </div> 303 <div> 304 <div class="text-gray-500 dark:text-gray-400 mb-1"> 305 Account Age 306 </div> 307 <div class="font-mono text-gray-900 dark:text-white"> 308 {passport.createdAt 309 ? new Date(passport.createdAt).toLocaleDateString() 310 : "Unknown"} 311 </div> 312 </div> 313 </div> 314 </div> 315 )} 316 317 <form onSubmit={handleSubmit} class="space-y-6"> 318 {error && ( 319 <div class="bg-red-50 dark:bg-red-900 rounded-lg "> 320 <p class="text-red-800 dark:text-red-200 flex items-center"> 321 <svg 322 class="w-5 h-5 mr-2" 323 fill="none" 324 stroke="currentColor" 325 viewBox="0 0 24 24" 326 > 327 <path 328 stroke-linecap="round" 329 stroke-linejoin="round" 330 stroke-width="2" 331 d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" 332 > 333 </path> 334 </svg> 335 {error} 336 </p> 337 </div> 338 )} 339 340 <div class="space-y-4"> 341 <div> 342 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 343 Destination Server 344 <span class="text-xs text-gray-500 ml-1"> 345 (Final Destination) 346 </span> 347 </label> 348 <div class="relative"> 349 <input 350 type="url" 351 value={service} 352 onChange={(e) => handleServiceChange(e.currentTarget.value)} 353 placeholder="https://example.com" 354 required 355 disabled={isLoading} 356 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed pl-10" 357 /> 358 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 359 <svg 360 class="h-5 w-5 text-gray-400" 361 fill="none" 362 stroke="currentColor" 363 viewBox="0 0 24 24" 364 > 365 <path 366 stroke-linecap="round" 367 stroke-linejoin="round" 368 stroke-width="2" 369 d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" 370 > 371 </path> 372 </svg> 373 </div> 374 </div> 375 {isLoading && ( 376 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center"> 377 <svg 378 class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" 379 fill="none" 380 viewBox="0 0 24 24" 381 > 382 <circle 383 class="opacity-25" 384 cx="12" 385 cy="12" 386 r="10" 387 stroke="currentColor" 388 stroke-width="4" 389 > 390 </circle> 391 <path 392 class="opacity-75" 393 fill="currentColor" 394 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 395 > 396 </path> 397 </svg> 398 Verifying destination server... 399 </p> 400 )} 401 </div> 402 403 <div> 404 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 405 New Account Handle 406 <span class="text-xs text-gray-500 ml-1">(Passport ID)</span> 407 <div class="inline-block relative group ml-2"> 408 <svg 409 class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" 410 fill="currentColor" 411 viewBox="0 0 20 20" 412 > 413 <path 414 fill-rule="evenodd" 415 d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" 416 clip-rule="evenodd" 417 /> 418 </svg> 419 <div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10"> 420 You can change your handle to a custom domain later 421 <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"> 422 </div> 423 </div> 424 </div> 425 </label> 426 <div class="mt-1 relative w-full"> 427 <div class="flex rounded-md shadow-sm w-full"> 428 <div class="relative flex-1"> 429 <input 430 type="text" 431 value={handlePrefix} 432 onChange={(e) => setHandlePrefix(e.currentTarget.value)} 433 placeholder="username" 434 required 435 class="w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10 pr-32" 436 style={{ fontFamily: "inherit" }} 437 /> 438 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 439 <svg 440 class="h-5 w-5 text-gray-400" 441 fill="none" 442 stroke="currentColor" 443 viewBox="0 0 24 24" 444 > 445 <path 446 stroke-linecap="round" 447 stroke-linejoin="round" 448 stroke-width="2" 449 d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" 450 > 451 </path> 452 </svg> 453 </div> 454 {/* Suffix for domain ending */} 455 {availableDomains.length > 0 456 ? ( 457 availableDomains.length === 1 458 ? ( 459 <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base"> 460 {availableDomains[0]} 461 </span> 462 ) 463 : ( 464 <span class="absolute inset-y-0 right-0 flex items-center pr-1"> 465 <select 466 value={selectedDomain} 467 onChange={(e) => 468 setSelectedDomain(e.currentTarget.value)} 469 class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2" 470 style={{ appearance: "none" }} 471 > 472 {availableDomains.map((domain) => ( 473 <option key={domain} value={domain}> 474 {domain} 475 </option> 476 ))} 477 </select> 478 </span> 479 ) 480 ) 481 : ( 482 <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base"> 483 .example.com 484 </span> 485 )} 486 </div> 487 </div> 488 </div> 489 </div> 490 491 <div> 492 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 493 Email 494 <span class="text-xs text-gray-500 ml-1"> 495 (Emergency Contact) 496 </span> 497 </label> 498 <div class="relative"> 499 <input 500 type="email" 501 value={email} 502 onChange={(e) => setEmail(e.currentTarget.value)} 503 required 504 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 505 /> 506 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 507 <svg 508 class="h-5 w-5 text-gray-400" 509 fill="none" 510 stroke="currentColor" 511 viewBox="0 0 24 24" 512 > 513 <path 514 stroke-linecap="round" 515 stroke-linejoin="round" 516 stroke-width="2" 517 d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" 518 > 519 </path> 520 </svg> 521 </div> 522 </div> 523 </div> 524 525 <div> 526 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 527 New Account Password 528 <span class="text-xs text-gray-500 ml-1"> 529 (Security Clearance) 530 </span> 531 </label> 532 <div class="relative"> 533 <input 534 type="password" 535 value={password} 536 onChange={(e) => setPassword(e.currentTarget.value)} 537 required 538 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 539 /> 540 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 541 <svg 542 class="h-5 w-5 text-gray-400" 543 fill="none" 544 stroke="currentColor" 545 viewBox="0 0 24 24" 546 > 547 <path 548 stroke-linecap="round" 549 stroke-linejoin="round" 550 stroke-width="2" 551 d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" 552 > 553 </path> 554 </svg> 555 </div> 556 </div> 557 </div> 558 559 {inviteRequired && ( 560 <div> 561 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 562 Invitation Code 563 <span class="text-xs text-gray-500 ml-1">(Boarding Pass)</span> 564 </label> 565 <div class="relative"> 566 <input 567 type="text" 568 value={invite} 569 onChange={(e) => setInvite(e.currentTarget.value)} 570 required 571 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 572 /> 573 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 574 <svg 575 class="h-5 w-5 text-gray-400" 576 fill="none" 577 stroke="currentColor" 578 viewBox="0 0 24 24" 579 > 580 <path 581 stroke-linecap="round" 582 stroke-linejoin="round" 583 stroke-width="2" 584 d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" 585 > 586 </path> 587 </svg> 588 </div> 589 </div> 590 </div> 591 )} 592 </div> 593 594 <button 595 type="submit" 596 disabled={isLoading || 597 Boolean(migrationState && !migrationState.allowMigration)} 598 class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" 599 > 600 <svg 601 class="w-5 h-5 mr-2" 602 fill="none" 603 stroke="currentColor" 604 viewBox="0 0 24 24" 605 > 606 <path 607 stroke-linecap="round" 608 stroke-linejoin="round" 609 stroke-width="2" 610 d="M5 13l4 4L19 7" 611 > 612 </path> 613 </svg> 614 Proceed to Check-in 615 </button> 616 </form> 617 618 {showConfirmation && ( 619 <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> 620 <div 621 class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin" 622 style={{ 623 boxShadow: 624 "0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)", 625 }} 626 > 627 <div class="absolute -top-8 left-1/2 -translate-x-1/2"> 628 <div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short"> 629 <svg 630 class="w-8 h-8 text-white" 631 fill="none" 632 stroke="currentColor" 633 viewBox="0 0 24 24" 634 > 635 <path 636 stroke-linecap="round" 637 stroke-linejoin="round" 638 stroke-width="2" 639 d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" 640 /> 641 </svg> 642 </div> 643 </div> 644 <div class="text-center mb-4 mt-6"> 645 <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide"> 646 Final Boarding Call 647 </h3> 648 <p class="text-gray-700 dark:text-gray-300 mb-2 text-base"> 649 <span class="font-semibold text-red-500">Warning:</span>{" "} 650 This migration is <strong>irreversible</strong>{" "} 651 if coming from Bluesky servers.<br />Bluesky does not recommend 652 it for main accounts. Migrate at your own risk. We reccomend 653 backing up your data before proceeding. 654 </p> 655 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base"> 656 Please type{" "} 657 <span class="font-mono font-bold text-blue-600">MIGRATE</span> 658 {" "} 659 below to confirm and proceed. 660 </p> 661 </div> 662 <div class="relative"> 663 <input 664 type="text" 665 value={confirmationText} 666 onInput={(e) => setConfirmationText(e.currentTarget.value)} 667 placeholder="Type MIGRATE to confirm" 668 class="w-full p-3 rounded-md bg-white dark:bg-gray-700 shadow focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 dark:text-white text-center font-mono text-lg border border-red-200 dark:border-red-700 transition" 669 autoFocus 670 /> 671 </div> 672 <div class="flex justify-end space-x-4 mt-6"> 673 <button 674 onClick={() => setShowConfirmation(false)} 675 class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md flex items-center transition" 676 type="button" 677 > 678 <svg 679 class="w-5 h-5 mr-2" 680 fill="none" 681 stroke="currentColor" 682 viewBox="0 0 24 24" 683 > 684 <path 685 stroke-linecap="round" 686 stroke-linejoin="round" 687 stroke-width="2" 688 d="M6 18L18 6M6 6l12 12" 689 > 690 </path> 691 </svg> 692 Cancel 693 </button> 694 <button 695 onClick={handleConfirmation} 696 class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${ 697 confirmationText.trim().toLowerCase() === "migrate" 698 ? "bg-red-600 text-white hover:bg-red-700 cursor-pointer" 699 : "bg-red-300 text-white cursor-not-allowed" 700 }`} 701 type="button" 702 disabled={confirmationText.trim().toLowerCase() !== "migrate"} 703 > 704 <svg 705 class="w-5 h-5 mr-2" 706 fill="none" 707 stroke="currentColor" 708 viewBox="0 0 24 24" 709 > 710 <path 711 stroke-linecap="round" 712 stroke-linejoin="round" 713 stroke-width="2" 714 d="M5 13l4 4L19 7" 715 > 716 </path> 717 </svg> 718 Confirm Migration 719 </button> 720 </div> 721 </div> 722 </div> 723 )} 724 </div> 725 ); 726}