this repo has no description
1<script lang="ts"> 2 import type { InboundMigrationFlow } from '../../lib/migration' 3 import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 4 import { getErrorMessage } from '../../lib/migration/types' 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 import { _ } from '../../lib/i18n' 7 import '../../styles/migration.css' 8 9 interface ResumeInfo { 10 direction: 'inbound' | 'outbound' 11 sourceHandle: string 12 targetHandle: string 13 sourcePdsUrl: string 14 targetPdsUrl: string 15 targetEmail: string 16 authMethod?: AuthMethod 17 progressSummary: string 18 step: string 19 } 20 21 interface Props { 22 flow: InboundMigrationFlow 23 resumeInfo?: ResumeInfo | null 24 onBack: () => void 25 onComplete: () => void 26 } 27 28 let { flow, resumeInfo = null, onBack, onComplete }: Props = $props() 29 30 let serverInfo = $state<ServerDescription | null>(null) 31 let loading = $state(false) 32 let handleInput = $state('') 33 let localPasswordInput = $state('') 34 let understood = $state(false) 35 let selectedDomain = $state('') 36 let handleAvailable = $state<boolean | null>(null) 37 let checkingHandle = $state(false) 38 let selectedAuthMethod = $state<AuthMethod>('password') 39 let passkeyName = $state('') 40 let appPasswordCopied = $state(false) 41 let appPasswordAcknowledged = $state(false) 42 43 const isResuming = $derived(flow.state.needsReauth === true) 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) 45 46 $effect(() => { 47 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 48 loadServerInfo() 49 } 50 if (flow.state.step === 'choose-handle') { 51 handleInput = '' 52 handleAvailable = null 53 } 54 if (flow.state.step === 'source-handle' && resumeInfo) { 55 handleInput = resumeInfo.sourceHandle 56 selectedAuthMethod = resumeInfo.authMethod ?? 'password' 57 } 58 }) 59 60 61 let redirectTriggered = $state(false) 62 63 $effect(() => { 64 if (flow.state.step === 'success' && !redirectTriggered) { 65 redirectTriggered = true 66 setTimeout(() => { 67 onComplete() 68 }, 2000) 69 } 70 }) 71 72 $effect(() => { 73 if (flow.state.step === 'email-verify') { 74 const interval = setInterval(async () => { 75 if (flow.state.emailVerifyToken.trim()) return 76 await flow.checkEmailVerifiedAndProceed() 77 }, 3000) 78 return () => clearInterval(interval) 79 } 80 }) 81 82 async function loadServerInfo() { 83 if (!serverInfo) { 84 serverInfo = await flow.loadLocalServerInfo() 85 if (serverInfo.availableUserDomains.length > 0) { 86 selectedDomain = serverInfo.availableUserDomains[0] 87 } 88 } 89 } 90 91 async function checkHandle() { 92 if (!handleInput.trim()) return 93 94 const fullHandle = handleInput.includes('.') 95 ? handleInput 96 : `${handleInput}.${selectedDomain}` 97 98 checkingHandle = true 99 handleAvailable = null 100 101 try { 102 handleAvailable = await flow.checkHandleAvailability(fullHandle) 103 } catch { 104 handleAvailable = true 105 } finally { 106 checkingHandle = false 107 } 108 } 109 110 function proceedToReview() { 111 const fullHandle = handleInput.includes('.') 112 ? handleInput 113 : `${handleInput}.${selectedDomain}` 114 115 flow.updateField('targetHandle', fullHandle) 116 flow.setStep('review') 117 } 118 119 async function startMigration() { 120 loading = true 121 try { 122 await flow.startMigration() 123 } catch (err) { 124 flow.setError(getErrorMessage(err)) 125 } finally { 126 loading = false 127 } 128 } 129 130 async function submitEmailVerify(e: Event) { 131 e.preventDefault() 132 loading = true 133 try { 134 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 135 } catch (err) { 136 flow.setError(getErrorMessage(err)) 137 } finally { 138 loading = false 139 } 140 } 141 142 async function resendEmailVerify() { 143 loading = true 144 try { 145 await flow.resendEmailVerification() 146 flow.setError(null) 147 } catch (err) { 148 flow.setError(getErrorMessage(err)) 149 } finally { 150 loading = false 151 } 152 } 153 154 async function submitPlcToken(e: Event) { 155 e.preventDefault() 156 loading = true 157 try { 158 await flow.submitPlcToken(flow.state.plcToken) 159 } catch (err) { 160 flow.setError(getErrorMessage(err)) 161 } finally { 162 loading = false 163 } 164 } 165 166 async function resendToken() { 167 loading = true 168 try { 169 await flow.resendPlcToken() 170 flow.setError(null) 171 } catch (err) { 172 flow.setError(getErrorMessage(err)) 173 } finally { 174 loading = false 175 } 176 } 177 178 async function completeDidWeb() { 179 loading = true 180 try { 181 await flow.completeDidWebMigration() 182 } catch (err) { 183 flow.setError(getErrorMessage(err)) 184 } finally { 185 loading = false 186 } 187 } 188 189 async function registerPasskey() { 190 loading = true 191 flow.setError(null) 192 193 try { 194 if (!window.PublicKeyCredential) { 195 throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 196 } 197 198 const { options } = await flow.startPasskeyRegistration() 199 200 const publicKeyOptions = prepareWebAuthnCreationOptions( 201 options as { publicKey: Record<string, unknown> } 202 ) 203 const credential = await navigator.credentials.create({ 204 publicKey: publicKeyOptions, 205 }) 206 207 if (!credential) { 208 throw new Error('Passkey creation was cancelled') 209 } 210 211 const publicKeyCredential = credential as PublicKeyCredential 212 const response = publicKeyCredential.response as AuthenticatorAttestationResponse 213 214 const credentialData = { 215 id: publicKeyCredential.id, 216 rawId: base64UrlEncode(publicKeyCredential.rawId), 217 type: publicKeyCredential.type, 218 response: { 219 clientDataJSON: base64UrlEncode(response.clientDataJSON), 220 attestationObject: base64UrlEncode(response.attestationObject), 221 }, 222 } 223 224 await flow.completePasskeyRegistration(credentialData, passkeyName || undefined) 225 } catch (err) { 226 const message = getErrorMessage(err) 227 if (message.includes('cancelled') || message.includes('AbortError')) { 228 flow.setError('Passkey registration was cancelled. Please try again.') 229 } else { 230 flow.setError(message) 231 } 232 } finally { 233 loading = false 234 } 235 } 236 237 function copyAppPassword() { 238 if (flow.state.generatedAppPassword) { 239 navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 appPasswordCopied = true 241 } 242 } 243 244 async function handleProceedFromAppPassword() { 245 loading = true 246 try { 247 await flow.proceedFromAppPassword() 248 } catch (err) { 249 flow.setError(getErrorMessage(err)) 250 } finally { 251 loading = false 252 } 253 } 254 255 async function handleSourceHandleSubmit(e: Event) { 256 e.preventDefault() 257 loading = true 258 flow.updateField('error', null) 259 260 try { 261 await flow.initiateOAuthLogin(handleInput) 262 } catch (err) { 263 flow.setError(getErrorMessage(err)) 264 } finally { 265 loading = false 266 } 267 } 268 269 function proceedToReviewWithAuth() { 270 const fullHandle = handleInput.includes('.') 271 ? handleInput 272 : `${handleInput}.${selectedDomain}` 273 274 flow.updateField('targetHandle', fullHandle) 275 flow.updateField('authMethod', selectedAuthMethod) 276 flow.setStep('review') 277 } 278 279 const steps = $derived(isDidWeb 280 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete'] 281 : flow.state.authMethod === 'passkey' 282 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete'] 283 : ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']) 284 285 function getCurrentStepIndex(): number { 286 const isPasskey = flow.state.authMethod === 'passkey' 287 switch (flow.state.step) { 288 case 'welcome': 289 case 'source-handle': return 0 290 case 'choose-handle': return 1 291 case 'review': return 2 292 case 'migrating': return 3 293 case 'email-verify': return 4 294 case 'passkey-setup': return isPasskey ? 5 : 4 295 case 'app-password': return 6 296 case 'plc-token': 297 case 'did-web-update': 298 case 'finalizing': return isPasskey ? 7 : 5 299 case 'success': return isPasskey ? 8 : 6 300 default: return 0 301 } 302 } 303</script> 304 305<div class="migration-wizard"> 306 <div class="step-indicator"> 307 {#each steps as _, i} 308 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 309 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 310 </div> 311 {#if i < steps.length - 1} 312 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 313 {/if} 314 {/each} 315 </div> 316 <div class="current-step-label"> 317 <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 318 </div> 319 320 {#if flow.state.error} 321 <div class="message error">{flow.state.error}</div> 322 {/if} 323 324 {#if flow.state.step === 'welcome'} 325 <div class="step-content"> 326 <h2>{$_('migration.inbound.welcome.title')}</h2> 327 <p>{$_('migration.inbound.welcome.desc')}</p> 328 329 <div class="info-box"> 330 <h3>{$_('migration.inbound.common.whatWillHappen')}</h3> 331 <ol> 332 <li>{$_('migration.inbound.common.step1')}</li> 333 <li>{$_('migration.inbound.common.step2')}</li> 334 <li>{$_('migration.inbound.common.step3')}</li> 335 <li>{$_('migration.inbound.common.step4')}</li> 336 <li>{$_('migration.inbound.common.step5')}</li> 337 </ol> 338 </div> 339 340 <div class="warning-box"> 341 <strong>{$_('migration.inbound.common.beforeProceed')}</strong> 342 <ul> 343 <li>{$_('migration.inbound.common.warning1')}</li> 344 <li>{$_('migration.inbound.common.warning2')}</li> 345 <li>{$_('migration.inbound.common.warning3')}</li> 346 </ul> 347 </div> 348 349 <label class="checkbox-label"> 350 <input type="checkbox" bind:checked={understood} /> 351 <span>{$_('migration.inbound.welcome.understand')}</span> 352 </label> 353 354 <div class="button-row"> 355 <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 357 {$_('migration.inbound.common.continue')} 358 </button> 359 </div> 360 </div> 361 362 {:else if flow.state.step === 'source-handle'} 363 <div class="step-content"> 364 <h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2> 365 <p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p> 366 367 {#if isResuming && resumeInfo} 368 <div class="info-box resume-info"> 369 <h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3> 370 <div class="resume-details"> 371 <div class="resume-row"> 372 <span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span> 373 <span class="value">@{resumeInfo.sourceHandle}</span> 374 </div> 375 <div class="resume-row"> 376 <span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span> 377 <span class="value">@{resumeInfo.targetHandle}</span> 378 </div> 379 <div class="resume-row"> 380 <span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span> 381 <span class="value">{resumeInfo.progressSummary}</span> 382 </div> 383 </div> 384 <p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p> 385 </div> 386 {/if} 387 388 <form onsubmit={handleSourceHandleSubmit}> 389 <div class="field"> 390 <label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label> 391 <input 392 id="source-handle" 393 type="text" 394 placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')} 395 bind:value={handleInput} 396 disabled={loading || isResuming} 397 required 398 /> 399 <p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p> 400 </div> 401 402 <div class="button-row"> 403 <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 404 <button type="submit" disabled={loading || !handleInput.trim()}> 405 {loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))} 406 </button> 407 </div> 408 </form> 409 </div> 410 411 {:else if flow.state.step === 'choose-handle'} 412 <div class="step-content"> 413 <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 <p>{$_('migration.inbound.chooseHandle.desc')}</p> 415 416 <div class="current-info"> 417 <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 418 <span class="value">{flow.state.sourceHandle}</span> 419 </div> 420 421 <div class="field"> 422 <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 423 <div class="handle-input-group"> 424 <input 425 id="new-handle" 426 type="text" 427 placeholder="username" 428 bind:value={handleInput} 429 onblur={checkHandle} 430 /> 431 {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 432 <select bind:value={selectedDomain}> 433 {#each serverInfo.availableUserDomains as domain} 434 <option value={domain}>.{domain}</option> 435 {/each} 436 </select> 437 {/if} 438 </div> 439 440 {#if checkingHandle} 441 <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 442 {:else if handleAvailable === true} 443 <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 444 {:else if handleAvailable === false} 445 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 446 {:else} 447 <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 448 {/if} 449 </div> 450 451 <div class="field"> 452 <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 453 <input 454 id="email" 455 type="email" 456 placeholder="you@example.com" 457 bind:value={flow.state.targetEmail} 458 oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 459 required 460 /> 461 </div> 462 463 <div class="field"> 464 <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 465 <div class="auth-method-options"> 466 <label class="auth-option" class:selected={selectedAuthMethod === 'password'}> 467 <input 468 type="radio" 469 name="auth-method" 470 value="password" 471 bind:group={selectedAuthMethod} 472 /> 473 <div class="auth-option-content"> 474 <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 475 <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 476 </div> 477 </label> 478 <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}> 479 <input 480 type="radio" 481 name="auth-method" 482 value="passkey" 483 bind:group={selectedAuthMethod} 484 /> 485 <div class="auth-option-content"> 486 <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 487 <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 488 </div> 489 </label> 490 </div> 491 </div> 492 493 {#if selectedAuthMethod === 'password'} 494 <div class="field"> 495 <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 496 <input 497 id="new-password" 498 type="password" 499 placeholder="Password for your new account" 500 bind:value={flow.state.targetPassword} 501 oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 502 required 503 minlength="8" 504 /> 505 <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 506 </div> 507 {:else} 508 <div class="info-box"> 509 <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 510 </div> 511 {/if} 512 513 {#if serverInfo?.inviteCodeRequired} 514 <div class="field"> 515 <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 516 <input 517 id="invite" 518 type="text" 519 placeholder="Enter invite code" 520 bind:value={flow.state.inviteCode} 521 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 522 required 523 /> 524 </div> 525 {/if} 526 527 <div class="button-row"> 528 <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 529 <button 530 disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 onclick={proceedToReviewWithAuth} 532 > 533 {$_('migration.inbound.common.continue')} 534 </button> 535 </div> 536 </div> 537 538 {:else if flow.state.step === 'review'} 539 <div class="step-content"> 540 <h2>{$_('migration.inbound.review.title')}</h2> 541 <p>{$_('migration.inbound.review.desc')}</p> 542 543 <div class="review-card"> 544 <div class="review-row"> 545 <span class="label">{$_('migration.inbound.review.currentHandle')}:</span> 546 <span class="value">{flow.state.sourceHandle}</span> 547 </div> 548 <div class="review-row"> 549 <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 550 <span class="value">{flow.state.targetHandle}</span> 551 </div> 552 <div class="review-row"> 553 <span class="label">{$_('migration.inbound.review.did')}:</span> 554 <span class="value mono">{flow.state.sourceDid}</span> 555 </div> 556 <div class="review-row"> 557 <span class="label">{$_('migration.inbound.review.sourcePds')}:</span> 558 <span class="value">{flow.state.sourcePdsUrl}</span> 559 </div> 560 <div class="review-row"> 561 <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 562 <span class="value">{window.location.origin}</span> 563 </div> 564 <div class="review-row"> 565 <span class="label">{$_('migration.inbound.review.email')}:</span> 566 <span class="value">{flow.state.targetEmail}</span> 567 </div> 568 <div class="review-row"> 569 <span class="label">{$_('migration.inbound.review.authentication')}:</span> 570 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 571 </div> 572 </div> 573 574 <div class="warning-box"> 575 {$_('migration.inbound.review.warning')} 576 </div> 577 578 <div class="button-row"> 579 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 580 <button onclick={startMigration} disabled={loading}> 581 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 582 </button> 583 </div> 584 </div> 585 586 {:else if flow.state.step === 'migrating'} 587 <div class="step-content"> 588 <h2>{$_('migration.inbound.migrating.title')}</h2> 589 <p>{$_('migration.inbound.migrating.desc')}</p> 590 591 <div class="progress-section"> 592 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 593 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 594 <span>{$_('migration.inbound.migrating.exportRepo')}</span> 595 </div> 596 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 597 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 598 <span>{$_('migration.inbound.migrating.importRepo')}</span> 599 </div> 600 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 601 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 602 <span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 603 </div> 604 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 605 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 606 <span>{$_('migration.inbound.migrating.migratePrefs')}</span> 607 </div> 608 </div> 609 610 {#if flow.state.progress.blobsTotal > 0} 611 <div class="progress-bar"> 612 <div 613 class="progress-fill" 614 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 615 ></div> 616 </div> 617 {/if} 618 619 <p class="status-text">{flow.state.progress.currentOperation}</p> 620 </div> 621 622 {:else if flow.state.step === 'passkey-setup'} 623 <div class="step-content"> 624 <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 625 <p>{$_('migration.inbound.passkeySetup.desc')}</p> 626 627 {#if flow.state.error} 628 <div class="message error"> 629 {flow.state.error} 630 </div> 631 {/if} 632 633 <div class="field"> 634 <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 635 <input 636 id="passkey-name" 637 type="text" 638 placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 639 bind:value={passkeyName} 640 disabled={loading} 641 /> 642 <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 643 </div> 644 645 <div class="passkey-section"> 646 <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 647 <button class="primary" onclick={registerPasskey} disabled={loading}> 648 {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 649 </button> 650 </div> 651 </div> 652 653 {:else if flow.state.step === 'app-password'} 654 <div class="step-content"> 655 <h2>{$_('migration.inbound.appPassword.title')}</h2> 656 <p>{$_('migration.inbound.appPassword.desc')}</p> 657 658 <div class="warning-box"> 659 <strong>{$_('migration.inbound.appPassword.warning')}</strong> 660 </div> 661 662 <div class="app-password-display"> 663 <div class="app-password-label"> 664 {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong> 665 </div> 666 <code class="app-password-code">{flow.state.generatedAppPassword}</code> 667 <button type="button" class="copy-btn" onclick={copyAppPassword}> 668 {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 669 </button> 670 </div> 671 672 <label class="checkbox-label"> 673 <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 674 <span>{$_('migration.inbound.appPassword.saved')}</span> 675 </label> 676 677 <div class="button-row"> 678 <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}> 679 {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 680 </button> 681 </div> 682 </div> 683 684 {:else if flow.state.step === 'email-verify'} 685 <div class="step-content"> 686 <h2>{$_('migration.inbound.emailVerify.title')}</h2> 687 <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 688 689 <div class="info-box"> 690 <p> 691 {$_('migration.inbound.emailVerify.hint')} 692 </p> 693 </div> 694 695 {#if flow.state.error} 696 <div class="message error"> 697 {flow.state.error} 698 </div> 699 {/if} 700 701 <form onsubmit={submitEmailVerify}> 702 <div class="field"> 703 <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 704 <input 705 id="email-verify-token" 706 type="text" 707 placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 708 bind:value={flow.state.emailVerifyToken} 709 oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 710 disabled={loading} 711 required 712 /> 713 </div> 714 715 <div class="button-row"> 716 <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 717 {$_('migration.inbound.emailVerify.resend')} 718 </button> 719 <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 720 {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 721 </button> 722 </div> 723 </form> 724 </div> 725 726 {:else if flow.state.step === 'plc-token'} 727 <div class="step-content"> 728 <h2>{$_('migration.inbound.plcToken.title')}</h2> 729 <p>{$_('migration.inbound.plcToken.desc')}</p> 730 731 <div class="info-box"> 732 <p>{$_('migration.inbound.plcToken.info')}</p> 733 </div> 734 735 <form onsubmit={submitPlcToken}> 736 <div class="field"> 737 <label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label> 738 <input 739 id="plc-token" 740 type="text" 741 placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')} 742 bind:value={flow.state.plcToken} 743 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 744 disabled={loading} 745 required 746 /> 747 </div> 748 749 <div class="button-row"> 750 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 751 {$_('migration.inbound.plcToken.resend')} 752 </button> 753 <button type="submit" disabled={loading || !flow.state.plcToken}> 754 {loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')} 755 </button> 756 </div> 757 </form> 758 </div> 759 760 {:else if flow.state.step === 'did-web-update'} 761 <div class="step-content"> 762 <h2>{$_('migration.inbound.didWebUpdate.title')}</h2> 763 <p>{$_('migration.inbound.didWebUpdate.desc')}</p> 764 765 <div class="info-box"> 766 <p> 767 {$_('migration.inbound.didWebUpdate.yourDid')} <code>{flow.state.sourceDid}</code> 768 </p> 769 <p style="margin-top: 12px;"> 770 {$_('migration.inbound.didWebUpdate.updateInstructions')} 771 </p> 772 </div> 773 774 <div class="code-block"> 775 <pre>{`{ 776 "@context": [ 777 "https://www.w3.org/ns/did/v1", 778 "https://w3id.org/security/multikey/v1", 779 "https://w3id.org/security/suites/secp256k1-2019/v1" 780 ], 781 "id": "${flow.state.sourceDid}", 782 "alsoKnownAs": [ 783 "at://${flow.state.targetHandle || '...'}" 784 ], 785 "verificationMethod": [ 786 { 787 "id": "${flow.state.sourceDid}#atproto", 788 "type": "Multikey", 789 "controller": "${flow.state.sourceDid}", 790 "publicKeyMultibase": "${flow.state.targetVerificationMethod?.replace('did:key:', '') || '...'}" 791 } 792 ], 793 "service": [ 794 { 795 "id": "#atproto_pds", 796 "type": "AtprotoPersonalDataServer", 797 "serviceEndpoint": "${window.location.origin}" 798 } 799 ] 800}`}</pre> 801 </div> 802 803 <div class="warning-box"> 804 <strong>{$_('migration.inbound.didWebUpdate.important')}</strong> {$_('migration.inbound.didWebUpdate.verifyFirst')} 805 {$_('migration.inbound.didWebUpdate.fileLocation')} <code>https://{flow.state.sourceDid.replace('did:web:', '')}/.well-known/did.json</code> 806 </div> 807 808 <div class="button-row"> 809 <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 810 <button onclick={completeDidWeb} disabled={loading}> 811 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')} 812 </button> 813 </div> 814 </div> 815 816 {:else if flow.state.step === 'finalizing'} 817 <div class="step-content"> 818 <h2>{$_('migration.inbound.finalizing.title')}</h2> 819 <p>{$_('migration.inbound.finalizing.desc')}</p> 820 821 <div class="progress-section"> 822 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 823 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 824 <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 825 </div> 826 <div class="progress-item" class:completed={flow.state.progress.activated}> 827 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 828 <span>{$_('migration.inbound.finalizing.activating')}</span> 829 </div> 830 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 831 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 832 <span>{$_('migration.inbound.finalizing.deactivating')}</span> 833 </div> 834 </div> 835 836 <p class="status-text">{flow.state.progress.currentOperation}</p> 837 </div> 838 839 {:else if flow.state.step === 'success'} 840 <div class="step-content success-content"> 841 <div class="success-icon"></div> 842 <h2>{$_('migration.inbound.success.title')}</h2> 843 <p>{$_('migration.inbound.success.desc')}</p> 844 845 <div class="success-details"> 846 <div class="detail-row"> 847 <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 848 <span class="value">{flow.state.targetHandle}</span> 849 </div> 850 <div class="detail-row"> 851 <span class="label">{$_('migration.inbound.success.did')}:</span> 852 <span class="value mono">{flow.state.sourceDid}</span> 853 </div> 854 </div> 855 856 {#if flow.state.progress.blobsFailed.length > 0} 857 <div class="message warning"> 858 {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 859 </div> 860 {/if} 861 862 <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 863 </div> 864 865 {:else if flow.state.step === 'error'} 866 <div class="step-content"> 867 <h2>{$_('migration.inbound.error.title')}</h2> 868 <p>{$_('migration.inbound.error.desc')}</p> 869 870 <div class="message error"> 871 {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 872 </div> 873 874 <div class="button-row"> 875 <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 876 </div> 877 </div> 878 {/if} 879</div> 880 881<style> 882 .passkey-section { 883 margin-top: 16px; 884 } 885 .passkey-section button { 886 width: 100%; 887 margin-top: 12px; 888 } 889 .app-password-display { 890 background: var(--bg-card); 891 border: 2px solid var(--accent); 892 border-radius: var(--radius-xl); 893 padding: var(--space-6); 894 text-align: center; 895 margin: var(--space-4) 0; 896 } 897 .app-password-label { 898 font-size: var(--text-sm); 899 color: var(--text-secondary); 900 margin-bottom: var(--space-4); 901 } 902 .app-password-code { 903 display: block; 904 font-size: var(--text-xl); 905 font-family: ui-monospace, monospace; 906 letter-spacing: 0.1em; 907 padding: var(--space-5); 908 background: var(--bg-input); 909 border-radius: var(--radius-md); 910 margin-bottom: var(--space-4); 911 user-select: all; 912 } 913 .copy-btn { 914 padding: var(--space-3) var(--space-5); 915 font-size: var(--text-sm); 916 } 917 .resume-info { 918 margin-bottom: var(--space-5); 919 } 920 .resume-info h3 { 921 margin: 0 0 var(--space-3) 0; 922 font-size: var(--text-base); 923 } 924 .resume-details { 925 display: flex; 926 flex-direction: column; 927 gap: var(--space-2); 928 } 929 .resume-row { 930 display: flex; 931 justify-content: space-between; 932 font-size: var(--text-sm); 933 } 934 .resume-row .label { 935 color: var(--text-secondary); 936 } 937 .resume-row .value { 938 font-weight: var(--font-medium); 939 } 940 .resume-note { 941 margin-top: var(--space-3); 942 font-size: var(--text-sm); 943 font-style: italic; 944 } 945</style>