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
9 interface ResumeInfo {
10 direction: 'inbound' | 'outbound'
11 sourceHandle: string
12 targetHandle: string
13 sourcePdsUrl: string
14 targetPdsUrl: string
15 targetEmail: string
16 authMethod?: AuthMethod
17 progressSummary: string
18 step: string
19 }
20
21 interface Props {
22 flow: InboundMigrationFlow
23 resumeInfo?: ResumeInfo | null
24 onBack: () => void
25 onComplete: () => void
26 }
27
28 let { flow, resumeInfo = null, onBack, onComplete }: Props = $props()
29
30 let serverInfo = $state<ServerDescription | null>(null)
31 let loading = $state(false)
32 let handleInput = $state('')
33 let localPasswordInput = $state('')
34 let understood = $state(false)
35 let selectedDomain = $state('')
36 let handleAvailable = $state<boolean | null>(null)
37 let checkingHandle = $state(false)
38 let selectedAuthMethod = $state<AuthMethod>('password')
39 let passkeyName = $state('')
40 let appPasswordCopied = $state(false)
41 let appPasswordAcknowledged = $state(false)
42
43 const isResuming = $derived(flow.state.needsReauth === true)
44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
45
46 $effect(() => {
47 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
48 loadServerInfo()
49 }
50 if (flow.state.step === 'choose-handle') {
51 handleInput = ''
52 handleAvailable = null
53 }
54 if (flow.state.step === 'source-handle' && resumeInfo) {
55 handleInput = resumeInfo.sourceHandle
56 selectedAuthMethod = resumeInfo.authMethod ?? 'password'
57 }
58 })
59
60
61 let redirectTriggered = $state(false)
62
63 $effect(() => {
64 if (flow.state.step === 'success' && !redirectTriggered) {
65 redirectTriggered = true
66 setTimeout(() => {
67 onComplete()
68 }, 2000)
69 }
70 })
71
72 $effect(() => {
73 if (flow.state.step === 'email-verify') {
74 const interval = setInterval(async () => {
75 if (flow.state.emailVerifyToken.trim()) return
76 await flow.checkEmailVerifiedAndProceed()
77 }, 3000)
78 return () => clearInterval(interval)
79 }
80 })
81
82 async function loadServerInfo() {
83 if (!serverInfo) {
84 serverInfo = await flow.loadLocalServerInfo()
85 if (serverInfo.availableUserDomains.length > 0) {
86 selectedDomain = serverInfo.availableUserDomains[0]
87 }
88 }
89 }
90
91 async function checkHandle() {
92 if (!handleInput.trim()) return
93
94 const fullHandle = handleInput.includes('.')
95 ? handleInput
96 : `${handleInput}.${selectedDomain}`
97
98 checkingHandle = true
99 handleAvailable = null
100
101 try {
102 handleAvailable = await flow.checkHandleAvailability(fullHandle)
103 } catch {
104 handleAvailable = true
105 } finally {
106 checkingHandle = false
107 }
108 }
109
110 function proceedToReview() {
111 const fullHandle = handleInput.includes('.')
112 ? handleInput
113 : `${handleInput}.${selectedDomain}`
114
115 flow.updateField('targetHandle', fullHandle)
116 flow.setStep('review')
117 }
118
119 async function startMigration() {
120 loading = true
121 try {
122 await flow.startMigration()
123 } catch (err) {
124 flow.setError(getErrorMessage(err))
125 } finally {
126 loading = false
127 }
128 }
129
130 async function submitEmailVerify(e: Event) {
131 e.preventDefault()
132 loading = true
133 try {
134 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined)
135 } catch (err) {
136 flow.setError(getErrorMessage(err))
137 } finally {
138 loading = false
139 }
140 }
141
142 async function resendEmailVerify() {
143 loading = true
144 try {
145 await flow.resendEmailVerification()
146 flow.setError(null)
147 } catch (err) {
148 flow.setError(getErrorMessage(err))
149 } finally {
150 loading = false
151 }
152 }
153
154 async function submitPlcToken(e: Event) {
155 e.preventDefault()
156 loading = true
157 try {
158 await flow.submitPlcToken(flow.state.plcToken)
159 } catch (err) {
160 flow.setError(getErrorMessage(err))
161 } finally {
162 loading = false
163 }
164 }
165
166 async function resendToken() {
167 loading = true
168 try {
169 await flow.resendPlcToken()
170 flow.setError(null)
171 } catch (err) {
172 flow.setError(getErrorMessage(err))
173 } finally {
174 loading = false
175 }
176 }
177
178 async function completeDidWeb() {
179 loading = true
180 try {
181 await flow.completeDidWebMigration()
182 } catch (err) {
183 flow.setError(getErrorMessage(err))
184 } finally {
185 loading = false
186 }
187 }
188
189 async function registerPasskey() {
190 loading = true
191 flow.setError(null)
192
193 try {
194 if (!window.PublicKeyCredential) {
195 throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
196 }
197
198 const { options } = await flow.startPasskeyRegistration()
199
200 const publicKeyOptions = prepareWebAuthnCreationOptions(
201 options as { publicKey: Record<string, unknown> }
202 )
203 const credential = await navigator.credentials.create({
204 publicKey: publicKeyOptions,
205 })
206
207 if (!credential) {
208 throw new Error('Passkey creation was cancelled')
209 }
210
211 const publicKeyCredential = credential as PublicKeyCredential
212 const response = publicKeyCredential.response as AuthenticatorAttestationResponse
213
214 const credentialData = {
215 id: publicKeyCredential.id,
216 rawId: base64UrlEncode(publicKeyCredential.rawId),
217 type: publicKeyCredential.type,
218 response: {
219 clientDataJSON: base64UrlEncode(response.clientDataJSON),
220 attestationObject: base64UrlEncode(response.attestationObject),
221 },
222 }
223
224 await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
225 } catch (err) {
226 const message = getErrorMessage(err)
227 if (message.includes('cancelled') || message.includes('AbortError')) {
228 flow.setError('Passkey registration was cancelled. Please try again.')
229 } else {
230 flow.setError(message)
231 }
232 } finally {
233 loading = false
234 }
235 }
236
237 function copyAppPassword() {
238 if (flow.state.generatedAppPassword) {
239 navigator.clipboard.writeText(flow.state.generatedAppPassword)
240 appPasswordCopied = true
241 }
242 }
243
244 async function handleProceedFromAppPassword() {
245 loading = true
246 try {
247 await flow.proceedFromAppPassword()
248 } catch (err) {
249 flow.setError(getErrorMessage(err))
250 } finally {
251 loading = false
252 }
253 }
254
255 async function handleSourceHandleSubmit(e: Event) {
256 e.preventDefault()
257 loading = true
258 flow.updateField('error', null)
259
260 try {
261 await flow.initiateOAuthLogin(handleInput)
262 } catch (err) {
263 flow.setError(getErrorMessage(err))
264 } finally {
265 loading = false
266 }
267 }
268
269 function proceedToReviewWithAuth() {
270 const fullHandle = handleInput.includes('.')
271 ? handleInput
272 : `${handleInput}.${selectedDomain}`
273
274 flow.updateField('targetHandle', fullHandle)
275 flow.updateField('authMethod', selectedAuthMethod)
276 flow.setStep('review')
277 }
278
279 const steps = $derived(isDidWeb
280 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete']
281 : flow.state.authMethod === 'passkey'
282 ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete']
283 : ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'])
284
285 function getCurrentStepIndex(): number {
286 const isPasskey = flow.state.authMethod === 'passkey'
287 switch (flow.state.step) {
288 case 'welcome':
289 case 'source-handle': return 0
290 case 'choose-handle': return 1
291 case 'review': return 2
292 case 'migrating': return 3
293 case 'email-verify': return 4
294 case 'passkey-setup': return isPasskey ? 5 : 4
295 case 'app-password': return 6
296 case 'plc-token':
297 case 'did-web-update':
298 case 'finalizing': return isPasskey ? 7 : 5
299 case 'success': return isPasskey ? 8 : 6
300 default: return 0
301 }
302 }
303</script>
304
305<div class="migration-wizard">
306 <div class="step-indicator">
307 {#each steps as _, i}
308 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
309 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
310 </div>
311 {#if i < steps.length - 1}
312 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
313 {/if}
314 {/each}
315 </div>
316 <div class="current-step-label">
317 <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
318 </div>
319
320 {#if flow.state.error}
321 <div class="message error">{flow.state.error}</div>
322 {/if}
323
324 {#if flow.state.step === 'welcome'}
325 <div class="step-content">
326 <h2>{$_('migration.inbound.welcome.title')}</h2>
327 <p>{$_('migration.inbound.welcome.desc')}</p>
328
329 <div class="info-box">
330 <h3>{$_('migration.inbound.common.whatWillHappen')}</h3>
331 <ol>
332 <li>{$_('migration.inbound.common.step1')}</li>
333 <li>{$_('migration.inbound.common.step2')}</li>
334 <li>{$_('migration.inbound.common.step3')}</li>
335 <li>{$_('migration.inbound.common.step4')}</li>
336 <li>{$_('migration.inbound.common.step5')}</li>
337 </ol>
338 </div>
339
340 <div class="warning-box">
341 <strong>{$_('migration.inbound.common.beforeProceed')}</strong>
342 <ul>
343 <li>{$_('migration.inbound.common.warning1')}</li>
344 <li>{$_('migration.inbound.common.warning2')}</li>
345 <li>{$_('migration.inbound.common.warning3')}</li>
346 </ul>
347 </div>
348
349 <label class="checkbox-label">
350 <input type="checkbox" bind:checked={understood} />
351 <span>{$_('migration.inbound.welcome.understand')}</span>
352 </label>
353
354 <div class="button-row">
355 <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
356 <button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
357 {$_('migration.inbound.common.continue')}
358 </button>
359 </div>
360 </div>
361
362 {:else if flow.state.step === 'source-handle'}
363 <div class="step-content">
364 <h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2>
365 <p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p>
366
367 {#if isResuming && resumeInfo}
368 <div class="info-box resume-info">
369 <h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3>
370 <div class="resume-details">
371 <div class="resume-row">
372 <span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span>
373 <span class="value">@{resumeInfo.sourceHandle}</span>
374 </div>
375 <div class="resume-row">
376 <span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span>
377 <span class="value">@{resumeInfo.targetHandle}</span>
378 </div>
379 <div class="resume-row">
380 <span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span>
381 <span class="value">{resumeInfo.progressSummary}</span>
382 </div>
383 </div>
384 <p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p>
385 </div>
386 {/if}
387
388 <form onsubmit={handleSourceHandleSubmit}>
389 <div class="field">
390 <label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label>
391 <input
392 id="source-handle"
393 type="text"
394 placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')}
395 bind:value={handleInput}
396 disabled={loading || isResuming}
397 required
398 />
399 <p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p>
400 </div>
401
402 <div class="button-row">
403 <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
404 <button type="submit" disabled={loading || !handleInput.trim()}>
405 {loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))}
406 </button>
407 </div>
408 </form>
409 </div>
410
411 {:else if flow.state.step === 'choose-handle'}
412 <div class="step-content">
413 <h2>{$_('migration.inbound.chooseHandle.title')}</h2>
414 <p>{$_('migration.inbound.chooseHandle.desc')}</p>
415
416 <div class="current-info">
417 <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
418 <span class="value">{flow.state.sourceHandle}</span>
419 </div>
420
421 <div class="field">
422 <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
423 <div class="handle-input-group">
424 <input
425 id="new-handle"
426 type="text"
427 placeholder="username"
428 bind:value={handleInput}
429 onblur={checkHandle}
430 />
431 {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
432 <select bind:value={selectedDomain}>
433 {#each serverInfo.availableUserDomains as domain}
434 <option value={domain}>.{domain}</option>
435 {/each}
436 </select>
437 {/if}
438 </div>
439
440 {#if checkingHandle}
441 <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
442 {:else if handleAvailable === true}
443 <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
444 {:else if handleAvailable === false}
445 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
446 {:else}
447 <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
448 {/if}
449 </div>
450
451 <div class="field">
452 <label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
453 <input
454 id="email"
455 type="email"
456 placeholder="you@example.com"
457 bind:value={flow.state.targetEmail}
458 oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
459 required
460 />
461 </div>
462
463 <div class="field">
464 <label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
465 <div class="auth-method-options">
466 <label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
467 <input
468 type="radio"
469 name="auth-method"
470 value="password"
471 bind:group={selectedAuthMethod}
472 />
473 <div class="auth-option-content">
474 <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
475 <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
476 </div>
477 </label>
478 <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
479 <input
480 type="radio"
481 name="auth-method"
482 value="passkey"
483 bind:group={selectedAuthMethod}
484 />
485 <div class="auth-option-content">
486 <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
487 <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
488 </div>
489 </label>
490 </div>
491 </div>
492
493 {#if selectedAuthMethod === 'password'}
494 <div class="field">
495 <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
496 <input
497 id="new-password"
498 type="password"
499 placeholder="Password for your new account"
500 bind:value={flow.state.targetPassword}
501 oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
502 required
503 minlength="8"
504 />
505 <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
506 </div>
507 {:else}
508 <div class="info-box">
509 <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
510 </div>
511 {/if}
512
513 {#if serverInfo?.inviteCodeRequired}
514 <div class="field">
515 <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
516 <input
517 id="invite"
518 type="text"
519 placeholder="Enter invite code"
520 bind:value={flow.state.inviteCode}
521 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
522 required
523 />
524 </div>
525 {/if}
526
527 <div class="button-row">
528 <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
529 <button
530 disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
531 onclick={proceedToReviewWithAuth}
532 >
533 {$_('migration.inbound.common.continue')}
534 </button>
535 </div>
536 </div>
537
538 {:else if flow.state.step === 'review'}
539 <div class="step-content">
540 <h2>{$_('migration.inbound.review.title')}</h2>
541 <p>{$_('migration.inbound.review.desc')}</p>
542
543 <div class="review-card">
544 <div class="review-row">
545 <span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
546 <span class="value">{flow.state.sourceHandle}</span>
547 </div>
548 <div class="review-row">
549 <span class="label">{$_('migration.inbound.review.newHandle')}:</span>
550 <span class="value">{flow.state.targetHandle}</span>
551 </div>
552 <div class="review-row">
553 <span class="label">{$_('migration.inbound.review.did')}:</span>
554 <span class="value mono">{flow.state.sourceDid}</span>
555 </div>
556 <div class="review-row">
557 <span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
558 <span class="value">{flow.state.sourcePdsUrl}</span>
559 </div>
560 <div class="review-row">
561 <span class="label">{$_('migration.inbound.review.targetPds')}:</span>
562 <span class="value">{window.location.origin}</span>
563 </div>
564 <div class="review-row">
565 <span class="label">{$_('migration.inbound.review.email')}:</span>
566 <span class="value">{flow.state.targetEmail}</span>
567 </div>
568 <div class="review-row">
569 <span class="label">{$_('migration.inbound.review.authentication')}:</span>
570 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
571 </div>
572 </div>
573
574 <div class="warning-box">
575 {$_('migration.inbound.review.warning')}
576 </div>
577
578 <div class="button-row">
579 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
580 <button onclick={startMigration} disabled={loading}>
581 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
582 </button>
583 </div>
584 </div>
585
586 {:else if flow.state.step === 'migrating'}
587 <div class="step-content">
588 <h2>{$_('migration.inbound.migrating.title')}</h2>
589 <p>{$_('migration.inbound.migrating.desc')}</p>
590
591 <div class="progress-section">
592 <div class="progress-item" class:completed={flow.state.progress.repoExported}>
593 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
594 <span>{$_('migration.inbound.migrating.exportRepo')}</span>
595 </div>
596 <div class="progress-item" class:completed={flow.state.progress.repoImported}>
597 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
598 <span>{$_('migration.inbound.migrating.importRepo')}</span>
599 </div>
600 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
601 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
602 <span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
603 </div>
604 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
605 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
606 <span>{$_('migration.inbound.migrating.migratePrefs')}</span>
607 </div>
608 </div>
609
610 {#if flow.state.progress.blobsTotal > 0}
611 <div class="progress-bar">
612 <div
613 class="progress-fill"
614 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
615 ></div>
616 </div>
617 {/if}
618
619 <p class="status-text">{flow.state.progress.currentOperation}</p>
620 </div>
621
622 {:else if flow.state.step === 'passkey-setup'}
623 <div class="step-content">
624 <h2>{$_('migration.inbound.passkeySetup.title')}</h2>
625 <p>{$_('migration.inbound.passkeySetup.desc')}</p>
626
627 {#if flow.state.error}
628 <div class="message error">
629 {flow.state.error}
630 </div>
631 {/if}
632
633 <div class="field">
634 <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
635 <input
636 id="passkey-name"
637 type="text"
638 placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
639 bind:value={passkeyName}
640 disabled={loading}
641 />
642 <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
643 </div>
644
645 <div class="passkey-section">
646 <p>{$_('migration.inbound.passkeySetup.instructions')}</p>
647 <button class="primary" onclick={registerPasskey} disabled={loading}>
648 {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
649 </button>
650 </div>
651 </div>
652
653 {:else if flow.state.step === 'app-password'}
654 <div class="step-content">
655 <h2>{$_('migration.inbound.appPassword.title')}</h2>
656 <p>{$_('migration.inbound.appPassword.desc')}</p>
657
658 <div class="warning-box">
659 <strong>{$_('migration.inbound.appPassword.warning')}</strong>
660 </div>
661
662 <div class="app-password-display">
663 <div class="app-password-label">
664 {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
665 </div>
666 <code class="app-password-code">{flow.state.generatedAppPassword}</code>
667 <button type="button" class="copy-btn" onclick={copyAppPassword}>
668 {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
669 </button>
670 </div>
671
672 <label class="checkbox-label">
673 <input type="checkbox" bind:checked={appPasswordAcknowledged} />
674 <span>{$_('migration.inbound.appPassword.saved')}</span>
675 </label>
676
677 <div class="button-row">
678 <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
679 {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
680 </button>
681 </div>
682 </div>
683
684 {:else if flow.state.step === 'email-verify'}
685 <div class="step-content">
686 <h2>{$_('migration.inbound.emailVerify.title')}</h2>
687 <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p>
688
689 <div class="info-box">
690 <p>
691 {$_('migration.inbound.emailVerify.hint')}
692 </p>
693 </div>
694
695 {#if flow.state.error}
696 <div class="message error">
697 {flow.state.error}
698 </div>
699 {/if}
700
701 <form onsubmit={submitEmailVerify}>
702 <div class="field">
703 <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
704 <input
705 id="email-verify-token"
706 type="text"
707 placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
708 bind:value={flow.state.emailVerifyToken}
709 oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)}
710 disabled={loading}
711 required
712 />
713 </div>
714
715 <div class="button-row">
716 <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}>
717 {$_('migration.inbound.emailVerify.resend')}
718 </button>
719 <button type="submit" disabled={loading || !flow.state.emailVerifyToken}>
720 {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')}
721 </button>
722 </div>
723 </form>
724 </div>
725
726 {:else if flow.state.step === 'plc-token'}
727 <div class="step-content">
728 <h2>{$_('migration.inbound.plcToken.title')}</h2>
729 <p>{$_('migration.inbound.plcToken.desc')}</p>
730
731 <div class="info-box">
732 <p>{$_('migration.inbound.plcToken.info')}</p>
733 </div>
734
735 <form onsubmit={submitPlcToken}>
736 <div class="field">
737 <label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label>
738 <input
739 id="plc-token"
740 type="text"
741 placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')}
742 bind:value={flow.state.plcToken}
743 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
744 disabled={loading}
745 required
746 />
747 </div>
748
749 <div class="button-row">
750 <button type="button" class="ghost" onclick={resendToken} disabled={loading}>
751 {$_('migration.inbound.plcToken.resend')}
752 </button>
753 <button type="submit" disabled={loading || !flow.state.plcToken}>
754 {loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')}
755 </button>
756 </div>
757 </form>
758 </div>
759
760 {:else if flow.state.step === 'did-web-update'}
761 <div class="step-content">
762 <h2>{$_('migration.inbound.didWebUpdate.title')}</h2>
763 <p>{$_('migration.inbound.didWebUpdate.desc')}</p>
764
765 <div class="info-box">
766 <p>
767 {$_('migration.inbound.didWebUpdate.yourDid')} <code>{flow.state.sourceDid}</code>
768 </p>
769 <p style="margin-top: 12px;">
770 {$_('migration.inbound.didWebUpdate.updateInstructions')}
771 </p>
772 </div>
773
774 <div class="code-block">
775 <pre>{`{
776 "@context": [
777 "https://www.w3.org/ns/did/v1",
778 "https://w3id.org/security/multikey/v1",
779 "https://w3id.org/security/suites/secp256k1-2019/v1"
780 ],
781 "id": "${flow.state.sourceDid}",
782 "alsoKnownAs": [
783 "at://${flow.state.targetHandle || '...'}"
784 ],
785 "verificationMethod": [
786 {
787 "id": "${flow.state.sourceDid}#atproto",
788 "type": "Multikey",
789 "controller": "${flow.state.sourceDid}",
790 "publicKeyMultibase": "${flow.state.targetVerificationMethod?.replace('did:key:', '') || '...'}"
791 }
792 ],
793 "service": [
794 {
795 "id": "#atproto_pds",
796 "type": "AtprotoPersonalDataServer",
797 "serviceEndpoint": "${window.location.origin}"
798 }
799 ]
800}`}</pre>
801 </div>
802
803 <div class="warning-box">
804 <strong>{$_('migration.inbound.didWebUpdate.important')}</strong> {$_('migration.inbound.didWebUpdate.verifyFirst')}
805 {$_('migration.inbound.didWebUpdate.fileLocation')} <code>https://{flow.state.sourceDid.replace('did:web:', '')}/.well-known/did.json</code>
806 </div>
807
808 <div class="button-row">
809 <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
810 <button onclick={completeDidWeb} disabled={loading}>
811 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')}
812 </button>
813 </div>
814 </div>
815
816 {:else if flow.state.step === 'finalizing'}
817 <div class="step-content">
818 <h2>{$_('migration.inbound.finalizing.title')}</h2>
819 <p>{$_('migration.inbound.finalizing.desc')}</p>
820
821 <div class="progress-section">
822 <div class="progress-item" class:completed={flow.state.progress.plcSigned}>
823 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
824 <span>{$_('migration.inbound.finalizing.signingPlc')}</span>
825 </div>
826 <div class="progress-item" class:completed={flow.state.progress.activated}>
827 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
828 <span>{$_('migration.inbound.finalizing.activating')}</span>
829 </div>
830 <div class="progress-item" class:completed={flow.state.progress.deactivated}>
831 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
832 <span>{$_('migration.inbound.finalizing.deactivating')}</span>
833 </div>
834 </div>
835
836 <p class="status-text">{flow.state.progress.currentOperation}</p>
837 </div>
838
839 {:else if flow.state.step === 'success'}
840 <div class="step-content success-content">
841 <div class="success-icon">✓</div>
842 <h2>{$_('migration.inbound.success.title')}</h2>
843 <p>{$_('migration.inbound.success.desc')}</p>
844
845 <div class="success-details">
846 <div class="detail-row">
847 <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
848 <span class="value">{flow.state.targetHandle}</span>
849 </div>
850 <div class="detail-row">
851 <span class="label">{$_('migration.inbound.success.did')}:</span>
852 <span class="value mono">{flow.state.sourceDid}</span>
853 </div>
854 </div>
855
856 {#if flow.state.progress.blobsFailed.length > 0}
857 <div class="message warning">
858 {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
859 </div>
860 {/if}
861
862 <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
863 </div>
864
865 {:else if flow.state.step === 'error'}
866 <div class="step-content">
867 <h2>{$_('migration.inbound.error.title')}</h2>
868 <p>{$_('migration.inbound.error.desc')}</p>
869
870 <div class="message error">
871 {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
872 </div>
873
874 <div class="button-row">
875 <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
876 </div>
877 </div>
878 {/if}
879</div>
880
881<style>
882 .passkey-section {
883 margin-top: 16px;
884 }
885 .passkey-section button {
886 width: 100%;
887 margin-top: 12px;
888 }
889 .app-password-display {
890 background: var(--bg-card);
891 border: 2px solid var(--accent);
892 border-radius: var(--radius-xl);
893 padding: var(--space-6);
894 text-align: center;
895 margin: var(--space-4) 0;
896 }
897 .app-password-label {
898 font-size: var(--text-sm);
899 color: var(--text-secondary);
900 margin-bottom: var(--space-4);
901 }
902 .app-password-code {
903 display: block;
904 font-size: var(--text-xl);
905 font-family: ui-monospace, monospace;
906 letter-spacing: 0.1em;
907 padding: var(--space-5);
908 background: var(--bg-input);
909 border-radius: var(--radius-md);
910 margin-bottom: var(--space-4);
911 user-select: all;
912 }
913 .copy-btn {
914 padding: var(--space-3) var(--space-5);
915 font-size: var(--text-sm);
916 }
917 .resume-info {
918 margin-bottom: var(--space-5);
919 }
920 .resume-info h3 {
921 margin: 0 0 var(--space-3) 0;
922 font-size: var(--text-base);
923 }
924 .resume-details {
925 display: flex;
926 flex-direction: column;
927 gap: var(--space-2);
928 }
929 .resume-row {
930 display: flex;
931 justify-content: space-between;
932 font-size: var(--text-sm);
933 }
934 .resume-row .label {
935 color: var(--text-secondary);
936 }
937 .resume-row .value {
938 font-weight: var(--font-medium);
939 }
940 .resume-note {
941 margin-top: var(--space-3);
942 font-size: var(--text-sm);
943 font-style: italic;
944 }
945</style>