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