this repo has no description
1<script lang="ts"> 2 import type { InboundMigrationFlow } from '../../lib/migration' 3 import type { ServerDescription } from '../../lib/migration/types' 4 import { _ } from '../../lib/i18n' 5 6 interface Props { 7 flow: InboundMigrationFlow 8 onBack: () => void 9 onComplete: () => void 10 } 11 12 let { flow, onBack, onComplete }: Props = $props() 13 14 let serverInfo = $state<ServerDescription | null>(null) 15 let loading = $state(false) 16 let handleInput = $state('') 17 let passwordInput = $state('') 18 let localPasswordInput = $state('') 19 let understood = $state(false) 20 let selectedDomain = $state('') 21 let handleAvailable = $state<boolean | null>(null) 22 let checkingHandle = $state(false) 23 24 const isResumedMigration = $derived(flow.state.progress.repoImported) 25 26 $effect(() => { 27 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 28 loadServerInfo() 29 } 30 }) 31 32 33 let redirectTriggered = $state(false) 34 35 $effect(() => { 36 if (flow.state.step === 'success' && !redirectTriggered) { 37 redirectTriggered = true 38 setTimeout(() => { 39 onComplete() 40 }, 2000) 41 } 42 }) 43 44 $effect(() => { 45 if (flow.state.step === 'email-verify') { 46 const interval = setInterval(async () => { 47 if (flow.state.emailVerifyToken.trim()) return 48 await flow.checkEmailVerifiedAndProceed() 49 }, 3000) 50 return () => clearInterval(interval) 51 } 52 }) 53 54 async function loadServerInfo() { 55 if (!serverInfo) { 56 serverInfo = await flow.loadLocalServerInfo() 57 if (serverInfo.availableUserDomains.length > 0) { 58 selectedDomain = serverInfo.availableUserDomains[0] 59 } 60 } 61 } 62 63 async function handleLogin(e: Event) { 64 e.preventDefault() 65 loading = true 66 flow.updateField('error', null) 67 68 try { 69 await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined) 70 const username = flow.state.sourceHandle.split('.')[0] 71 handleInput = username 72 flow.updateField('targetPassword', passwordInput) 73 74 if (flow.state.progress.repoImported) { 75 if (!localPasswordInput) { 76 flow.setError('Please enter your password for your new account on this PDS') 77 return 78 } 79 await flow.loadLocalServerInfo() 80 81 try { 82 await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput) 83 await flow.requestPlcToken() 84 flow.setStep('plc-token') 85 } catch (err) { 86 const error = err as Error & { error?: string } 87 if (error.error === 'AccountNotVerified') { 88 flow.setStep('email-verify') 89 } else { 90 throw err 91 } 92 } 93 } else { 94 flow.setStep('choose-handle') 95 } 96 } catch (err) { 97 flow.setError((err as Error).message) 98 } finally { 99 loading = false 100 } 101 } 102 103 async function checkHandle() { 104 if (!handleInput.trim()) return 105 106 const fullHandle = handleInput.includes('.') 107 ? handleInput 108 : `${handleInput}.${selectedDomain}` 109 110 checkingHandle = true 111 handleAvailable = null 112 113 try { 114 handleAvailable = await flow.checkHandleAvailability(fullHandle) 115 } catch { 116 handleAvailable = true 117 } finally { 118 checkingHandle = false 119 } 120 } 121 122 function proceedToReview() { 123 const fullHandle = handleInput.includes('.') 124 ? handleInput 125 : `${handleInput}.${selectedDomain}` 126 127 flow.updateField('targetHandle', fullHandle) 128 flow.setStep('review') 129 } 130 131 async function startMigration() { 132 loading = true 133 try { 134 await flow.startMigration() 135 } catch (err) { 136 flow.setError((err as Error).message) 137 } finally { 138 loading = false 139 } 140 } 141 142 async function submitEmailVerify(e: Event) { 143 e.preventDefault() 144 loading = true 145 try { 146 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 147 } catch (err) { 148 flow.setError((err as Error).message) 149 } finally { 150 loading = false 151 } 152 } 153 154 async function resendEmailVerify() { 155 loading = true 156 try { 157 await flow.resendEmailVerification() 158 flow.setError(null) 159 } catch (err) { 160 flow.setError((err as Error).message) 161 } finally { 162 loading = false 163 } 164 } 165 166 async function submitPlcToken(e: Event) { 167 e.preventDefault() 168 loading = true 169 try { 170 await flow.submitPlcToken(flow.state.plcToken) 171 } catch (err) { 172 flow.setError((err as Error).message) 173 } finally { 174 loading = false 175 } 176 } 177 178 async function resendToken() { 179 loading = true 180 try { 181 await flow.resendPlcToken() 182 flow.setError(null) 183 } catch (err) { 184 flow.setError((err as Error).message) 185 } finally { 186 loading = false 187 } 188 } 189 190 const steps = ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'] 191 function getCurrentStepIndex(): number { 192 switch (flow.state.step) { 193 case 'welcome': 194 case 'source-login': return 0 195 case 'choose-handle': return 1 196 case 'review': return 2 197 case 'migrating': return 3 198 case 'email-verify': return 4 199 case 'plc-token': 200 case 'finalizing': return 5 201 case 'success': return 6 202 default: return 0 203 } 204 } 205</script> 206 207<div class="inbound-wizard"> 208 <div class="step-indicator"> 209 {#each steps as stepName, i} 210 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 211 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 212 <span class="step-label">{stepName}</span> 213 </div> 214 {#if i < steps.length - 1} 215 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 216 {/if} 217 {/each} 218 </div> 219 220 {#if flow.state.error} 221 <div class="message error">{flow.state.error}</div> 222 {/if} 223 224 {#if flow.state.step === 'welcome'} 225 <div class="step-content"> 226 <h2>Migrate Your Account Here</h2> 227 <p>This wizard will help you move your AT Protocol account from another PDS to this one.</p> 228 229 <div class="info-box"> 230 <h3>What will happen:</h3> 231 <ol> 232 <li>Log in to your current PDS</li> 233 <li>Choose your new handle on this server</li> 234 <li>Your repository and blobs will be transferred</li> 235 <li>Verify the migration via email</li> 236 <li>Your identity will be updated to point here</li> 237 </ol> 238 </div> 239 240 <div class="warning-box"> 241 <strong>Before you proceed:</strong> 242 <ul> 243 <li>You need access to the email registered with your current account</li> 244 <li>Large accounts may take several minutes to transfer</li> 245 <li>Your old account will be deactivated after migration</li> 246 </ul> 247 </div> 248 249 <label class="checkbox-label"> 250 <input type="checkbox" bind:checked={understood} /> 251 <span>I understand the risks and want to proceed with migration</span> 252 </label> 253 254 <div class="button-row"> 255 <button class="ghost" onclick={onBack}>Cancel</button> 256 <button disabled={!understood} onclick={() => flow.setStep('source-login')}> 257 Continue 258 </button> 259 </div> 260 </div> 261 262 {:else if flow.state.step === 'source-login'} 263 <div class="step-content"> 264 <h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2> 265 <p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p> 266 267 {#if isResumedMigration} 268 <div class="info-box"> 269 <p>Your migration was interrupted. Log in to both accounts to resume.</p> 270 <p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong><strong>{flow.state.targetHandle}</strong></p> 271 </div> 272 {/if} 273 274 <form onsubmit={handleLogin}> 275 <div class="field"> 276 <label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label> 277 <input 278 id="handle" 279 type="text" 280 placeholder="alice.bsky.social" 281 bind:value={handleInput} 282 disabled={loading} 283 required 284 /> 285 <p class="hint">Your current handle on your existing PDS</p> 286 </div> 287 288 <div class="field"> 289 <label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label> 290 <input 291 id="password" 292 type="password" 293 bind:value={passwordInput} 294 disabled={loading} 295 required 296 /> 297 <p class="hint">Your account password (not an app password)</p> 298 </div> 299 300 {#if flow.state.requires2FA} 301 <div class="field"> 302 <label for="2fa">Two-Factor Code</label> 303 <input 304 id="2fa" 305 type="text" 306 placeholder="Enter code from email" 307 bind:value={flow.state.twoFactorCode} 308 disabled={loading} 309 required 310 /> 311 <p class="hint">Check your email for the verification code</p> 312 </div> 313 {/if} 314 315 {#if isResumedMigration} 316 <hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" /> 317 318 <div class="field"> 319 <label for="local-password">New Account Password</label> 320 <input 321 id="local-password" 322 type="password" 323 placeholder="Password for your new account" 324 bind:value={localPasswordInput} 325 disabled={loading} 326 required 327 /> 328 <p class="hint">The password you set for your account on this PDS</p> 329 </div> 330 {/if} 331 332 <div class="button-row"> 333 <button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button> 334 <button type="submit" disabled={loading}> 335 {loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')} 336 </button> 337 </div> 338 </form> 339 </div> 340 341 {:else if flow.state.step === 'choose-handle'} 342 <div class="step-content"> 343 <h2>Choose Your New Handle</h2> 344 <p>Select a handle for your account on this PDS.</p> 345 346 <div class="current-info"> 347 <span class="label">Migrating from:</span> 348 <span class="value">{flow.state.sourceHandle}</span> 349 </div> 350 351 <div class="field"> 352 <label for="new-handle">New Handle</label> 353 <div class="handle-input-group"> 354 <input 355 id="new-handle" 356 type="text" 357 placeholder="username" 358 bind:value={handleInput} 359 onblur={checkHandle} 360 /> 361 {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 362 <select bind:value={selectedDomain}> 363 {#each serverInfo.availableUserDomains as domain} 364 <option value={domain}>.{domain}</option> 365 {/each} 366 </select> 367 {/if} 368 </div> 369 370 {#if checkingHandle} 371 <p class="hint">Checking availability...</p> 372 {:else if handleAvailable === true} 373 <p class="hint success">Handle is available!</p> 374 {:else if handleAvailable === false} 375 <p class="hint error">Handle is already taken</p> 376 {:else} 377 <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 378 {/if} 379 </div> 380 381 <div class="field"> 382 <label for="email">Email Address</label> 383 <input 384 id="email" 385 type="email" 386 placeholder="you@example.com" 387 bind:value={flow.state.targetEmail} 388 oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 389 required 390 /> 391 </div> 392 393 <div class="field"> 394 <label for="new-password">Password</label> 395 <input 396 id="new-password" 397 type="password" 398 placeholder="Password for your new account" 399 bind:value={flow.state.targetPassword} 400 oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 401 required 402 minlength="8" 403 /> 404 <p class="hint">At least 8 characters</p> 405 </div> 406 407 {#if serverInfo?.inviteCodeRequired} 408 <div class="field"> 409 <label for="invite">Invite Code</label> 410 <input 411 id="invite" 412 type="text" 413 placeholder="Enter invite code" 414 bind:value={flow.state.inviteCode} 415 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 416 required 417 /> 418 </div> 419 {/if} 420 421 <div class="button-row"> 422 <button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button> 423 <button 424 disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false} 425 onclick={proceedToReview} 426 > 427 Continue 428 </button> 429 </div> 430 </div> 431 432 {:else if flow.state.step === 'review'} 433 <div class="step-content"> 434 <h2>Review Migration</h2> 435 <p>Please confirm the details of your migration.</p> 436 437 <div class="review-card"> 438 <div class="review-row"> 439 <span class="label">Current Handle:</span> 440 <span class="value">{flow.state.sourceHandle}</span> 441 </div> 442 <div class="review-row"> 443 <span class="label">New Handle:</span> 444 <span class="value">{flow.state.targetHandle}</span> 445 </div> 446 <div class="review-row"> 447 <span class="label">DID:</span> 448 <span class="value mono">{flow.state.sourceDid}</span> 449 </div> 450 <div class="review-row"> 451 <span class="label">From PDS:</span> 452 <span class="value">{flow.state.sourcePdsUrl}</span> 453 </div> 454 <div class="review-row"> 455 <span class="label">To PDS:</span> 456 <span class="value">{window.location.origin}</span> 457 </div> 458 <div class="review-row"> 459 <span class="label">Email:</span> 460 <span class="value">{flow.state.targetEmail}</span> 461 </div> 462 </div> 463 464 <div class="warning-box"> 465 <strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin 466 transferring. This process cannot be easily undone. 467 </div> 468 469 <div class="button-row"> 470 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button> 471 <button onclick={startMigration} disabled={loading}> 472 {loading ? 'Starting...' : 'Start Migration'} 473 </button> 474 </div> 475 </div> 476 477 {:else if flow.state.step === 'migrating'} 478 <div class="step-content"> 479 <h2>Migration in Progress</h2> 480 <p>Please wait while your account is being transferred...</p> 481 482 <div class="progress-section"> 483 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 484 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 485 <span>Export repository</span> 486 </div> 487 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 488 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 489 <span>Import repository</span> 490 </div> 491 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 492 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 493 <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 494 </div> 495 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 496 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 497 <span>Migrate preferences</span> 498 </div> 499 </div> 500 501 {#if flow.state.progress.blobsTotal > 0} 502 <div class="progress-bar"> 503 <div 504 class="progress-fill" 505 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 506 ></div> 507 </div> 508 {/if} 509 510 <p class="status-text">{flow.state.progress.currentOperation}</p> 511 </div> 512 513 {:else if flow.state.step === 'email-verify'} 514 <div class="step-content"> 515 <h2>{$_('migration.inbound.emailVerify.title')}</h2> 516 <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 517 518 <div class="info-box"> 519 <p> 520 {$_('migration.inbound.emailVerify.hint')} 521 </p> 522 </div> 523 524 {#if flow.state.error} 525 <div class="error-box"> 526 {flow.state.error} 527 </div> 528 {/if} 529 530 <form onsubmit={submitEmailVerify}> 531 <div class="field"> 532 <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 533 <input 534 id="email-verify-token" 535 type="text" 536 placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 537 bind:value={flow.state.emailVerifyToken} 538 oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 539 disabled={loading} 540 required 541 /> 542 </div> 543 544 <div class="button-row"> 545 <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 546 {$_('migration.inbound.emailVerify.resend')} 547 </button> 548 <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 549 {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 550 </button> 551 </div> 552 </form> 553 </div> 554 555 {:else if flow.state.step === 'plc-token'} 556 <div class="step-content"> 557 <h2>Verify Migration</h2> 558 <p>A verification code has been sent to the email registered with your old account.</p> 559 560 <div class="info-box"> 561 <p> 562 This code confirms you have access to the account and authorizes updating your identity 563 to point to this PDS. 564 </p> 565 </div> 566 567 <form onsubmit={submitPlcToken}> 568 <div class="field"> 569 <label for="plc-token">Verification Code</label> 570 <input 571 id="plc-token" 572 type="text" 573 placeholder="Enter code from email" 574 bind:value={flow.state.plcToken} 575 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 576 disabled={loading} 577 required 578 /> 579 </div> 580 581 <div class="button-row"> 582 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 583 Resend Code 584 </button> 585 <button type="submit" disabled={loading || !flow.state.plcToken}> 586 {loading ? 'Verifying...' : 'Complete Migration'} 587 </button> 588 </div> 589 </form> 590 </div> 591 592 {:else if flow.state.step === 'finalizing'} 593 <div class="step-content"> 594 <h2>Finalizing Migration</h2> 595 <p>Please wait while we complete the migration...</p> 596 597 <div class="progress-section"> 598 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 599 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 600 <span>Sign identity update</span> 601 </div> 602 <div class="progress-item" class:completed={flow.state.progress.activated}> 603 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 604 <span>Activate new account</span> 605 </div> 606 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 607 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 608 <span>Deactivate old account</span> 609 </div> 610 </div> 611 612 <p class="status-text">{flow.state.progress.currentOperation}</p> 613 </div> 614 615 {:else if flow.state.step === 'success'} 616 <div class="step-content success-content"> 617 <div class="success-icon"></div> 618 <h2>Migration Complete!</h2> 619 <p>Your account has been successfully migrated to this PDS.</p> 620 621 <div class="success-details"> 622 <div class="detail-row"> 623 <span class="label">Your new handle:</span> 624 <span class="value">{flow.state.targetHandle}</span> 625 </div> 626 <div class="detail-row"> 627 <span class="label">DID:</span> 628 <span class="value mono">{flow.state.sourceDid}</span> 629 </div> 630 </div> 631 632 {#if flow.state.progress.blobsFailed.length > 0} 633 <div class="warning-box"> 634 <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 635 These may be images or other media that are no longer available. 636 </div> 637 {/if} 638 639 <p class="redirect-text">Redirecting to dashboard...</p> 640 </div> 641 642 {:else if flow.state.step === 'error'} 643 <div class="step-content"> 644 <h2>Migration Error</h2> 645 <p>An error occurred during migration.</p> 646 647 <div class="error-box"> 648 {flow.state.error} 649 </div> 650 651 <div class="button-row"> 652 <button class="ghost" onclick={onBack}>Start Over</button> 653 </div> 654 </div> 655 {/if} 656</div> 657 658<style> 659 .inbound-wizard { 660 max-width: 600px; 661 margin: 0 auto; 662 } 663 664 .step-indicator { 665 display: flex; 666 align-items: center; 667 justify-content: center; 668 margin-bottom: var(--space-8); 669 padding: 0 var(--space-4); 670 } 671 672 .step { 673 display: flex; 674 flex-direction: column; 675 align-items: center; 676 gap: var(--space-2); 677 } 678 679 .step-dot { 680 width: 32px; 681 height: 32px; 682 border-radius: 50%; 683 background: var(--bg-secondary); 684 border: 2px solid var(--border); 685 display: flex; 686 align-items: center; 687 justify-content: center; 688 font-size: var(--text-sm); 689 font-weight: var(--font-medium); 690 color: var(--text-secondary); 691 } 692 693 .step.active .step-dot { 694 background: var(--accent); 695 border-color: var(--accent); 696 color: var(--text-inverse); 697 } 698 699 .step.completed .step-dot { 700 background: var(--success-bg); 701 border-color: var(--success-text); 702 color: var(--success-text); 703 } 704 705 .step-label { 706 font-size: var(--text-xs); 707 color: var(--text-secondary); 708 } 709 710 .step.active .step-label { 711 color: var(--accent); 712 font-weight: var(--font-medium); 713 } 714 715 .step-line { 716 flex: 1; 717 height: 2px; 718 background: var(--border); 719 margin: 0 var(--space-2); 720 margin-bottom: var(--space-6); 721 min-width: 20px; 722 } 723 724 .step-line.completed { 725 background: var(--success-text); 726 } 727 728 .step-content { 729 background: var(--bg-secondary); 730 border-radius: var(--radius-xl); 731 padding: var(--space-6); 732 } 733 734 .step-content h2 { 735 margin: 0 0 var(--space-3) 0; 736 } 737 738 .step-content > p { 739 color: var(--text-secondary); 740 margin: 0 0 var(--space-5) 0; 741 } 742 743 .info-box { 744 background: var(--accent-muted); 745 border: 1px solid var(--accent); 746 border-radius: var(--radius-lg); 747 padding: var(--space-5); 748 margin-bottom: var(--space-5); 749 } 750 751 .info-box h3 { 752 margin: 0 0 var(--space-3) 0; 753 font-size: var(--text-base); 754 } 755 756 .info-box ol, .info-box ul { 757 margin: 0; 758 padding-left: var(--space-5); 759 } 760 761 .info-box li { 762 margin-bottom: var(--space-2); 763 color: var(--text-secondary); 764 } 765 766 .info-box p { 767 margin: 0; 768 color: var(--text-secondary); 769 } 770 771 .warning-box { 772 background: var(--warning-bg); 773 border: 1px solid var(--warning-border); 774 border-radius: var(--radius-lg); 775 padding: var(--space-5); 776 margin-bottom: var(--space-5); 777 font-size: var(--text-sm); 778 } 779 780 .warning-box strong { 781 color: var(--warning-text); 782 } 783 784 .warning-box ul { 785 margin: var(--space-3) 0 0 0; 786 padding-left: var(--space-5); 787 } 788 789 .error-box { 790 background: var(--error-bg); 791 border: 1px solid var(--error-border); 792 border-radius: var(--radius-lg); 793 padding: var(--space-5); 794 margin-bottom: var(--space-5); 795 color: var(--error-text); 796 } 797 798 .checkbox-label { 799 display: inline-flex; 800 align-items: flex-start; 801 gap: var(--space-3); 802 cursor: pointer; 803 margin-bottom: var(--space-5); 804 text-align: left; 805 } 806 807 .checkbox-label input[type="checkbox"] { 808 width: 18px; 809 height: 18px; 810 margin: 0; 811 flex-shrink: 0; 812 } 813 814 .button-row { 815 display: flex; 816 gap: var(--space-3); 817 justify-content: flex-end; 818 margin-top: var(--space-5); 819 } 820 821 .field { 822 margin-bottom: var(--space-5); 823 } 824 825 .field label { 826 display: block; 827 margin-bottom: var(--space-2); 828 font-weight: var(--font-medium); 829 } 830 831 .field input, .field select { 832 width: 100%; 833 padding: var(--space-3); 834 border: 1px solid var(--border); 835 border-radius: var(--radius-md); 836 background: var(--bg-primary); 837 color: var(--text-primary); 838 } 839 840 .field input:focus, .field select:focus { 841 outline: none; 842 border-color: var(--accent); 843 } 844 845 .hint { 846 font-size: var(--text-sm); 847 color: var(--text-secondary); 848 margin: var(--space-2) 0 0 0; 849 } 850 851 .hint.success { 852 color: var(--success-text); 853 } 854 855 .hint.error { 856 color: var(--error-text); 857 } 858 859 .handle-input-group { 860 display: flex; 861 gap: var(--space-2); 862 } 863 864 .handle-input-group input { 865 flex: 1; 866 } 867 868 .handle-input-group select { 869 width: auto; 870 } 871 872 .current-info { 873 background: var(--bg-primary); 874 border-radius: var(--radius-lg); 875 padding: var(--space-4); 876 margin-bottom: var(--space-5); 877 display: flex; 878 justify-content: space-between; 879 } 880 881 .current-info .label { 882 color: var(--text-secondary); 883 } 884 885 .current-info .value { 886 font-weight: var(--font-medium); 887 } 888 889 .review-card { 890 background: var(--bg-primary); 891 border-radius: var(--radius-lg); 892 padding: var(--space-4); 893 margin-bottom: var(--space-5); 894 } 895 896 .review-row { 897 display: flex; 898 justify-content: space-between; 899 padding: var(--space-3) 0; 900 border-bottom: 1px solid var(--border); 901 } 902 903 .review-row:last-child { 904 border-bottom: none; 905 } 906 907 .review-row .label { 908 color: var(--text-secondary); 909 } 910 911 .review-row .value { 912 font-weight: var(--font-medium); 913 text-align: right; 914 word-break: break-all; 915 } 916 917 .review-row .value.mono { 918 font-family: var(--font-mono); 919 font-size: var(--text-sm); 920 } 921 922 .progress-section { 923 margin-bottom: var(--space-5); 924 } 925 926 .progress-item { 927 display: flex; 928 align-items: center; 929 gap: var(--space-3); 930 padding: var(--space-3) 0; 931 color: var(--text-secondary); 932 } 933 934 .progress-item.completed { 935 color: var(--success-text); 936 } 937 938 .progress-item.active { 939 color: var(--accent); 940 } 941 942 .progress-item .icon { 943 width: 24px; 944 text-align: center; 945 } 946 947 .progress-bar { 948 height: 8px; 949 background: var(--bg-primary); 950 border-radius: 4px; 951 overflow: hidden; 952 margin-bottom: var(--space-4); 953 } 954 955 .progress-fill { 956 height: 100%; 957 background: var(--accent); 958 transition: width 0.3s ease; 959 } 960 961 .status-text { 962 text-align: center; 963 color: var(--text-secondary); 964 font-size: var(--text-sm); 965 } 966 967 .success-content { 968 text-align: center; 969 } 970 971 .success-icon { 972 width: 64px; 973 height: 64px; 974 background: var(--success-bg); 975 color: var(--success-text); 976 border-radius: 50%; 977 display: flex; 978 align-items: center; 979 justify-content: center; 980 font-size: var(--text-2xl); 981 margin: 0 auto var(--space-5) auto; 982 } 983 984 .success-details { 985 background: var(--bg-primary); 986 border-radius: var(--radius-lg); 987 padding: var(--space-4); 988 margin: var(--space-5) 0; 989 text-align: left; 990 } 991 992 .success-details .detail-row { 993 display: flex; 994 justify-content: space-between; 995 padding: var(--space-2) 0; 996 } 997 998 .success-details .label { 999 color: var(--text-secondary); 1000 } 1001 1002 .success-details .value { 1003 font-weight: var(--font-medium); 1004 } 1005 1006 .success-details .value.mono { 1007 font-family: var(--font-mono); 1008 font-size: var(--text-sm); 1009 } 1010 1011 .redirect-text { 1012 color: var(--text-secondary); 1013 font-style: italic; 1014 } 1015 1016 .message.error { 1017 background: var(--error-bg); 1018 border: 1px solid var(--error-border); 1019 color: var(--error-text); 1020 padding: var(--space-4); 1021 border-radius: var(--radius-lg); 1022 margin-bottom: var(--space-5); 1023 } 1024</style>