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