this repo has no description
at main 20 kB view raw
1<script lang="ts"> 2 import type { OfflineInboundMigrationFlow } from '../../lib/migration' 3 import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 4 import { getErrorMessage } from '../../lib/migration/types' 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 import { _ } from '../../lib/i18n' 7 import '../../styles/migration.css' 8 import ErrorStep from './ErrorStep.svelte' 9 import SuccessStep from './SuccessStep.svelte' 10 import ChooseHandleStep from './ChooseHandleStep.svelte' 11 import EmailVerifyStep from './EmailVerifyStep.svelte' 12 import PasskeySetupStep from './PasskeySetupStep.svelte' 13 import AppPasswordStep from './AppPasswordStep.svelte' 14 15 interface Props { 16 flow: OfflineInboundMigrationFlow 17 onBack: () => void 18 onComplete: () => void 19 } 20 21 let { flow, onBack, onComplete }: Props = $props() 22 23 let serverInfo = $state<ServerDescription | null>(null) 24 let loading = $state(false) 25 let understood = $state(false) 26 let handleInput = $state('') 27 let selectedDomain = $state('') 28 let handleAvailable = $state<boolean | null>(null) 29 let checkingHandle = $state(false) 30 let validatingKey = $state(false) 31 let keyValid = $state<boolean | null>(null) 32 let fileInputRef = $state<HTMLInputElement | null>(null) 33 let selectedAuthMethod = $state<AuthMethod>('password') 34 let passkeyName = $state('') 35 36 let redirectTriggered = $state(false) 37 38 $effect(() => { 39 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 40 loadServerInfo() 41 } 42 if (flow.state.step === 'choose-handle') { 43 handleInput = '' 44 handleAvailable = null 45 } 46 }) 47 48 $effect(() => { 49 if (flow.state.step === 'success' && !redirectTriggered) { 50 redirectTriggered = true 51 setTimeout(() => { 52 onComplete() 53 }, 2000) 54 } 55 }) 56 57 $effect(() => { 58 if (flow.state.step === 'email-verify') { 59 const interval = setInterval(async () => { 60 if (flow.state.emailVerifyToken.trim()) return 61 await flow.checkEmailVerifiedAndProceed() 62 }, 3000) 63 return () => clearInterval(interval) 64 } 65 return undefined 66 }) 67 68 async function loadServerInfo() { 69 if (!serverInfo) { 70 serverInfo = await flow.loadLocalServerInfo() 71 if (serverInfo.availableUserDomains.length > 0) { 72 selectedDomain = serverInfo.availableUserDomains[0] 73 } 74 } 75 } 76 77 function handleFileSelect(e: Event) { 78 const input = e.target as HTMLInputElement 79 const file = input.files?.[0] 80 if (!file) return 81 82 const reader = new FileReader() 83 reader.onload = () => { 84 const arrayBuffer = reader.result as ArrayBuffer 85 flow.setCarFile(new Uint8Array(arrayBuffer), file.name) 86 } 87 reader.readAsArrayBuffer(file) 88 } 89 90 async function validateRotationKey() { 91 if (!flow.state.rotationKey || !flow.state.userDid) return 92 93 validatingKey = true 94 keyValid = null 95 96 try { 97 const isValid = await flow.validateRotationKey() 98 keyValid = isValid 99 if (isValid) { 100 flow.setStep('choose-handle') 101 } 102 } catch (err) { 103 flow.setError(getErrorMessage(err)) 104 keyValid = false 105 } finally { 106 validatingKey = false 107 } 108 } 109 110 async function startMigration() { 111 loading = true 112 try { 113 await flow.runMigration() 114 } catch (err) { 115 flow.setError(getErrorMessage(err)) 116 } finally { 117 loading = false 118 } 119 } 120 121 const steps = $derived( 122 flow.state.authMethod === 'passkey' 123 ? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete'] 124 : ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete'] 125 ) 126 127 function getCurrentStepIndex(): number { 128 const isPasskey = flow.state.authMethod === 'passkey' 129 switch (flow.state.step) { 130 case 'welcome': return 0 131 case 'provide-did': return 0 132 case 'upload-car': return 1 133 case 'provide-rotation-key': return 2 134 case 'choose-handle': return 3 135 case 'review': return 4 136 case 'creating': 137 case 'importing': return 5 138 case 'migrating-blobs': return 6 139 case 'email-verify': return 7 140 case 'passkey-setup': return isPasskey ? 8 : 7 141 case 'app-password': return 9 142 case 'plc-signing': 143 case 'finalizing': return isPasskey ? 10 : 8 144 case 'success': return isPasskey ? 10 : 8 145 default: return 0 146 } 147 } 148 149 async function checkHandle() { 150 if (!handleInput.trim()) return 151 152 const fullHandle = handleInput.includes('.') 153 ? handleInput 154 : `${handleInput}.${selectedDomain}` 155 156 checkingHandle = true 157 handleAvailable = null 158 159 try { 160 handleAvailable = await flow.checkHandleAvailability(fullHandle) 161 } catch { 162 handleAvailable = true 163 } finally { 164 checkingHandle = false 165 } 166 } 167 168 function proceedToReview() { 169 const fullHandle = handleInput.includes('.') 170 ? handleInput 171 : `${handleInput}.${selectedDomain}` 172 173 flow.setTargetHandle(fullHandle) 174 flow.setAuthMethod(selectedAuthMethod) 175 flow.setStep('review') 176 } 177 178 async function submitEmailVerify(e: Event) { 179 e.preventDefault() 180 loading = true 181 try { 182 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken) 183 } catch (err) { 184 flow.setError(getErrorMessage(err)) 185 } finally { 186 loading = false 187 } 188 } 189 190 async function resendEmailVerify() { 191 loading = true 192 try { 193 await flow.resendEmailVerification() 194 flow.setError(null) 195 } catch (err) { 196 flow.setError(getErrorMessage(err)) 197 } finally { 198 loading = false 199 } 200 } 201 202 async function registerPasskey() { 203 loading = true 204 flow.setError(null) 205 206 try { 207 if (!window.PublicKeyCredential) { 208 throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 209 } 210 211 await flow.registerPasskey(passkeyName || undefined) 212 } catch (err) { 213 const message = getErrorMessage(err) 214 if (message.includes('cancelled') || message.includes('AbortError')) { 215 flow.setError('Passkey registration was cancelled. Please try again.') 216 } else { 217 flow.setError(message) 218 } 219 } finally { 220 loading = false 221 } 222 } 223 224 async function handleProceedFromAppPassword() { 225 loading = true 226 try { 227 await flow.proceedFromAppPassword() 228 } catch (err) { 229 flow.setError(getErrorMessage(err)) 230 } finally { 231 loading = false 232 } 233 } 234</script> 235 236<div class="migration-wizard"> 237 <div class="step-indicator"> 238 {#each steps as _, i} 239 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 240 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 241 </div> 242 {#if i < steps.length - 1} 243 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 244 {/if} 245 {/each} 246 </div> 247 <div class="current-step-label"> 248 <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 249 </div> 250 251 {#if flow.state.error} 252 <div class="message error">{flow.state.error}</div> 253 {/if} 254 255 {#if flow.state.step === 'welcome'} 256 <div class="step-content"> 257 <h2>{$_('migration.offline.welcome.title')}</h2> 258 <p>{$_('migration.offline.welcome.desc')}</p> 259 260 <div class="warning-box"> 261 <strong>{$_('migration.offline.welcome.warningTitle')}</strong> 262 <p>{$_('migration.offline.welcome.warningDesc')}</p> 263 </div> 264 265 <div class="info-box"> 266 <h3>{$_('migration.offline.welcome.requirementsTitle')}</h3> 267 <ul> 268 <li>{$_('migration.offline.welcome.requirement1')}</li> 269 <li>{$_('migration.offline.welcome.requirement2')}</li> 270 <li>{$_('migration.offline.welcome.requirement3')}</li> 271 </ul> 272 </div> 273 274 <label class="checkbox-label"> 275 <input type="checkbox" bind:checked={understood} /> 276 <span>{$_('migration.offline.welcome.understand')}</span> 277 </label> 278 279 <div class="button-row"> 280 <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 281 <button disabled={!understood} onclick={() => flow.setStep('provide-did')}> 282 {$_('migration.inbound.common.continue')} 283 </button> 284 </div> 285 </div> 286 287 {:else if flow.state.step === 'provide-did'} 288 <div class="step-content"> 289 <h2>{$_('migration.offline.provideDid.title')}</h2> 290 <p>{$_('migration.offline.provideDid.desc')}</p> 291 292 <div class="field"> 293 <label for="user-did">{$_('migration.offline.provideDid.label')}</label> 294 <input 295 id="user-did" 296 type="text" 297 placeholder="did:plc:abc123..." 298 value={flow.state.userDid} 299 oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)} 300 /> 301 <p class="hint">{$_('migration.offline.provideDid.hint')}</p> 302 </div> 303 304 <div class="button-row"> 305 <button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button> 306 <button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}> 307 {$_('migration.inbound.common.continue')} 308 </button> 309 </div> 310 </div> 311 312 {:else if flow.state.step === 'upload-car'} 313 <div class="step-content"> 314 <h2>{$_('migration.offline.uploadCar.title')}</h2> 315 <p>{$_('migration.offline.uploadCar.desc')}</p> 316 317 {#if flow.state.carNeedsReupload} 318 <div class="warning-box"> 319 <strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong> 320 <p>{$_('migration.offline.uploadCar.reuploadWarning')}</p> 321 {#if flow.state.carFileName} 322 <p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p> 323 {/if} 324 </div> 325 {/if} 326 327 <div class="field"> 328 <label for="car-file">{$_('migration.offline.uploadCar.label')}</label> 329 <div class="file-input-container"> 330 <input 331 id="car-file" 332 type="file" 333 accept=".car" 334 onchange={handleFileSelect} 335 bind:this={fileInputRef} 336 /> 337 {#if flow.state.carFile && flow.state.carFileName} 338 <div class="file-info"> 339 <span class="file-name">{flow.state.carFileName}</span> 340 <span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 341 </div> 342 {/if} 343 </div> 344 <p class="hint">{$_('migration.offline.uploadCar.hint')}</p> 345 </div> 346 347 <div class="button-row"> 348 <button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button> 349 <button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}> 350 {$_('migration.inbound.common.continue')} 351 </button> 352 </div> 353 </div> 354 355 {:else if flow.state.step === 'provide-rotation-key'} 356 <div class="step-content"> 357 <h2>{$_('migration.offline.rotationKey.title')}</h2> 358 <p>{$_('migration.offline.rotationKey.desc')}</p> 359 360 <div class="warning-box"> 361 <strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong> 362 <ul> 363 <li>{$_('migration.offline.rotationKey.securityWarning1')}</li> 364 <li>{$_('migration.offline.rotationKey.securityWarning2')}</li> 365 <li>{$_('migration.offline.rotationKey.securityWarning3')}</li> 366 </ul> 367 </div> 368 369 <div class="field"> 370 <label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label> 371 <textarea 372 id="rotation-key" 373 rows={4} 374 placeholder={$_('migration.offline.rotationKey.placeholder')} 375 value={flow.state.rotationKey} 376 oninput={(e) => { 377 flow.setRotationKey((e.target as HTMLTextAreaElement).value) 378 keyValid = null 379 }} 380 ></textarea> 381 <p class="hint">{$_('migration.offline.rotationKey.hint')}</p> 382 </div> 383 384 {#if keyValid === true} 385 <div class="message success">{$_('migration.offline.rotationKey.valid')}</div> 386 {:else if keyValid === false} 387 <div class="message error">{$_('migration.offline.rotationKey.invalid')}</div> 388 {/if} 389 390 <div class="button-row"> 391 <button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button> 392 <button 393 disabled={!flow.state.rotationKey || validatingKey} 394 onclick={validateRotationKey} 395 > 396 {validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')} 397 </button> 398 </div> 399 </div> 400 401 {:else if flow.state.step === 'choose-handle'} 402 <ChooseHandleStep 403 {handleInput} 404 {selectedDomain} 405 {handleAvailable} 406 {checkingHandle} 407 email={flow.state.targetEmail} 408 password={flow.state.targetPassword} 409 authMethod={selectedAuthMethod} 410 inviteCode={flow.state.inviteCode} 411 {serverInfo} 412 migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')} 413 migratingFromValue={flow.state.userDid} 414 {loading} 415 onHandleChange={(h) => handleInput = h} 416 onDomainChange={(d) => selectedDomain = d} 417 onCheckHandle={checkHandle} 418 onEmailChange={(e) => flow.setTargetEmail(e)} 419 onPasswordChange={(p) => flow.setTargetPassword(p)} 420 onAuthMethodChange={(m) => selectedAuthMethod = m} 421 onInviteCodeChange={(c) => flow.setInviteCode(c)} 422 onBack={() => flow.setStep('provide-rotation-key')} 423 onContinue={proceedToReview} 424 /> 425 426 {:else if flow.state.step === 'review'} 427 <div class="step-content"> 428 <h2>{$_('migration.inbound.review.title')}</h2> 429 <p>{$_('migration.offline.review.desc')}</p> 430 431 <div class="review-card"> 432 <div class="review-row"> 433 <span class="label">{$_('migration.inbound.review.did')}:</span> 434 <span class="value mono">{flow.state.userDid}</span> 435 </div> 436 <div class="review-row"> 437 <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 438 <span class="value">{flow.state.targetHandle}</span> 439 </div> 440 <div class="review-row"> 441 <span class="label">{$_('migration.offline.review.carFile')}:</span> 442 <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 443 </div> 444 <div class="review-row"> 445 <span class="label">{$_('migration.offline.review.rotationKey')}:</span> 446 <span class="value mono">{flow.state.rotationKeyDidKey}</span> 447 </div> 448 <div class="review-row"> 449 <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 450 <span class="value">{window.location.origin}</span> 451 </div> 452 <div class="review-row"> 453 <span class="label">{$_('migration.inbound.review.email')}:</span> 454 <span class="value">{flow.state.targetEmail}</span> 455 </div> 456 <div class="review-row"> 457 <span class="label">{$_('migration.inbound.review.authentication')}:</span> 458 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 459 </div> 460 </div> 461 462 <div class="warning-box"> 463 <strong>{$_('migration.offline.review.plcWarningTitle')}</strong> 464 <p>{$_('migration.offline.review.plcWarning')}</p> 465 </div> 466 467 <div class="button-row"> 468 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 469 <button onclick={startMigration} disabled={loading}> 470 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 471 </button> 472 </div> 473 </div> 474 475 {:else if flow.state.step === 'creating' || flow.state.step === 'importing'} 476 <div class="step-content"> 477 <h2>{$_('migration.offline.migrating.title')}</h2> 478 <p>{$_('migration.offline.migrating.desc')}</p> 479 480 <div class="progress-section"> 481 <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}> 482 <span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span> 483 <span>{$_('migration.offline.migrating.creating')}</span> 484 </div> 485 <div class="progress-item" class:active={flow.state.step === 'importing'}> 486 <span class="icon"></span> 487 <span>{$_('migration.offline.migrating.importing')}</span> 488 </div> 489 </div> 490 491 <p class="status-text">{flow.state.progress.currentOperation}</p> 492 </div> 493 494 {:else if flow.state.step === 'migrating-blobs'} 495 <div class="step-content"> 496 <h2>{$_('migration.offline.blobs.title')}</h2> 497 <p>{$_('migration.offline.blobs.desc')}</p> 498 499 <div class="progress-section"> 500 <div class="progress-item completed"> 501 <span class="icon"></span> 502 <span>{$_('migration.offline.migrating.importing')}</span> 503 </div> 504 <div class="progress-item active"> 505 <span class="icon"></span> 506 <span>{$_('migration.offline.blobs.migrating')}</span> 507 </div> 508 </div> 509 510 {#if flow.state.progress.blobsTotal > 0} 511 <div class="blob-progress"> 512 <div class="blob-progress-bar"> 513 <div 514 class="blob-progress-fill" 515 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 516 ></div> 517 </div> 518 <p class="blob-progress-text"> 519 {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs 520 </p> 521 </div> 522 {/if} 523 524 <p class="status-text">{flow.state.progress.currentOperation}</p> 525 526 {#if flow.state.progress.blobsFailed.length > 0} 527 <div class="warning-box"> 528 <strong>{$_('migration.offline.blobs.failedTitle')}</strong> 529 <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p> 530 </div> 531 {/if} 532 </div> 533 534 {:else if flow.state.step === 'email-verify'} 535 <EmailVerifyStep 536 email={flow.state.targetEmail} 537 token={flow.state.emailVerifyToken} 538 {loading} 539 error={flow.state.error} 540 onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 541 onSubmit={submitEmailVerify} 542 onResend={resendEmailVerify} 543 /> 544 545 {:else if flow.state.step === 'passkey-setup'} 546 <PasskeySetupStep 547 {passkeyName} 548 {loading} 549 error={flow.state.error} 550 onPasskeyNameChange={(n) => passkeyName = n} 551 onRegister={registerPasskey} 552 /> 553 554 {:else if flow.state.step === 'app-password'} 555 <AppPasswordStep 556 appPassword={flow.state.generatedAppPassword || ''} 557 appPasswordName={flow.state.generatedAppPasswordName || ''} 558 {loading} 559 onContinue={handleProceedFromAppPassword} 560 /> 561 562 {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'} 563 <div class="step-content"> 564 <h2>{$_('migration.inbound.finalizing.title')}</h2> 565 <p>{$_('migration.inbound.finalizing.desc')}</p> 566 567 <div class="progress-section"> 568 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 569 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 570 <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 571 </div> 572 <div class="progress-item" class:completed={flow.state.progress.activated}> 573 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 574 <span>{$_('migration.inbound.finalizing.activating')}</span> 575 </div> 576 </div> 577 578 <p class="status-text">{flow.state.progress.currentOperation}</p> 579 </div> 580 581 {:else if flow.state.step === 'success'} 582 <SuccessStep 583 handle={flow.state.targetHandle} 584 did={flow.state.userDid} 585 description={$_('migration.offline.success.desc')} 586 /> 587 588 {:else if flow.state.step === 'error'} 589 <ErrorStep error={flow.state.error} onStartOver={onBack} /> 590 {/if} 591</div> 592