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