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