Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun
at main 598 lines 21 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 sourceHandle="" 416 sourceDid={flow.state.userDid} 417 handlePreservation="new" 418 existingHandleVerified={false} 419 verifyingExistingHandle={false} 420 existingHandleError={null} 421 onHandleChange={(h) => handleInput = h} 422 onDomainChange={(d) => selectedDomain = d} 423 onCheckHandle={checkHandle} 424 onEmailChange={(e) => flow.setTargetEmail(e)} 425 onPasswordChange={(p) => flow.setTargetPassword(p)} 426 onAuthMethodChange={(m) => selectedAuthMethod = m} 427 onInviteCodeChange={(c) => flow.setInviteCode(c)} 428 onBack={() => flow.setStep('provide-rotation-key')} 429 onContinue={proceedToReview} 430 /> 431 432 {:else if flow.state.step === 'review'} 433 <div class="step-content"> 434 <h2>{$_('migration.inbound.review.title')}</h2> 435 <p>{$_('migration.offline.review.desc')}</p> 436 437 <div class="review-card"> 438 <div class="review-row"> 439 <span class="label">{$_('migration.inbound.review.did')}:</span> 440 <span class="value mono">{flow.state.userDid}</span> 441 </div> 442 <div class="review-row"> 443 <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 444 <span class="value">{flow.state.targetHandle}</span> 445 </div> 446 <div class="review-row"> 447 <span class="label">{$_('migration.offline.review.carFile')}:</span> 448 <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 449 </div> 450 <div class="review-row"> 451 <span class="label">{$_('migration.offline.review.rotationKey')}:</span> 452 <span class="value mono">{flow.state.rotationKeyDidKey}</span> 453 </div> 454 <div class="review-row"> 455 <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 456 <span class="value">{window.location.origin}</span> 457 </div> 458 <div class="review-row"> 459 <span class="label">{$_('migration.inbound.review.email')}:</span> 460 <span class="value">{flow.state.targetEmail}</span> 461 </div> 462 <div class="review-row"> 463 <span class="label">{$_('migration.inbound.review.authentication')}:</span> 464 <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 465 </div> 466 </div> 467 468 <div class="warning-box"> 469 <strong>{$_('migration.offline.review.plcWarningTitle')}</strong> 470 <p>{$_('migration.offline.review.plcWarning')}</p> 471 </div> 472 473 <div class="button-row"> 474 <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 475 <button onclick={startMigration} disabled={loading}> 476 {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 477 </button> 478 </div> 479 </div> 480 481 {:else if flow.state.step === 'creating' || flow.state.step === 'importing'} 482 <div class="step-content"> 483 <h2>{$_('migration.offline.migrating.title')}</h2> 484 <p>{$_('migration.offline.migrating.desc')}</p> 485 486 <div class="progress-section"> 487 <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}> 488 <span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span> 489 <span>{$_('migration.offline.migrating.creating')}</span> 490 </div> 491 <div class="progress-item" class:active={flow.state.step === 'importing'}> 492 <span class="icon"></span> 493 <span>{$_('migration.offline.migrating.importing')}</span> 494 </div> 495 </div> 496 497 <p class="status-text">{flow.state.progress.currentOperation}</p> 498 </div> 499 500 {:else if flow.state.step === 'migrating-blobs'} 501 <div class="step-content"> 502 <h2>{$_('migration.offline.blobs.title')}</h2> 503 <p>{$_('migration.offline.blobs.desc')}</p> 504 505 <div class="progress-section"> 506 <div class="progress-item completed"> 507 <span class="icon"></span> 508 <span>{$_('migration.offline.migrating.importing')}</span> 509 </div> 510 <div class="progress-item active"> 511 <span class="icon"></span> 512 <span>{$_('migration.offline.blobs.migrating')}</span> 513 </div> 514 </div> 515 516 {#if flow.state.progress.blobsTotal > 0} 517 <div class="blob-progress"> 518 <div class="blob-progress-bar"> 519 <div 520 class="blob-progress-fill" 521 style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 522 ></div> 523 </div> 524 <p class="blob-progress-text"> 525 {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs 526 </p> 527 </div> 528 {/if} 529 530 <p class="status-text">{flow.state.progress.currentOperation}</p> 531 532 {#if flow.state.progress.blobsFailed.length > 0} 533 <div class="warning-box"> 534 <strong>{$_('migration.offline.blobs.failedTitle')}</strong> 535 <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p> 536 </div> 537 {/if} 538 </div> 539 540 {:else if flow.state.step === 'email-verify'} 541 <EmailVerifyStep 542 email={flow.state.targetEmail} 543 token={flow.state.emailVerifyToken} 544 {loading} 545 error={flow.state.error} 546 onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 547 onSubmit={submitEmailVerify} 548 onResend={resendEmailVerify} 549 /> 550 551 {:else if flow.state.step === 'passkey-setup'} 552 <PasskeySetupStep 553 {passkeyName} 554 {loading} 555 error={flow.state.error} 556 onPasskeyNameChange={(n) => passkeyName = n} 557 onRegister={registerPasskey} 558 /> 559 560 {:else if flow.state.step === 'app-password'} 561 <AppPasswordStep 562 appPassword={flow.state.generatedAppPassword || ''} 563 appPasswordName={flow.state.generatedAppPasswordName || ''} 564 {loading} 565 onContinue={handleProceedFromAppPassword} 566 /> 567 568 {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'} 569 <div class="step-content"> 570 <h2>{$_('migration.inbound.finalizing.title')}</h2> 571 <p>{$_('migration.inbound.finalizing.desc')}</p> 572 573 <div class="progress-section"> 574 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 575 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 576 <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 577 </div> 578 <div class="progress-item" class:completed={flow.state.progress.activated}> 579 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 580 <span>{$_('migration.inbound.finalizing.activating')}</span> 581 </div> 582 </div> 583 584 <p class="status-text">{flow.state.progress.currentOperation}</p> 585 </div> 586 587 {:else if flow.state.step === 'success'} 588 <SuccessStep 589 handle={flow.state.targetHandle} 590 did={flow.state.userDid} 591 description={$_('migration.offline.success.desc')} 592 /> 593 594 {:else if flow.state.step === 'error'} 595 <ErrorStep error={flow.state.error} onStartOver={onBack} /> 596 {/if} 597</div> 598