this repo has no description
1<script lang="ts">
2 import type { OutboundMigrationFlow } from '../../lib/migration'
3 import type { ServerDescription } from '../../lib/migration/types'
4 import { getAuthState, logout } from '../../lib/auth.svelte'
5 import '../../styles/migration.css'
6
7 interface Props {
8 flow: OutboundMigrationFlow
9 onBack: () => void
10 onComplete: () => void
11 }
12
13 let { flow, onBack, onComplete }: Props = $props()
14
15 const auth = getAuthState()
16
17 let loading = $state(false)
18 let understood = $state(false)
19 let pdsUrlInput = $state('')
20 let handleInput = $state('')
21 let selectedDomain = $state('')
22 let confirmFinal = $state(false)
23
24 $effect(() => {
25 if (flow.state.step === 'success') {
26 setTimeout(async () => {
27 await logout()
28 onComplete()
29 }, 3000)
30 }
31 })
32
33 $effect(() => {
34 if (flow.state.targetServerInfo?.availableUserDomains?.length) {
35 selectedDomain = flow.state.targetServerInfo.availableUserDomains[0]
36 }
37 })
38
39 async function validatePds(e: Event) {
40 e.preventDefault()
41 loading = true
42 flow.updateField('error', null)
43
44 try {
45 let url = pdsUrlInput.trim()
46 if (!url.startsWith('http://') && !url.startsWith('https://')) {
47 url = `https://${url}`
48 }
49 await flow.validateTargetPds(url)
50 flow.setStep('new-account')
51 } catch (err) {
52 flow.setError((err as Error).message)
53 } finally {
54 loading = false
55 }
56 }
57
58 function proceedToReview() {
59 const fullHandle = handleInput.includes('.')
60 ? handleInput
61 : `${handleInput}.${selectedDomain}`
62
63 flow.updateField('targetHandle', fullHandle)
64 flow.setStep('review')
65 }
66
67 async function startMigration() {
68 if (!auth.session) return
69 loading = true
70 try {
71 await flow.startMigration(auth.session.did)
72 } catch (err) {
73 flow.setError((err as Error).message)
74 } finally {
75 loading = false
76 }
77 }
78
79 async function submitPlcToken(e: Event) {
80 e.preventDefault()
81 loading = true
82 try {
83 await flow.submitPlcToken(flow.state.plcToken)
84 } catch (err) {
85 flow.setError((err as Error).message)
86 } finally {
87 loading = false
88 }
89 }
90
91 async function resendToken() {
92 loading = true
93 try {
94 await flow.resendPlcToken()
95 flow.setError(null)
96 } catch (err) {
97 flow.setError((err as Error).message)
98 } finally {
99 loading = false
100 }
101 }
102
103 function isDidWeb(): boolean {
104 return auth.session?.did?.startsWith('did:web:') ?? false
105 }
106
107 const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete']
108 function getCurrentStepIndex(): number {
109 switch (flow.state.step) {
110 case 'welcome': return -1
111 case 'target-pds': return 0
112 case 'new-account': return 1
113 case 'review': return 2
114 case 'migrating': return 3
115 case 'plc-token':
116 case 'finalizing': return 4
117 case 'success': return 5
118 default: return 0
119 }
120 }
121</script>
122
123<div class="migration-wizard">
124 {#if flow.state.step !== 'welcome'}
125 <div class="step-indicator">
126 {#each steps as stepName, i}
127 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
128 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
129 <span class="step-label">{stepName}</span>
130 </div>
131 {#if i < steps.length - 1}
132 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
133 {/if}
134 {/each}
135 </div>
136 {/if}
137
138 {#if flow.state.error}
139 <div class="migration-message error">{flow.state.error}</div>
140 {/if}
141
142 {#if flow.state.step === 'welcome'}
143 <div class="step-content">
144 <h2>Migrate Your Account Away</h2>
145 <p>This wizard will help you move your AT Protocol account from this PDS to another one.</p>
146
147 <div class="current-account">
148 <span class="label">Current account:</span>
149 <span class="value">@{auth.session?.handle}</span>
150 </div>
151
152 {#if isDidWeb()}
153 <div class="migration-warning-box">
154 <strong>did:web Migration Notice</strong>
155 <p>
156 Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
157 continue serving your DID document with an updated service endpoint pointing to your new PDS.
158 </p>
159 <p>
160 You can return here anytime to update the forwarding if you migrate again in the future.
161 </p>
162 </div>
163 {/if}
164
165 <div class="migration-info-box">
166 <h3>What will happen:</h3>
167 <ol>
168 <li>Choose your new PDS</li>
169 <li>Set up your account on the new server</li>
170 <li>Your repository and blobs will be transferred</li>
171 <li>Verify the migration via email</li>
172 <li>Your identity will be updated to point to the new PDS</li>
173 <li>Your account here will be deactivated</li>
174 </ol>
175 </div>
176
177 <div class="migration-warning-box">
178 <strong>Before you proceed:</strong>
179 <ul>
180 <li>You need access to the email registered with this account</li>
181 <li>You will lose access to this account on this PDS</li>
182 <li>Make sure you trust the destination PDS</li>
183 <li>Large accounts may take several minutes to transfer</li>
184 </ul>
185 </div>
186
187 <label class="checkbox-label">
188 <input type="checkbox" bind:checked={understood} />
189 <span>I understand that my account will be moved and deactivated here</span>
190 </label>
191
192 <div class="button-row">
193 <button class="ghost" onclick={onBack}>Cancel</button>
194 <button disabled={!understood} onclick={() => flow.setStep('target-pds')}>
195 Continue
196 </button>
197 </div>
198 </div>
199
200 {:else if flow.state.step === 'target-pds'}
201 <div class="step-content">
202 <h2>Choose Your New PDS</h2>
203 <p>Enter the URL of the PDS you want to migrate to.</p>
204
205 <form onsubmit={validatePds}>
206 <div class="migration-field">
207 <label for="pds-url">PDS URL</label>
208 <input
209 id="pds-url"
210 type="text"
211 placeholder="pds.example.com"
212 bind:value={pdsUrlInput}
213 disabled={loading}
214 required
215 />
216 <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
217 </div>
218
219 <div class="button-row">
220 <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button>
221 <button type="submit" disabled={loading || !pdsUrlInput.trim()}>
222 {loading ? 'Checking...' : 'Connect'}
223 </button>
224 </div>
225 </form>
226
227 {#if flow.state.targetServerInfo}
228 <div class="server-info">
229 <h3>Connected to PDS</h3>
230 <div class="info-row">
231 <span class="label">Server:</span>
232 <span class="value">{flow.state.targetPdsUrl}</span>
233 </div>
234 {#if flow.state.targetServerInfo.availableUserDomains.length > 0}
235 <div class="info-row">
236 <span class="label">Available domains:</span>
237 <span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span>
238 </div>
239 {/if}
240 <div class="info-row">
241 <span class="label">Invite required:</span>
242 <span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span>
243 </div>
244 {#if flow.state.targetServerInfo.links?.termsOfService}
245 <a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener">
246 Terms of Service
247 </a>
248 {/if}
249 {#if flow.state.targetServerInfo.links?.privacyPolicy}
250 <a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener">
251 Privacy Policy
252 </a>
253 {/if}
254 </div>
255 {/if}
256 </div>
257
258 {:else if flow.state.step === 'new-account'}
259 <div class="step-content">
260 <h2>Set Up Your New Account</h2>
261 <p>Configure your account details on the new PDS.</p>
262
263 <div class="current-info">
264 <span class="label">Migrating to:</span>
265 <span class="value">{flow.state.targetPdsUrl}</span>
266 </div>
267
268 <div class="migration-field">
269 <label for="new-handle">New Handle</label>
270 <div class="handle-input-group">
271 <input
272 id="new-handle"
273 type="text"
274 placeholder="username"
275 bind:value={handleInput}
276 />
277 {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
278 <select bind:value={selectedDomain}>
279 {#each flow.state.targetServerInfo.availableUserDomains as domain}
280 <option value={domain}>.{domain}</option>
281 {/each}
282 </select>
283 {/if}
284 </div>
285 <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
286 </div>
287
288 <div class="migration-field">
289 <label for="email">Email Address</label>
290 <input
291 id="email"
292 type="email"
293 placeholder="you@example.com"
294 bind:value={flow.state.targetEmail}
295 oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
296 required
297 />
298 </div>
299
300 <div class="migration-field">
301 <label for="new-password">Password</label>
302 <input
303 id="new-password"
304 type="password"
305 placeholder="Password for your new account"
306 bind:value={flow.state.targetPassword}
307 oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
308 required
309 minlength="8"
310 />
311 <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p>
312 </div>
313
314 {#if flow.state.targetServerInfo?.inviteCodeRequired}
315 <div class="migration-field">
316 <label for="invite">Invite Code</label>
317 <input
318 id="invite"
319 type="text"
320 placeholder="Enter invite code"
321 bind:value={flow.state.inviteCode}
322 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
323 required
324 />
325 <p class="migration-hint">Required by this PDS to create an account</p>
326 </div>
327 {/if}
328
329 <div class="button-row">
330 <button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button>
331 <button
332 disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword}
333 onclick={proceedToReview}
334 >
335 Continue
336 </button>
337 </div>
338 </div>
339
340 {:else if flow.state.step === 'review'}
341 <div class="step-content">
342 <h2>Review Migration</h2>
343 <p>Please confirm the details of your migration.</p>
344
345 <div class="review-card">
346 <div class="review-row">
347 <span class="label">Current Handle:</span>
348 <span class="value">@{auth.session?.handle}</span>
349 </div>
350 <div class="review-row">
351 <span class="label">New Handle:</span>
352 <span class="value">@{flow.state.targetHandle}</span>
353 </div>
354 <div class="review-row">
355 <span class="label">DID:</span>
356 <span class="value mono">{auth.session?.did}</span>
357 </div>
358 <div class="review-row">
359 <span class="label">From PDS:</span>
360 <span class="value">{window.location.origin}</span>
361 </div>
362 <div class="review-row">
363 <span class="label">To PDS:</span>
364 <span class="value">{flow.state.targetPdsUrl}</span>
365 </div>
366 <div class="review-row">
367 <span class="label">New Email:</span>
368 <span class="value">{flow.state.targetEmail}</span>
369 </div>
370 </div>
371
372 <div class="migration-warning-box final-warning">
373 <strong>This action cannot be easily undone!</strong>
374 <p>
375 After migration completes, your account on this PDS will be deactivated.
376 To return, you would need to migrate back from the new PDS.
377 </p>
378 </div>
379
380 <label class="checkbox-label">
381 <input type="checkbox" bind:checked={confirmFinal} />
382 <span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span>
383 </label>
384
385 <div class="button-row">
386 <button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button>
387 <button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}>
388 {loading ? 'Starting...' : 'Start Migration'}
389 </button>
390 </div>
391 </div>
392
393 {:else if flow.state.step === 'migrating'}
394 <div class="step-content">
395 <h2>Migration in Progress</h2>
396 <p>Please wait while your account is being transferred...</p>
397
398 <div class="progress-section">
399 <div class="progress-item" class:completed={flow.state.progress.repoExported}>
400 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
401 <span>Export repository</span>
402 </div>
403 <div class="progress-item" class:completed={flow.state.progress.repoImported}>
404 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
405 <span>Import repository to new PDS</span>
406 </div>
407 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
408 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
409 <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
410 </div>
411 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
412 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
413 <span>Migrate preferences</span>
414 </div>
415 </div>
416
417 {#if flow.state.progress.blobsTotal > 0}
418 <div class="progress-bar">
419 <div
420 class="progress-fill"
421 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
422 ></div>
423 </div>
424 {/if}
425
426 <p class="status-text">{flow.state.progress.currentOperation}</p>
427 </div>
428
429 {:else if flow.state.step === 'plc-token'}
430 <div class="step-content">
431 <h2>Verify Migration</h2>
432 <p>A verification code has been sent to your email ({auth.session?.email}).</p>
433
434 <div class="migration-info-box">
435 <p>
436 This code confirms you have access to the account and authorizes updating your identity
437 to point to the new PDS.
438 </p>
439 </div>
440
441 <form onsubmit={submitPlcToken}>
442 <div class="migration-field">
443 <label for="plc-token">Verification Code</label>
444 <input
445 id="plc-token"
446 type="text"
447 placeholder="Enter code from email"
448 bind:value={flow.state.plcToken}
449 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
450 disabled={loading}
451 required
452 />
453 </div>
454
455 <div class="button-row">
456 <button type="button" class="ghost" onclick={resendToken} disabled={loading}>
457 Resend Code
458 </button>
459 <button type="submit" disabled={loading || !flow.state.plcToken}>
460 {loading ? 'Verifying...' : 'Complete Migration'}
461 </button>
462 </div>
463 </form>
464 </div>
465
466 {:else if flow.state.step === 'finalizing'}
467 <div class="step-content">
468 <h2>Finalizing Migration</h2>
469 <p>Please wait while we complete the migration...</p>
470
471 <div class="progress-section">
472 <div class="progress-item" class:completed={flow.state.progress.plcSigned}>
473 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
474 <span>Sign identity update</span>
475 </div>
476 <div class="progress-item" class:completed={flow.state.progress.activated}>
477 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
478 <span>Activate account on new PDS</span>
479 </div>
480 <div class="progress-item" class:completed={flow.state.progress.deactivated}>
481 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
482 <span>Deactivate account here</span>
483 </div>
484 </div>
485
486 <p class="status-text">{flow.state.progress.currentOperation}</p>
487 </div>
488
489 {:else if flow.state.step === 'success'}
490 <div class="step-content success-content">
491 <div class="success-icon">✓</div>
492 <h2>Migration Complete!</h2>
493 <p>Your account has been successfully migrated to your new PDS.</p>
494
495 <div class="success-details">
496 <div class="detail-row">
497 <span class="label">Your new handle:</span>
498 <span class="value">@{flow.state.targetHandle}</span>
499 </div>
500 <div class="detail-row">
501 <span class="label">New PDS:</span>
502 <span class="value">{flow.state.targetPdsUrl}</span>
503 </div>
504 <div class="detail-row">
505 <span class="label">DID:</span>
506 <span class="value mono">{auth.session?.did}</span>
507 </div>
508 </div>
509
510 {#if flow.state.progress.blobsFailed.length > 0}
511 <div class="migration-warning-box">
512 <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
513 These may be images or other media that are no longer available.
514 </div>
515 {/if}
516
517 <div class="next-steps">
518 <h3>Next Steps</h3>
519 <ol>
520 <li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li>
521 <li>Log in with your new credentials</li>
522 <li>Your followers and following will continue to work</li>
523 </ol>
524 </div>
525
526 <p class="redirect-text">Logging out in a moment...</p>
527 </div>
528
529 {:else if flow.state.step === 'error'}
530 <div class="step-content">
531 <h2>Migration Error</h2>
532 <p>An error occurred during migration.</p>
533
534 <div class="migration-error-box">
535 {flow.state.error}
536 </div>
537
538 <div class="button-row">
539 <button class="ghost" onclick={onBack}>Start Over</button>
540 </div>
541 </div>
542 {/if}
543</div>
544
545<style>
546</style>