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