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