Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
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>