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