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