this repo has no description
at main 14 kB view raw
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>