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 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>