this repo has no description
1<script lang="ts">
2 import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { _ } from '../lib/i18n'
5 import {
6 createInboundMigrationFlow,
7 createOutboundMigrationFlow,
8 hasPendingMigration,
9 getResumeInfo,
10 clearMigrationState,
11 loadMigrationState,
12 } from '../lib/migration'
13 import InboundWizard from '../components/migration/InboundWizard.svelte'
14 import OutboundWizard from '../components/migration/OutboundWizard.svelte'
15
16 const auth = getAuthState()
17
18 type Direction = 'select' | 'inbound' | 'outbound'
19 let direction = $state<Direction>('select')
20 let showResumeModal = $state(false)
21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
22 let oauthError = $state<string | null>(null)
23 let oauthLoading = $state(false)
24
25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
26 let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
27 let oauthCallbackProcessed = $state(false)
28
29 $effect(() => {
30 if (oauthCallbackProcessed) return
31
32 const url = new URL(window.location.href)
33 const code = url.searchParams.get('code')
34 const state = url.searchParams.get('state')
35 const errorParam = url.searchParams.get('error')
36 const errorDescription = url.searchParams.get('error_description')
37
38 if (errorParam) {
39 oauthCallbackProcessed = true
40 oauthError = errorDescription || errorParam
41 window.history.replaceState({}, '', '/#/migrate')
42 return
43 }
44
45 if (code && state) {
46 oauthCallbackProcessed = true
47 window.history.replaceState({}, '', '/#/migrate')
48 direction = 'inbound'
49 oauthLoading = true
50 inboundFlow = createInboundMigrationFlow()
51
52 inboundFlow.handleOAuthCallback(code, state)
53 .then(() => {
54 oauthLoading = false
55 })
56 .catch((e) => {
57 oauthLoading = false
58 oauthError = e.message || 'OAuth authentication failed'
59 inboundFlow = null
60 direction = 'select'
61 })
62 return
63 }
64 })
65
66 const urlParams = new URLSearchParams(window.location.search)
67 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
68
69 if (!hasOAuthCallback && hasPendingMigration()) {
70 resumeInfo = getResumeInfo()
71 if (resumeInfo) {
72 const stored = loadMigrationState()
73 if (stored) {
74 if (stored.direction === 'inbound') {
75 direction = 'inbound'
76 inboundFlow = createInboundMigrationFlow()
77 inboundFlow.resumeFromState(stored)
78 } else {
79 direction = 'outbound'
80 outboundFlow = createOutboundMigrationFlow()
81 }
82 }
83 }
84 }
85
86 function selectInbound() {
87 direction = 'inbound'
88 inboundFlow = createInboundMigrationFlow()
89 }
90
91 function selectOutbound() {
92 if (!auth.session) {
93 navigate('/login')
94 return
95 }
96 direction = 'outbound'
97 outboundFlow = createOutboundMigrationFlow()
98 outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
99 }
100
101 function handleResume() {
102 const stored = loadMigrationState()
103 if (!stored) return
104
105 showResumeModal = false
106
107 if (stored.direction === 'inbound') {
108 direction = 'inbound'
109 inboundFlow = createInboundMigrationFlow()
110 inboundFlow.resumeFromState(stored)
111 } else {
112 if (!auth.session) {
113 navigate('/login')
114 return
115 }
116 direction = 'outbound'
117 outboundFlow = createOutboundMigrationFlow()
118 outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
119 }
120 }
121
122 function handleStartOver() {
123 showResumeModal = false
124 clearMigrationState()
125 resumeInfo = null
126 }
127
128 function handleBack() {
129 if (inboundFlow) {
130 inboundFlow.reset()
131 inboundFlow = null
132 }
133 if (outboundFlow) {
134 outboundFlow.reset()
135 outboundFlow = null
136 }
137 direction = 'select'
138 }
139
140 function handleInboundComplete() {
141 const session = inboundFlow?.getLocalSession()
142 if (session) {
143 setSession({
144 did: session.did,
145 handle: session.handle,
146 accessJwt: session.accessJwt,
147 refreshJwt: '',
148 })
149 }
150 navigate('/dashboard')
151 }
152
153 async function handleOutboundComplete() {
154 await logout()
155 navigate('/login')
156 }
157</script>
158
159<div class="migration-page">
160 {#if showResumeModal && resumeInfo}
161 <div class="modal-overlay">
162 <div class="modal">
163 <h2>{$_('migration.resume.title')}</h2>
164 <p>{$_('migration.resume.incomplete')}</p>
165 <div class="resume-details">
166 <div class="detail-row">
167 <span class="label">{$_('migration.resume.direction')}:</span>
168 <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
169 </div>
170 {#if resumeInfo.sourceHandle}
171 <div class="detail-row">
172 <span class="label">{$_('migration.resume.from')}:</span>
173 <span class="value">{resumeInfo.sourceHandle}</span>
174 </div>
175 {/if}
176 {#if resumeInfo.targetHandle}
177 <div class="detail-row">
178 <span class="label">{$_('migration.resume.to')}:</span>
179 <span class="value">{resumeInfo.targetHandle}</span>
180 </div>
181 {/if}
182 <div class="detail-row">
183 <span class="label">{$_('migration.resume.progress')}:</span>
184 <span class="value">{resumeInfo.progressSummary}</span>
185 </div>
186 </div>
187 <p class="note">{$_('migration.resume.reenterCredentials')}</p>
188 <div class="modal-actions">
189 <button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button>
190 <button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button>
191 </div>
192 </div>
193 </div>
194 {/if}
195
196 {#if oauthLoading}
197 <div class="oauth-loading">
198 <div class="loading-spinner"></div>
199 <p>{$_('migration.oauthCompleting')}</p>
200 </div>
201 {:else if oauthError}
202 <div class="oauth-error">
203 <h2>{$_('migration.oauthFailed')}</h2>
204 <p>{oauthError}</p>
205 <button onclick={() => { oauthError = null; direction = 'select' }}>{$_('migration.tryAgain')}</button>
206 </div>
207 {:else if direction === 'select'}
208 <header class="page-header">
209 <h1>{$_('migration.title')}</h1>
210 <p class="subtitle">{$_('migration.subtitle')}</p>
211 </header>
212
213 <div class="direction-cards">
214 <button class="direction-card ghost" onclick={selectInbound}>
215 <div class="card-icon">↓</div>
216 <h2>{$_('migration.migrateHere')}</h2>
217 <p>{$_('migration.migrateHereDesc')}</p>
218 <ul class="features">
219 <li>{$_('migration.bringDid')}</li>
220 <li>{$_('migration.transferData')}</li>
221 <li>{$_('migration.keepFollowers')}</li>
222 </ul>
223 </button>
224
225 <button class="direction-card ghost" onclick={selectOutbound} disabled>
226 <div class="card-icon">↑</div>
227 <h2>{$_('migration.migrateAway')}</h2>
228 <p>{$_('migration.migrateAwayDesc')}</p>
229 <ul class="features">
230 <li>{$_('migration.exportRepo')}</li>
231 <li>{$_('migration.transferToPds')}</li>
232 <li>{$_('migration.updateIdentity')}</li>
233 </ul>
234 <p class="login-required">{$_('migration.comingSoon')}</p>
235 </button>
236 </div>
237
238 <div class="info-section">
239 <h3>{$_('migration.whatIsMigration')}</h3>
240 <p>{$_('migration.whatIsMigrationDesc')}</p>
241
242 <h3>{$_('migration.beforeMigrate')}</h3>
243 <ul>
244 <li>{$_('migration.beforeMigrate1')}</li>
245 <li>{$_('migration.beforeMigrate2')}</li>
246 <li>{$_('migration.beforeMigrate3')}</li>
247 <li>{$_('migration.beforeMigrate4')}</li>
248 </ul>
249
250 <div class="warning-box">
251 <strong>Important:</strong> {$_('migration.importantWarning')}
252 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
253 {$_('migration.learnMore')}
254 </a>
255 </div>
256 </div>
257
258 {:else if direction === 'inbound' && inboundFlow}
259 <InboundWizard
260 flow={inboundFlow}
261 {resumeInfo}
262 onBack={handleBack}
263 onComplete={handleInboundComplete}
264 />
265
266 {:else if direction === 'outbound' && outboundFlow}
267 <OutboundWizard
268 flow={outboundFlow}
269 onBack={handleBack}
270 onComplete={handleOutboundComplete}
271 />
272 {/if}
273</div>
274
275<style>
276 .migration-page {
277 max-width: var(--width-lg);
278 margin: var(--space-9) auto;
279 padding: var(--space-7);
280 }
281
282 .page-header {
283 text-align: center;
284 margin-bottom: var(--space-8);
285 }
286
287 .page-header h1 {
288 margin: 0 0 var(--space-3) 0;
289 }
290
291 .subtitle {
292 color: var(--text-secondary);
293 margin: 0;
294 font-size: var(--text-lg);
295 }
296
297 .direction-cards {
298 display: grid;
299 grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
300 gap: var(--space-6);
301 margin-bottom: var(--space-8);
302 }
303
304 .direction-card {
305 background: var(--bg-secondary);
306 border: 1px solid var(--border);
307 border-radius: var(--radius-xl);
308 padding: var(--space-6);
309 text-align: left;
310 cursor: pointer;
311 transition: all 0.2s ease;
312 }
313
314 .direction-card:hover:not(:disabled) {
315 border-color: var(--accent);
316 transform: translateY(-2px);
317 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
318 }
319
320 .direction-card:disabled {
321 opacity: 0.6;
322 cursor: not-allowed;
323 }
324
325 .card-icon {
326 font-size: var(--text-3xl);
327 margin-bottom: var(--space-4);
328 color: var(--accent);
329 }
330
331 .direction-card h2 {
332 margin: 0 0 var(--space-3) 0;
333 font-size: var(--text-xl);
334 color: var(--text-primary);
335 }
336
337 .direction-card p {
338 color: var(--text-secondary);
339 margin: 0 0 var(--space-4) 0;
340 font-size: var(--text-sm);
341 }
342
343 .features {
344 margin: 0;
345 padding-left: var(--space-5);
346 color: var(--text-secondary);
347 font-size: var(--text-sm);
348 }
349
350 .features li {
351 margin-bottom: var(--space-2);
352 }
353
354 .login-required {
355 color: var(--warning-text);
356 font-weight: var(--font-medium);
357 margin-top: var(--space-4);
358 }
359
360 .info-section {
361 background: var(--bg-secondary);
362 border-radius: var(--radius-xl);
363 padding: var(--space-6);
364 }
365
366 .info-section h3 {
367 margin: 0 0 var(--space-3) 0;
368 font-size: var(--text-lg);
369 }
370
371 .info-section h3:not(:first-child) {
372 margin-top: var(--space-6);
373 }
374
375 .info-section p {
376 color: var(--text-secondary);
377 line-height: var(--leading-relaxed);
378 margin: 0;
379 }
380
381 .info-section ul {
382 color: var(--text-secondary);
383 padding-left: var(--space-5);
384 margin: var(--space-3) 0 0 0;
385 }
386
387 .info-section li {
388 margin-bottom: var(--space-2);
389 }
390
391 .warning-box {
392 margin-top: var(--space-6);
393 padding: var(--space-5);
394 background: var(--warning-bg);
395 border: 1px solid var(--warning-border);
396 border-radius: var(--radius-lg);
397 font-size: var(--text-sm);
398 }
399
400 .warning-box strong {
401 color: var(--warning-text);
402 }
403
404 .warning-box a {
405 display: block;
406 margin-top: var(--space-3);
407 color: var(--accent);
408 }
409
410 .modal-overlay {
411 position: fixed;
412 inset: 0;
413 background: rgba(0, 0, 0, 0.5);
414 display: flex;
415 align-items: center;
416 justify-content: center;
417 z-index: 1000;
418 }
419
420 .modal {
421 background: var(--bg-primary);
422 border-radius: var(--radius-xl);
423 padding: var(--space-6);
424 max-width: 400px;
425 width: 90%;
426 }
427
428 .modal h2 {
429 margin: 0 0 var(--space-4) 0;
430 }
431
432 .modal p {
433 color: var(--text-secondary);
434 margin: 0 0 var(--space-4) 0;
435 }
436
437 .resume-details {
438 background: var(--bg-secondary);
439 border-radius: var(--radius-lg);
440 padding: var(--space-4);
441 margin-bottom: var(--space-4);
442 }
443
444 .detail-row {
445 display: flex;
446 justify-content: space-between;
447 padding: var(--space-2) 0;
448 font-size: var(--text-sm);
449 }
450
451 .detail-row:not(:last-child) {
452 border-bottom: 1px solid var(--border);
453 }
454
455 .detail-row .label {
456 color: var(--text-secondary);
457 }
458
459 .detail-row .value {
460 font-weight: var(--font-medium);
461 }
462
463 .note {
464 font-size: var(--text-sm);
465 font-style: italic;
466 }
467
468 .modal-actions {
469 display: flex;
470 gap: var(--space-3);
471 justify-content: flex-end;
472 }
473
474 .oauth-loading {
475 display: flex;
476 flex-direction: column;
477 align-items: center;
478 justify-content: center;
479 padding: var(--space-12);
480 text-align: center;
481 }
482
483 .loading-spinner {
484 width: 48px;
485 height: 48px;
486 border: 3px solid var(--border);
487 border-top-color: var(--accent);
488 border-radius: 50%;
489 animation: spin 1s linear infinite;
490 margin-bottom: var(--space-4);
491 }
492
493 @keyframes spin {
494 to { transform: rotate(360deg); }
495 }
496
497 .oauth-loading p {
498 color: var(--text-secondary);
499 margin: 0;
500 }
501
502 .oauth-error {
503 max-width: 500px;
504 margin: 0 auto;
505 text-align: center;
506 padding: var(--space-8);
507 background: var(--error-bg);
508 border: 1px solid var(--error-border);
509 border-radius: var(--radius-xl);
510 }
511
512 .oauth-error h2 {
513 margin: 0 0 var(--space-4) 0;
514 color: var(--error-text);
515 }
516
517 .oauth-error p {
518 color: var(--text-secondary);
519 margin: 0 0 var(--space-5) 0;
520 }
521</style>