this repo has no description
1<script lang="ts">
2 import type { OfflineInboundMigrationFlow } 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 Props {
16 flow: OfflineInboundMigrationFlow
17 onBack: () => void
18 onComplete: () => void
19 }
20
21 let { flow, onBack, onComplete }: Props = $props()
22
23 let serverInfo = $state<ServerDescription | null>(null)
24 let loading = $state(false)
25 let understood = $state(false)
26 let handleInput = $state('')
27 let selectedDomain = $state('')
28 let handleAvailable = $state<boolean | null>(null)
29 let checkingHandle = $state(false)
30 let validatingKey = $state(false)
31 let keyValid = $state<boolean | null>(null)
32 let fileInputRef = $state<HTMLInputElement | null>(null)
33 let selectedAuthMethod = $state<AuthMethod>('password')
34 let passkeyName = $state('')
35
36 let redirectTriggered = $state(false)
37
38 $effect(() => {
39 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
40 loadServerInfo()
41 }
42 if (flow.state.step === 'choose-handle') {
43 handleInput = ''
44 handleAvailable = null
45 }
46 })
47
48 $effect(() => {
49 if (flow.state.step === 'success' && !redirectTriggered) {
50 redirectTriggered = true
51 setTimeout(() => {
52 onComplete()
53 }, 2000)
54 }
55 })
56
57 $effect(() => {
58 if (flow.state.step === 'email-verify') {
59 const interval = setInterval(async () => {
60 if (flow.state.emailVerifyToken.trim()) return
61 await flow.checkEmailVerifiedAndProceed()
62 }, 3000)
63 return () => clearInterval(interval)
64 }
65 return undefined
66 })
67
68 async function loadServerInfo() {
69 if (!serverInfo) {
70 serverInfo = await flow.loadLocalServerInfo()
71 if (serverInfo.availableUserDomains.length > 0) {
72 selectedDomain = serverInfo.availableUserDomains[0]
73 }
74 }
75 }
76
77 function handleFileSelect(e: Event) {
78 const input = e.target as HTMLInputElement
79 const file = input.files?.[0]
80 if (!file) return
81
82 const reader = new FileReader()
83 reader.onload = () => {
84 const arrayBuffer = reader.result as ArrayBuffer
85 flow.setCarFile(new Uint8Array(arrayBuffer), file.name)
86 }
87 reader.readAsArrayBuffer(file)
88 }
89
90 async function validateRotationKey() {
91 if (!flow.state.rotationKey || !flow.state.userDid) return
92
93 validatingKey = true
94 keyValid = null
95
96 try {
97 const isValid = await flow.validateRotationKey()
98 keyValid = isValid
99 if (isValid) {
100 flow.setStep('choose-handle')
101 }
102 } catch (err) {
103 flow.setError(getErrorMessage(err))
104 keyValid = false
105 } finally {
106 validatingKey = false
107 }
108 }
109
110 async function startMigration() {
111 loading = true
112 try {
113 await flow.runMigration()
114 } catch (err) {
115 flow.setError(getErrorMessage(err))
116 } finally {
117 loading = false
118 }
119 }
120
121 const steps = $derived(
122 flow.state.authMethod === 'passkey'
123 ? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete']
124 : ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete']
125 )
126
127 function getCurrentStepIndex(): number {
128 const isPasskey = flow.state.authMethod === 'passkey'
129 switch (flow.state.step) {
130 case 'welcome': return 0
131 case 'provide-did': return 0
132 case 'upload-car': return 1
133 case 'provide-rotation-key': return 2
134 case 'choose-handle': return 3
135 case 'review': return 4
136 case 'creating':
137 case 'importing': return 5
138 case 'migrating-blobs': return 6
139 case 'email-verify': return 7
140 case 'passkey-setup': return isPasskey ? 8 : 7
141 case 'app-password': return 9
142 case 'plc-signing':
143 case 'finalizing': return isPasskey ? 10 : 8
144 case 'success': return isPasskey ? 10 : 8
145 default: return 0
146 }
147 }
148
149 async function checkHandle() {
150 if (!handleInput.trim()) return
151
152 const fullHandle = handleInput.includes('.')
153 ? handleInput
154 : `${handleInput}.${selectedDomain}`
155
156 checkingHandle = true
157 handleAvailable = null
158
159 try {
160 handleAvailable = await flow.checkHandleAvailability(fullHandle)
161 } catch {
162 handleAvailable = true
163 } finally {
164 checkingHandle = false
165 }
166 }
167
168 function proceedToReview() {
169 const fullHandle = handleInput.includes('.')
170 ? handleInput
171 : `${handleInput}.${selectedDomain}`
172
173 flow.setTargetHandle(fullHandle)
174 flow.setAuthMethod(selectedAuthMethod)
175 flow.setStep('review')
176 }
177
178 async function submitEmailVerify(e: Event) {
179 e.preventDefault()
180 loading = true
181 try {
182 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken)
183 } catch (err) {
184 flow.setError(getErrorMessage(err))
185 } finally {
186 loading = false
187 }
188 }
189
190 async function resendEmailVerify() {
191 loading = true
192 try {
193 await flow.resendEmailVerification()
194 flow.setError(null)
195 } catch (err) {
196 flow.setError(getErrorMessage(err))
197 } finally {
198 loading = false
199 }
200 }
201
202 async function registerPasskey() {
203 loading = true
204 flow.setError(null)
205
206 try {
207 if (!window.PublicKeyCredential) {
208 throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
209 }
210
211 await flow.registerPasskey(passkeyName || undefined)
212 } catch (err) {
213 const message = getErrorMessage(err)
214 if (message.includes('cancelled') || message.includes('AbortError')) {
215 flow.setError('Passkey registration was cancelled. Please try again.')
216 } else {
217 flow.setError(message)
218 }
219 } finally {
220 loading = false
221 }
222 }
223
224 async function handleProceedFromAppPassword() {
225 loading = true
226 try {
227 await flow.proceedFromAppPassword()
228 } catch (err) {
229 flow.setError(getErrorMessage(err))
230 } finally {
231 loading = false
232 }
233 }
234</script>
235
236<div class="migration-wizard">
237 <div class="step-indicator">
238 {#each steps as _, i}
239 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
240 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
241 </div>
242 {#if i < steps.length - 1}
243 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
244 {/if}
245 {/each}
246 </div>
247 <div class="current-step-label">
248 <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
249 </div>
250
251 {#if flow.state.error}
252 <div class="message error">{flow.state.error}</div>
253 {/if}
254
255 {#if flow.state.step === 'welcome'}
256 <div class="step-content">
257 <h2>{$_('migration.offline.welcome.title')}</h2>
258 <p>{$_('migration.offline.welcome.desc')}</p>
259
260 <div class="warning-box">
261 <strong>{$_('migration.offline.welcome.warningTitle')}</strong>
262 <p>{$_('migration.offline.welcome.warningDesc')}</p>
263 </div>
264
265 <div class="info-box">
266 <h3>{$_('migration.offline.welcome.requirementsTitle')}</h3>
267 <ul>
268 <li>{$_('migration.offline.welcome.requirement1')}</li>
269 <li>{$_('migration.offline.welcome.requirement2')}</li>
270 <li>{$_('migration.offline.welcome.requirement3')}</li>
271 </ul>
272 </div>
273
274 <label class="checkbox-label">
275 <input type="checkbox" bind:checked={understood} />
276 <span>{$_('migration.offline.welcome.understand')}</span>
277 </label>
278
279 <div class="button-row">
280 <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
281 <button disabled={!understood} onclick={() => flow.setStep('provide-did')}>
282 {$_('migration.inbound.common.continue')}
283 </button>
284 </div>
285 </div>
286
287 {:else if flow.state.step === 'provide-did'}
288 <div class="step-content">
289 <h2>{$_('migration.offline.provideDid.title')}</h2>
290 <p>{$_('migration.offline.provideDid.desc')}</p>
291
292 <div class="field">
293 <label for="user-did">{$_('migration.offline.provideDid.label')}</label>
294 <input
295 id="user-did"
296 type="text"
297 placeholder="did:plc:abc123..."
298 value={flow.state.userDid}
299 oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)}
300 />
301 <p class="hint">{$_('migration.offline.provideDid.hint')}</p>
302 </div>
303
304 <div class="button-row">
305 <button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button>
306 <button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}>
307 {$_('migration.inbound.common.continue')}
308 </button>
309 </div>
310 </div>
311
312 {:else if flow.state.step === 'upload-car'}
313 <div class="step-content">
314 <h2>{$_('migration.offline.uploadCar.title')}</h2>
315 <p>{$_('migration.offline.uploadCar.desc')}</p>
316
317 {#if flow.state.carNeedsReupload}
318 <div class="warning-box">
319 <strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong>
320 <p>{$_('migration.offline.uploadCar.reuploadWarning')}</p>
321 {#if flow.state.carFileName}
322 <p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p>
323 {/if}
324 </div>
325 {/if}
326
327 <div class="field">
328 <label for="car-file">{$_('migration.offline.uploadCar.label')}</label>
329 <div class="file-input-container">
330 <input
331 id="car-file"
332 type="file"
333 accept=".car"
334 onchange={handleFileSelect}
335 bind:this={fileInputRef}
336 />
337 {#if flow.state.carFile && flow.state.carFileName}
338 <div class="file-info">
339 <span class="file-name">{flow.state.carFileName}</span>
340 <span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
341 </div>
342 {/if}
343 </div>
344 <p class="hint">{$_('migration.offline.uploadCar.hint')}</p>
345 </div>
346
347 <div class="button-row">
348 <button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button>
349 <button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}>
350 {$_('migration.inbound.common.continue')}
351 </button>
352 </div>
353 </div>
354
355 {:else if flow.state.step === 'provide-rotation-key'}
356 <div class="step-content">
357 <h2>{$_('migration.offline.rotationKey.title')}</h2>
358 <p>{$_('migration.offline.rotationKey.desc')}</p>
359
360 <div class="warning-box">
361 <strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong>
362 <ul>
363 <li>{$_('migration.offline.rotationKey.securityWarning1')}</li>
364 <li>{$_('migration.offline.rotationKey.securityWarning2')}</li>
365 <li>{$_('migration.offline.rotationKey.securityWarning3')}</li>
366 </ul>
367 </div>
368
369 <div class="field">
370 <label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label>
371 <textarea
372 id="rotation-key"
373 rows={4}
374 placeholder={$_('migration.offline.rotationKey.placeholder')}
375 value={flow.state.rotationKey}
376 oninput={(e) => {
377 flow.setRotationKey((e.target as HTMLTextAreaElement).value)
378 keyValid = null
379 }}
380 ></textarea>
381 <p class="hint">{$_('migration.offline.rotationKey.hint')}</p>
382 </div>
383
384 {#if keyValid === true}
385 <div class="message success">{$_('migration.offline.rotationKey.valid')}</div>
386 {:else if keyValid === false}
387 <div class="message error">{$_('migration.offline.rotationKey.invalid')}</div>
388 {/if}
389
390 <div class="button-row">
391 <button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button>
392 <button
393 disabled={!flow.state.rotationKey || validatingKey}
394 onclick={validateRotationKey}
395 >
396 {validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')}
397 </button>
398 </div>
399 </div>
400
401 {:else if flow.state.step === 'choose-handle'}
402 <ChooseHandleStep
403 {handleInput}
404 {selectedDomain}
405 {handleAvailable}
406 {checkingHandle}
407 email={flow.state.targetEmail}
408 password={flow.state.targetPassword}
409 authMethod={selectedAuthMethod}
410 inviteCode={flow.state.inviteCode}
411 {serverInfo}
412 migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
413 migratingFromValue={flow.state.userDid}
414 {loading}
415 onHandleChange={(h) => handleInput = h}
416 onDomainChange={(d) => selectedDomain = d}
417 onCheckHandle={checkHandle}
418 onEmailChange={(e) => flow.setTargetEmail(e)}
419 onPasswordChange={(p) => flow.setTargetPassword(p)}
420 onAuthMethodChange={(m) => selectedAuthMethod = m}
421 onInviteCodeChange={(c) => flow.setInviteCode(c)}
422 onBack={() => flow.setStep('provide-rotation-key')}
423 onContinue={proceedToReview}
424 />
425
426 {:else if flow.state.step === 'review'}
427 <div class="step-content">
428 <h2>{$_('migration.inbound.review.title')}</h2>
429 <p>{$_('migration.offline.review.desc')}</p>
430
431 <div class="review-card">
432 <div class="review-row">
433 <span class="label">{$_('migration.inbound.review.did')}:</span>
434 <span class="value mono">{flow.state.userDid}</span>
435 </div>
436 <div class="review-row">
437 <span class="label">{$_('migration.inbound.review.newHandle')}:</span>
438 <span class="value">{flow.state.targetHandle}</span>
439 </div>
440 <div class="review-row">
441 <span class="label">{$_('migration.offline.review.carFile')}:</span>
442 <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
443 </div>
444 <div class="review-row">
445 <span class="label">{$_('migration.offline.review.rotationKey')}:</span>
446 <span class="value mono">{flow.state.rotationKeyDidKey}</span>
447 </div>
448 <div class="review-row">
449 <span class="label">{$_('migration.inbound.review.targetPds')}:</span>
450 <span class="value">{window.location.origin}</span>
451 </div>
452 <div class="review-row">
453 <span class="label">{$_('migration.inbound.review.email')}:</span>
454 <span class="value">{flow.state.targetEmail}</span>
455 </div>
456 <div class="review-row">
457 <span class="label">{$_('migration.inbound.review.authentication')}:</span>
458 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
459 </div>
460 </div>
461
462 <div class="warning-box">
463 <strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
464 <p>{$_('migration.offline.review.plcWarning')}</p>
465 </div>
466
467 <div class="button-row">
468 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
469 <button onclick={startMigration} disabled={loading}>
470 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
471 </button>
472 </div>
473 </div>
474
475 {:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
476 <div class="step-content">
477 <h2>{$_('migration.offline.migrating.title')}</h2>
478 <p>{$_('migration.offline.migrating.desc')}</p>
479
480 <div class="progress-section">
481 <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
482 <span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
483 <span>{$_('migration.offline.migrating.creating')}</span>
484 </div>
485 <div class="progress-item" class:active={flow.state.step === 'importing'}>
486 <span class="icon">○</span>
487 <span>{$_('migration.offline.migrating.importing')}</span>
488 </div>
489 </div>
490
491 <p class="status-text">{flow.state.progress.currentOperation}</p>
492 </div>
493
494 {:else if flow.state.step === 'migrating-blobs'}
495 <div class="step-content">
496 <h2>{$_('migration.offline.blobs.title')}</h2>
497 <p>{$_('migration.offline.blobs.desc')}</p>
498
499 <div class="progress-section">
500 <div class="progress-item completed">
501 <span class="icon">✓</span>
502 <span>{$_('migration.offline.migrating.importing')}</span>
503 </div>
504 <div class="progress-item active">
505 <span class="icon">○</span>
506 <span>{$_('migration.offline.blobs.migrating')}</span>
507 </div>
508 </div>
509
510 {#if flow.state.progress.blobsTotal > 0}
511 <div class="blob-progress">
512 <div class="blob-progress-bar">
513 <div
514 class="blob-progress-fill"
515 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
516 ></div>
517 </div>
518 <p class="blob-progress-text">
519 {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
520 </p>
521 </div>
522 {/if}
523
524 <p class="status-text">{flow.state.progress.currentOperation}</p>
525
526 {#if flow.state.progress.blobsFailed.length > 0}
527 <div class="warning-box">
528 <strong>{$_('migration.offline.blobs.failedTitle')}</strong>
529 <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
530 </div>
531 {/if}
532 </div>
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 === 'passkey-setup'}
546 <PasskeySetupStep
547 {passkeyName}
548 {loading}
549 error={flow.state.error}
550 onPasskeyNameChange={(n) => passkeyName = n}
551 onRegister={registerPasskey}
552 />
553
554 {:else if flow.state.step === 'app-password'}
555 <AppPasswordStep
556 appPassword={flow.state.generatedAppPassword || ''}
557 appPasswordName={flow.state.generatedAppPasswordName || ''}
558 {loading}
559 onContinue={handleProceedFromAppPassword}
560 />
561
562 {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
563 <div class="step-content">
564 <h2>{$_('migration.inbound.finalizing.title')}</h2>
565 <p>{$_('migration.inbound.finalizing.desc')}</p>
566
567 <div class="progress-section">
568 <div class="progress-item" class:completed={flow.state.progress.plcSigned}>
569 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
570 <span>{$_('migration.inbound.finalizing.signingPlc')}</span>
571 </div>
572 <div class="progress-item" class:completed={flow.state.progress.activated}>
573 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
574 <span>{$_('migration.inbound.finalizing.activating')}</span>
575 </div>
576 </div>
577
578 <p class="status-text">{flow.state.progress.currentOperation}</p>
579 </div>
580
581 {:else if flow.state.step === 'success'}
582 <SuccessStep
583 handle={flow.state.targetHandle}
584 did={flow.state.userDid}
585 description={$_('migration.offline.success.desc')}
586 />
587
588 {:else if flow.state.step === 'error'}
589 <ErrorStep error={flow.state.error} onStartOver={onBack} />
590 {/if}
591</div>
592