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 import ErrorStep from './ErrorStep.svelte' 9 import SuccessStep from './SuccessStep.svelte' 10 import ChooseHandleStep from './ChooseHandleStep.svelte' 11 import EmailVerifyStep from './EmailVerifyStep.svelte' 12 import PasskeySetupStep from './PasskeySetupStep.svelte' 13 import AppPasswordStep from './AppPasswordStep.svelte' 14 15 interface ResumeInfo { 16 direction: 'inbound' 17 sourceHandle: string 18 targetHandle: string 19 sourcePdsUrl: string 20 targetPdsUrl: string 21 targetEmail: string 22 authMethod?: AuthMethod 23 progressSummary: string 24 step: string 25 } 26 27 interface Props { 28 flow: InboundMigrationFlow 29 resumeInfo?: ResumeInfo | null 30 onBack: () => void 31 onComplete: () => void 32 } 33 34 let { flow, resumeInfo = null, onBack, onComplete }: Props = $props() 35 36 let serverInfo = $state<ServerDescription | null>(null) 37 let loading = $state(false) 38 let handleInput = $state('') 39 let localPasswordInput = $state('') 40 let understood = $state(false) 41 let selectedDomain = $state('') 42 let handleAvailable = $state<boolean | null>(null) 43 let checkingHandle = $state(false) 44 let selectedAuthMethod = $state<AuthMethod>('password') 45 let passkeyName = $state('') 46 47 const isResuming = $derived(flow.state.needsReauth === true) 48 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) 49 50 $effect(() => { 51 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 52 loadServerInfo() 53 } 54 if (flow.state.step === 'choose-handle') { 55 handleInput = '' 56 handleAvailable = null 57 } 58 if (flow.state.step === 'source-handle' && resumeInfo) { 59 handleInput = resumeInfo.sourceHandle 60 selectedAuthMethod = resumeInfo.authMethod ?? 'password' 61 } 62 }) 63 64 65 let redirectTriggered = $state(false) 66 67 $effect(() => { 68 if (flow.state.step === 'success' && !redirectTriggered) { 69 redirectTriggered = true 70 setTimeout(() => { 71 onComplete() 72 }, 2000) 73 } 74 }) 75 76 $effect(() => { 77 if (flow.state.step === 'email-verify') { 78 const interval = setInterval(async () => { 79 if (flow.state.emailVerifyToken.trim()) return 80 await flow.checkEmailVerifiedAndProceed() 81 }, 3000) 82 return () => clearInterval(interval) 83 } 84 }) 85 86 async function loadServerInfo() { 87 if (!serverInfo) { 88 serverInfo = await flow.loadLocalServerInfo() 89 if (serverInfo.availableUserDomains.length > 0) { 90 selectedDomain = serverInfo.availableUserDomains[0] 91 } 92 } 93 } 94 95 async function checkHandle() { 96 if (!handleInput.trim()) return 97 98 const fullHandle = handleInput.includes('.') 99 ? handleInput 100 : `${handleInput}.${selectedDomain}` 101 102 checkingHandle = true 103 handleAvailable = null 104 105 try { 106 handleAvailable = await flow.checkHandleAvailability(fullHandle) 107 } catch { 108 handleAvailable = true 109 } finally { 110 checkingHandle = false 111 } 112 } 113 114 function proceedToReview() { 115 const fullHandle = handleInput.includes('.') 116 ? handleInput 117 : `${handleInput}.${selectedDomain}` 118 119 flow.updateField('targetHandle', fullHandle) 120 flow.setStep('review') 121 } 122 123 async function startMigration() { 124 loading = true 125 try { 126 await flow.startMigration() 127 } catch (err) { 128 flow.setError(getErrorMessage(err)) 129 } finally { 130 loading = false 131 } 132 } 133 134 async function submitEmailVerify(e: Event) { 135 e.preventDefault() 136 loading = true 137 try { 138 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 139 } catch (err) { 140 flow.setError(getErrorMessage(err)) 141 } finally { 142 loading = false 143 } 144 } 145 146 async function resendEmailVerify() { 147 loading = true 148 try { 149 await flow.resendEmailVerification() 150 flow.setError(null) 151 } catch (err) { 152 flow.setError(getErrorMessage(err)) 153 } finally { 154 loading = false 155 } 156 } 157 158 async function submitPlcToken(e: Event) { 159 e.preventDefault() 160 loading = true 161 try { 162 await flow.submitPlcToken(flow.state.plcToken) 163 } catch (err) { 164 flow.setError(getErrorMessage(err)) 165 } finally { 166 loading = false 167 } 168 } 169 170 async function resendToken() { 171 loading = true 172 try { 173 await flow.resendPlcToken() 174 flow.setError(null) 175 } catch (err) { 176 flow.setError(getErrorMessage(err)) 177 } finally { 178 loading = false 179 } 180 } 181 182 async function completeDidWeb() { 183 loading = true 184 try { 185 await flow.completeDidWebMigration() 186 } catch (err) { 187 flow.setError(getErrorMessage(err)) 188 } finally { 189 loading = false 190 } 191 } 192 193 async function registerPasskey() { 194 loading = true 195 flow.setError(null) 196 197 try { 198 if (!window.PublicKeyCredential) { 199 throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 200 } 201 202 const { options } = await flow.startPasskeyRegistration() 203 204 const publicKeyOptions = prepareWebAuthnCreationOptions( 205 options as { publicKey: Record<string, unknown> } 206 ) 207 const credential = await navigator.credentials.create({ 208 publicKey: publicKeyOptions, 209 }) 210 211 if (!credential) { 212 throw new Error('Passkey creation was cancelled') 213 } 214 215 const publicKeyCredential = credential as PublicKeyCredential 216 const response = publicKeyCredential.response as AuthenticatorAttestationResponse 217 218 const credentialData = { 219 id: publicKeyCredential.id, 220 rawId: base64UrlEncode(publicKeyCredential.rawId), 221 type: publicKeyCredential.type, 222 response: { 223 clientDataJSON: base64UrlEncode(response.clientDataJSON), 224 attestationObject: base64UrlEncode(response.attestationObject), 225 }, 226 } 227 228 await flow.completePasskeyRegistration(credentialData, passkeyName || undefined) 229 } catch (err) { 230 const message = getErrorMessage(err) 231 if (message.includes('cancelled') || message.includes('AbortError')) { 232 flow.setError('Passkey registration was cancelled. Please try again.') 233 } else { 234 flow.setError(message) 235 } 236 } finally { 237 loading = false 238 } 239 } 240 241 async function handleProceedFromAppPassword() { 242 loading = true 243 try { 244 await flow.proceedFromAppPassword() 245 } catch (err) { 246 flow.setError(getErrorMessage(err)) 247 } finally { 248 loading = false 249 } 250 } 251 252 async function handleSourceHandleSubmit(e: Event) { 253 e.preventDefault() 254 loading = true 255 flow.updateField('error', null) 256 257 try { 258 await flow.initiateOAuthLogin(handleInput) 259 } catch (err) { 260 flow.setError(getErrorMessage(err)) 261 } finally { 262 loading = false 263 } 264 } 265 266 function proceedToReviewWithAuth() { 267 const fullHandle = handleInput.includes('.') 268 ? handleInput 269 : `${handleInput}.${selectedDomain}` 270 271 flow.updateField('targetHandle', fullHandle) 272 flow.updateField('authMethod', selectedAuthMethod) 273 flow.setStep('review') 274 } 275 276 const steps = $derived(isDidWeb 277 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete'] 278 : flow.state.authMethod === 'passkey' 279 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete'] 280 : ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']) 281 282 function getCurrentStepIndex(): number { 283 const isPasskey = flow.state.authMethod === 'passkey' 284 switch (flow.state.step) { 285 case 'welcome': 286 case 'source-handle': return 0 287 case 'choose-handle': return 1 288 case 'review': return 2 289 case 'migrating': return 3 290 case 'email-verify': return 4 291 case 'passkey-setup': return isPasskey ? 5 : 4 292 case 'app-password': return 6 293 case 'plc-token': 294 case 'did-web-update': 295 case 'finalizing': return isPasskey ? 7 : 5 296 case 'success': return isPasskey ? 8 : 6 297 default: return 0 298 } 299 } 300</script> 301 302<div class="migration-wizard"> 303 <div class="step-indicator"> 304 {#each steps as _, i} 305 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 306 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 307 </div> 308 {#if i < steps.length - 1} 309 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 310 {/if} 311 {/each} 312 </div> 313 <div class="current-step-label"> 314 <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 315 </div> 316 317 {#if flow.state.error} 318 <div class="message error">{flow.state.error}</div> 319 {/if} 320 321 {#if flow.state.step === 'welcome'} 322 <div class="step-content"> 323 <h2>{$_('migration.inbound.welcome.title')}</h2> 324 <p>{$_('migration.inbound.welcome.desc')}</p> 325 326 <div class="info-box"> 327 <h3>{$_('migration.inbound.common.whatWillHappen')}</h3> 328 <ol> 329 <li>{$_('migration.inbound.common.step1')}</li> 330 <li>{$_('migration.inbound.common.step2')}</li> 331 <li>{$_('migration.inbound.common.step3')}</li> 332 <li>{$_('migration.inbound.common.step4')}</li> 333 <li>{$_('migration.inbound.common.step5')}</li> 334 </ol> 335 </div> 336 337 <div class="warning-box"> 338 <strong>{$_('migration.inbound.common.beforeProceed')}</strong> 339 <ul> 340 <li>{$_('migration.inbound.common.warning1')}</li> 341 <li>{$_('migration.inbound.common.warning2')}</li> 342 <li>{$_('migration.inbound.common.warning3')}</li> 343 </ul> 344 </div> 345 346 <label class="checkbox-label"> 347 <input type="checkbox" bind:checked={understood} /> 348 <span>{$_('migration.inbound.welcome.understand')}</span> 349 </label> 350 351 <div class="button-row"> 352 <button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 353 <button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}> 354 {$_('migration.inbound.common.continue')} 355 </button> 356 </div> 357 </div> 358 359 {:else if flow.state.step === 'source-handle'} 360 <div class="step-content"> 361 <h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2> 362 <p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p> 363 364 {#if isResuming && resumeInfo} 365 <div class="info-box resume-info"> 366 <h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3> 367 <div class="resume-details"> 368 <div class="resume-row"> 369 <span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span> 370 <span class="value">@{resumeInfo.sourceHandle}</span> 371 </div> 372 <div class="resume-row"> 373 <span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span> 374 <span class="value">@{resumeInfo.targetHandle}</span> 375 </div> 376 <div class="resume-row"> 377 <span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span> 378 <span class="value">{resumeInfo.progressSummary}</span> 379 </div> 380 </div> 381 <p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p> 382 </div> 383 {/if} 384 385 <form onsubmit={handleSourceHandleSubmit}> 386 <div class="field"> 387 <label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label> 388 <input 389 id="source-handle" 390 type="text" 391 placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')} 392 bind:value={handleInput} 393 disabled={loading || isResuming} 394 required 395 /> 396 <p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p> 397 </div> 398 399 <div class="button-row"> 400 <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 401 <button type="submit" disabled={loading || !handleInput.trim()}> 402 {loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))} 403 </button> 404 </div> 405 </form> 406 </div> 407 408 {:else if flow.state.step === 'choose-handle'} 409 <ChooseHandleStep 410 {handleInput} 411 {selectedDomain} 412 {handleAvailable} 413 {checkingHandle} 414 email={flow.state.targetEmail} 415 password={flow.state.targetPassword} 416 authMethod={selectedAuthMethod} 417 inviteCode={flow.state.inviteCode} 418 {serverInfo} 419 migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')} 420 migratingFromValue={flow.state.sourceHandle} 421 {loading} 422 onHandleChange={(h) => handleInput = h} 423 onDomainChange={(d) => selectedDomain = d} 424 onCheckHandle={checkHandle} 425 onEmailChange={(e) => flow.updateField('targetEmail', e)} 426 onPasswordChange={(p) => flow.updateField('targetPassword', p)} 427 onAuthMethodChange={(m) => selectedAuthMethod = m} 428 onInviteCodeChange={(c) => flow.updateField('inviteCode', c)} 429 onBack={() => flow.setStep('source-handle')} 430 onContinue={proceedToReviewWithAuth} 431 /> 432 433 {:else if flow.state.step === 'review'} 434 <div class="step-content"> 435 <h2>{$_('migration.inbound.review.title')}</h2> 436 <p>{$_('migration.inbound.review.desc')}</p> 437 438 <div class="review-card"> 439 <div class="review-row"> 440 <span class="label">{$_('migration.inbound.review.currentHandle')}:</span> 441 <span class="value">{flow.state.sourceHandle}</span> 442 </div> 443 <div class="review-row"> 444 <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 445 <span class="value">{flow.state.targetHandle}</span> 446 </div> 447 <div class="review-row"> 448 <span class="label">{$_('migration.inbound.review.did')}:</span> 449 <span class="value mono">{flow.state.sourceDid}</span> 450 </div> 451 <div class="review-row"> 452 <span class="label">{$_('migration.inbound.review.sourcePds')}:</span> 453 <span class="value">{flow.state.sourcePdsUrl}</span> 454 </div> 455 <div class="review-row"> 456 <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 457 <span class="value">{window.location.origin}</span> 458 </div> 459 <div class="review-row"> 460 <span class="label">{$_('migration.inbound.review.email')}:</span> 461 <span class="value">{flow.state.targetEmail}</span> 462 </div> 463 <div class="review-row"> 464 <span class="label">{$_('migration.inbound.review.authentication')}:</span> 465 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 466 </div> 467 </div> 468 469 <div class="warning-box"> 470 {$_('migration.inbound.review.warning')} 471 </div> 472 473 <div class="button-row"> 474 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 475 <button onclick={startMigration} disabled={loading}> 476 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 477 </button> 478 </div> 479 </div> 480 481 {:else if flow.state.step === 'migrating'} 482 <div class="step-content"> 483 <h2>{$_('migration.inbound.migrating.title')}</h2> 484 <p>{$_('migration.inbound.migrating.desc')}</p> 485 486 <div class="progress-section"> 487 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 488 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 489 <span>{$_('migration.inbound.migrating.exportRepo')}</span> 490 </div> 491 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 492 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 493 <span>{$_('migration.inbound.migrating.importRepo')}</span> 494 </div> 495 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 496 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 497 <span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 498 </div> 499 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 500 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 501 <span>{$_('migration.inbound.migrating.migratePrefs')}</span> 502 </div> 503 </div> 504 505 {#if flow.state.progress.blobsTotal > 0} 506 <div class="progress-bar"> 507 <div 508 class="progress-fill" 509 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 510 ></div> 511 </div> 512 {/if} 513 514 <p class="status-text">{flow.state.progress.currentOperation}</p> 515 </div> 516 517 {:else if flow.state.step === 'passkey-setup'} 518 <PasskeySetupStep 519 {passkeyName} 520 {loading} 521 error={flow.state.error} 522 onPasskeyNameChange={(n) => passkeyName = n} 523 onRegister={registerPasskey} 524 /> 525 526 {:else if flow.state.step === 'app-password'} 527 <AppPasswordStep 528 appPassword={flow.state.generatedAppPassword || ''} 529 appPasswordName={flow.state.generatedAppPasswordName || ''} 530 {loading} 531 onContinue={handleProceedFromAppPassword} 532 /> 533 534 {:else if flow.state.step === 'email-verify'} 535 <EmailVerifyStep 536 email={flow.state.targetEmail} 537 token={flow.state.emailVerifyToken} 538 {loading} 539 error={flow.state.error} 540 onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 541 onSubmit={submitEmailVerify} 542 onResend={resendEmailVerify} 543 /> 544 545 {:else if flow.state.step === 'plc-token'} 546 <div class="step-content"> 547 <h2>{$_('migration.inbound.plcToken.title')}</h2> 548 <p>{$_('migration.inbound.plcToken.desc')}</p> 549 550 <div class="info-box"> 551 <p>{$_('migration.inbound.plcToken.info')}</p> 552 </div> 553 554 <form onsubmit={submitPlcToken}> 555 <div class="field"> 556 <label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label> 557 <input 558 id="plc-token" 559 type="text" 560 placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')} 561 bind:value={flow.state.plcToken} 562 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 563 disabled={loading} 564 required 565 /> 566 </div> 567 568 <div class="button-row"> 569 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 570 {$_('migration.inbound.plcToken.resend')} 571 </button> 572 <button type="submit" disabled={loading || !flow.state.plcToken}> 573 {loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')} 574 </button> 575 </div> 576 </form> 577 </div> 578 579 {:else if flow.state.step === 'did-web-update'} 580 <div class="step-content"> 581 <h2>{$_('migration.inbound.didWebUpdate.title')}</h2> 582 <p>{$_('migration.inbound.didWebUpdate.desc')}</p> 583 584 <div class="info-box"> 585 <p> 586 {$_('migration.inbound.didWebUpdate.yourDid')} <code>{flow.state.sourceDid}</code> 587 </p> 588 <p style="margin-top: 12px;"> 589 {$_('migration.inbound.didWebUpdate.updateInstructions')} 590 </p> 591 </div> 592 593 <div class="code-block"> 594 <pre>{`{ 595 "@context": [ 596 "https://www.w3.org/ns/did/v1", 597 "https://w3id.org/security/multikey/v1", 598 "https://w3id.org/security/suites/secp256k1-2019/v1" 599 ], 600 "id": "${flow.state.sourceDid}", 601 "alsoKnownAs": [ 602 "at://${flow.state.targetHandle || '...'}" 603 ], 604 "verificationMethod": [ 605 { 606 "id": "${flow.state.sourceDid}#atproto", 607 "type": "Multikey", 608 "controller": "${flow.state.sourceDid}", 609 "publicKeyMultibase": "${flow.state.targetVerificationMethod?.replace('did:key:', '') || '...'}" 610 } 611 ], 612 "service": [ 613 { 614 "id": "#atproto_pds", 615 "type": "AtprotoPersonalDataServer", 616 "serviceEndpoint": "${window.location.origin}" 617 } 618 ] 619}`}</pre> 620 </div> 621 622 <div class="warning-box"> 623 <strong>{$_('migration.inbound.didWebUpdate.important')}</strong> {$_('migration.inbound.didWebUpdate.verifyFirst')} 624 {$_('migration.inbound.didWebUpdate.fileLocation')} <code>https://{flow.state.sourceDid.replace('did:web:', '')}/.well-known/did.json</code> 625 </div> 626 627 <div class="button-row"> 628 <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 629 <button onclick={completeDidWeb} disabled={loading}> 630 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')} 631 </button> 632 </div> 633 </div> 634 635 {:else if flow.state.step === 'finalizing'} 636 <div class="step-content"> 637 <h2>{$_('migration.inbound.finalizing.title')}</h2> 638 <p>{$_('migration.inbound.finalizing.desc')}</p> 639 640 <div class="progress-section"> 641 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 642 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 643 <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 644 </div> 645 <div class="progress-item" class:completed={flow.state.progress.activated}> 646 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 647 <span>{$_('migration.inbound.finalizing.activating')}</span> 648 </div> 649 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 650 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 651 <span>{$_('migration.inbound.finalizing.deactivating')}</span> 652 </div> 653 </div> 654 655 <p class="status-text">{flow.state.progress.currentOperation}</p> 656 </div> 657 658 {:else if flow.state.step === 'success'} 659 <SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}> 660 {#snippet extraContent()} 661 {#if flow.state.progress.blobsFailed.length > 0} 662 <div class="message warning"> 663 {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 664 </div> 665 {/if} 666 {/snippet} 667 </SuccessStep> 668 669 {:else if flow.state.step === 'error'} 670 <ErrorStep error={flow.state.error} onStartOver={onBack} /> 671 {/if} 672</div> 673 674<style> 675 .resume-info { 676 margin-bottom: var(--space-5); 677 } 678 .resume-info h3 { 679 margin: 0 0 var(--space-3) 0; 680 font-size: var(--text-base); 681 } 682 .resume-details { 683 display: flex; 684 flex-direction: column; 685 gap: var(--space-2); 686 } 687 .resume-row { 688 display: flex; 689 justify-content: space-between; 690 font-size: var(--text-sm); 691 } 692 .resume-row .label { 693 color: var(--text-secondary); 694 } 695 .resume-row .value { 696 font-weight: var(--font-medium); 697 } 698 .resume-note { 699 margin-top: var(--space-3); 700 font-size: var(--text-sm); 701 font-style: italic; 702 } 703</style>