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