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({}, '', '/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('/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('/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>