this repo has no description

OAuth inbound migration

lewis 8984efae 0bdef257

+9 -1
frontend/deno.json
··· 9 "test:ui": "deno run -A npm:vitest --ui", 10 "test:coverage": "deno run -A npm:vitest run --coverage" 11 }, 12 - "nodeModulesDir": "auto" 13 }
··· 9 "test:ui": "deno run -A npm:vitest --ui", 10 "test:coverage": "deno run -A npm:vitest run --coverage" 11 }, 12 + "nodeModulesDir": "auto", 13 + "lint": { 14 + "rules": { 15 + "exclude": [ 16 + "require-await", 17 + "prefer-const" 18 + ] 19 + } 20 + } 21 }
+8
frontend/src/App.svelte
··· 36 import DidDocumentEditor from './routes/DidDocumentEditor.svelte' 37 import Home from './routes/Home.svelte' 38 39 initI18n() 40 41 const auth = getAuthState() ··· 43 let oauthCallbackPending = $state(hasOAuthCallback()) 44 45 function hasOAuthCallback(): boolean { 46 const params = new URLSearchParams(window.location.search) 47 return !!(params.get('code') && params.get('state')) 48 }
··· 36 import DidDocumentEditor from './routes/DidDocumentEditor.svelte' 37 import Home from './routes/Home.svelte' 38 39 + if (window.location.pathname === '/migrate') { 40 + const newUrl = `${window.location.origin}/${window.location.search}#/migrate` 41 + window.location.replace(newUrl) 42 + } 43 + 44 initI18n() 45 46 const auth = getAuthState() ··· 48 let oauthCallbackPending = $state(hasOAuthCallback()) 49 50 function hasOAuthCallback(): boolean { 51 + if (window.location.hash === '#/migrate') { 52 + return false 53 + } 54 const params = new URLSearchParams(window.location.search) 55 return !!(params.get('code') && params.get('state')) 56 }
+12 -12
frontend/src/components/ReauthModal.svelte
··· 170 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 171 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 172 <div class="modal-header"> 173 - <h2>Re-authentication Required</h2> 174 <button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button> 175 </div> 176 177 <p class="modal-description"> 178 - This action requires you to verify your identity. 179 </p> 180 181 {#if error} ··· 190 class:active={activeMethod === 'password'} 191 onclick={() => activeMethod = 'password'} 192 > 193 - Password 194 </button> 195 {/if} 196 {#if availableMethods.includes('totp')} ··· 199 class:active={activeMethod === 'totp'} 200 onclick={() => activeMethod = 'totp'} 201 > 202 - TOTP 203 </button> 204 {/if} 205 {#if availableMethods.includes('passkey')} ··· 208 class:active={activeMethod === 'passkey'} 209 onclick={() => activeMethod = 'passkey'} 210 > 211 - Passkey 212 </button> 213 {/if} 214 </div> ··· 218 {#if activeMethod === 'password'} 219 <form onsubmit={handlePasswordSubmit}> 220 <div class="form-group"> 221 - <label for="reauth-password">Password</label> 222 <input 223 id="reauth-password" 224 type="password" ··· 228 /> 229 </div> 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 - {loading ? 'Verifying...' : 'Verify'} 232 </button> 233 </form> 234 {:else if activeMethod === 'totp'} 235 <form onsubmit={handleTotpSubmit}> 236 <div class="form-group"> 237 - <label for="reauth-totp">Authenticator Code</label> 238 <input 239 id="reauth-totp" 240 type="text" ··· 247 /> 248 </div> 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 - {loading ? 'Verifying...' : 'Verify'} 251 </button> 252 </form> 253 {:else if activeMethod === 'passkey'} 254 <div class="passkey-auth"> 255 - <p>Click the button below to authenticate with your passkey.</p> 256 <button 257 class="btn-primary" 258 onclick={handlePasskeyAuth} 259 disabled={loading} 260 > 261 - {loading ? 'Authenticating...' : 'Use Passkey'} 262 </button> 263 </div> 264 {/if} ··· 266 267 <div class="modal-footer"> 268 <button class="btn-secondary" onclick={handleClose} disabled={loading}> 269 - Cancel 270 </button> 271 </div> 272 </div>
··· 170 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 171 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 172 <div class="modal-header"> 173 + <h2>{$_('reauth.title')}</h2> 174 <button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button> 175 </div> 176 177 <p class="modal-description"> 178 + {$_('reauth.subtitle')} 179 </p> 180 181 {#if error} ··· 190 class:active={activeMethod === 'password'} 191 onclick={() => activeMethod = 'password'} 192 > 193 + {$_('reauth.password')} 194 </button> 195 {/if} 196 {#if availableMethods.includes('totp')} ··· 199 class:active={activeMethod === 'totp'} 200 onclick={() => activeMethod = 'totp'} 201 > 202 + {$_('reauth.totp')} 203 </button> 204 {/if} 205 {#if availableMethods.includes('passkey')} ··· 208 class:active={activeMethod === 'passkey'} 209 onclick={() => activeMethod = 'passkey'} 210 > 211 + {$_('reauth.passkey')} 212 </button> 213 {/if} 214 </div> ··· 218 {#if activeMethod === 'password'} 219 <form onsubmit={handlePasswordSubmit}> 220 <div class="form-group"> 221 + <label for="reauth-password">{$_('reauth.password')}</label> 222 <input 223 id="reauth-password" 224 type="password" ··· 228 /> 229 </div> 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 232 </button> 233 </form> 234 {:else if activeMethod === 'totp'} 235 <form onsubmit={handleTotpSubmit}> 236 <div class="form-group"> 237 + <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 238 <input 239 id="reauth-totp" 240 type="text" ··· 247 /> 248 </div> 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 251 </button> 252 </form> 253 {:else if activeMethod === 'passkey'} 254 <div class="passkey-auth"> 255 + <p>{$_('reauth.passkeyPrompt')}</p> 256 <button 257 class="btn-primary" 258 onclick={handlePasskeyAuth} 259 disabled={loading} 260 > 261 + {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 262 </button> 263 </div> 264 {/if} ··· 266 267 <div class="modal-footer"> 268 <button class="btn-secondary" onclick={handleClose} disabled={loading}> 269 + {$_('reauth.cancel')} 270 </button> 271 </div> 272 </div>
+394 -569
frontend/src/components/migration/InboundWizard.svelte
··· 1 <script lang="ts"> 2 import type { InboundMigrationFlow } from '../../lib/migration' 3 - import type { ServerDescription } from '../../lib/migration/types' 4 import { _ } from '../../lib/i18n' 5 6 interface Props { 7 flow: InboundMigrationFlow 8 onBack: () => void 9 onComplete: () => void 10 } 11 12 - let { flow, onBack, onComplete }: Props = $props() 13 14 let serverInfo = $state<ServerDescription | null>(null) 15 let loading = $state(false) 16 let handleInput = $state('') 17 - let passwordInput = $state('') 18 let localPasswordInput = $state('') 19 let understood = $state(false) 20 let selectedDomain = $state('') 21 let handleAvailable = $state<boolean | null>(null) 22 let checkingHandle = $state(false) 23 24 - const isResumedMigration = $derived(flow.state.progress.repoImported) 25 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) 26 27 $effect(() => { 28 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 29 loadServerInfo() 30 } 31 }) 32 33 ··· 61 } 62 } 63 64 - async function handleLogin(e: Event) { 65 - e.preventDefault() 66 - loading = true 67 - flow.updateField('error', null) 68 - 69 - try { 70 - await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined) 71 - const username = flow.state.sourceHandle.split('.')[0] 72 - handleInput = username 73 - flow.updateField('targetPassword', passwordInput) 74 - 75 - if (flow.state.progress.repoImported) { 76 - if (!localPasswordInput) { 77 - flow.setError('Please enter your password for your new account on this PDS') 78 - return 79 - } 80 - await flow.loadLocalServerInfo() 81 - 82 - try { 83 - await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput) 84 - await flow.requestPlcToken() 85 - flow.setStep('plc-token') 86 - } catch (err) { 87 - const error = err as Error & { error?: string } 88 - if (error.error === 'AccountNotVerified') { 89 - flow.setStep('email-verify') 90 - } else { 91 - throw err 92 - } 93 - } 94 - } else { 95 - flow.setStep('choose-handle') 96 - } 97 - } catch (err) { 98 - flow.setError((err as Error).message) 99 - } finally { 100 - loading = false 101 - } 102 - } 103 - 104 async function checkHandle() { 105 if (!handleInput.trim()) return 106 ··· 134 try { 135 await flow.startMigration() 136 } catch (err) { 137 - flow.setError((err as Error).message) 138 } finally { 139 loading = false 140 } ··· 146 try { 147 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 148 } catch (err) { 149 - flow.setError((err as Error).message) 150 } finally { 151 loading = false 152 } ··· 158 await flow.resendEmailVerification() 159 flow.setError(null) 160 } catch (err) { 161 - flow.setError((err as Error).message) 162 } finally { 163 loading = false 164 } ··· 170 try { 171 await flow.submitPlcToken(flow.state.plcToken) 172 } catch (err) { 173 - flow.setError((err as Error).message) 174 } finally { 175 loading = false 176 } ··· 182 await flow.resendPlcToken() 183 flow.setError(null) 184 } catch (err) { 185 - flow.setError((err as Error).message) 186 } finally { 187 loading = false 188 } ··· 193 try { 194 await flow.completeDidWebMigration() 195 } catch (err) { 196 - flow.setError((err as Error).message) 197 } finally { 198 loading = false 199 } 200 } 201 202 const steps = $derived(isDidWeb 203 - ? ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete'] 204 - : ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']) 205 function getCurrentStepIndex(): number { 206 switch (flow.state.step) { 207 case 'welcome': 208 - case 'source-login': return 0 209 case 'choose-handle': return 1 210 case 'review': return 2 211 case 'migrating': return 3 212 case 'email-verify': return 4 213 case 'plc-token': 214 case 'did-web-update': 215 - case 'finalizing': return 5 216 - case 'success': return 6 217 default: return 0 218 } 219 } 220 </script> 221 222 - <div class="inbound-wizard"> 223 <div class="step-indicator"> 224 - {#each steps as stepName, i} 225 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 226 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 227 - <span class="step-label">{stepName}</span> 228 </div> 229 {#if i < steps.length - 1} 230 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 231 {/if} 232 {/each} 233 </div> 234 235 {#if flow.state.error} 236 <div class="message error">{flow.state.error}</div> ··· 238 239 {#if flow.state.step === 'welcome'} 240 <div class="step-content"> 241 - <h2>Migrate Your Account Here</h2> 242 - <p>This wizard will help you move your AT Protocol account from another PDS to this one.</p> 243 244 <div class="info-box"> 245 - <h3>What will happen:</h3> 246 <ol> 247 - <li>Log in to your current PDS</li> 248 - <li>Choose your new handle on this server</li> 249 - <li>Your repository and blobs will be transferred</li> 250 - <li>Verify the migration via email</li> 251 - <li>Your identity will be updated to point here</li> 252 </ol> 253 </div> 254 255 <div class="warning-box"> 256 - <strong>Before you proceed:</strong> 257 <ul> 258 - <li>You need access to the email registered with your current account</li> 259 - <li>Large accounts may take several minutes to transfer</li> 260 - <li>Your old account will be deactivated after migration</li> 261 </ul> 262 </div> 263 264 <label class="checkbox-label"> 265 <input type="checkbox" bind:checked={understood} /> 266 - <span>I understand the risks and want to proceed with migration</span> 267 </label> 268 269 <div class="button-row"> 270 - <button class="ghost" onclick={onBack}>Cancel</button> 271 - <button disabled={!understood} onclick={() => flow.setStep('source-login')}> 272 - Continue 273 </button> 274 </div> 275 </div> 276 277 - {:else if flow.state.step === 'source-login'} 278 <div class="step-content"> 279 - <h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2> 280 - <p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p> 281 282 - {#if isResumedMigration} 283 - <div class="info-box"> 284 - <p>Your migration was interrupted. Log in to both accounts to resume.</p> 285 - <p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong> → <strong>{flow.state.targetHandle}</strong></p> 286 </div> 287 {/if} 288 289 - <form onsubmit={handleLogin}> 290 <div class="field"> 291 - <label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label> 292 <input 293 - id="handle" 294 type="text" 295 - placeholder="alice.bsky.social" 296 bind:value={handleInput} 297 - disabled={loading} 298 required 299 /> 300 - <p class="hint">Your current handle on your existing PDS</p> 301 </div> 302 303 - <div class="field"> 304 - <label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label> 305 - <input 306 - id="password" 307 - type="password" 308 - bind:value={passwordInput} 309 - disabled={loading} 310 - required 311 - /> 312 - <p class="hint">Your account password (not an app password)</p> 313 - </div> 314 - 315 - {#if flow.state.requires2FA} 316 - <div class="field"> 317 - <label for="2fa">Two-Factor Code</label> 318 - <input 319 - id="2fa" 320 - type="text" 321 - placeholder="Enter code from email" 322 - bind:value={flow.state.twoFactorCode} 323 - disabled={loading} 324 - required 325 - /> 326 - <p class="hint">Check your email for the verification code</p> 327 - </div> 328 - {/if} 329 - 330 - {#if isResumedMigration} 331 - <hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" /> 332 - 333 - <div class="field"> 334 - <label for="local-password">New Account Password</label> 335 - <input 336 - id="local-password" 337 - type="password" 338 - placeholder="Password for your new account" 339 - bind:value={localPasswordInput} 340 - disabled={loading} 341 - required 342 - /> 343 - <p class="hint">The password you set for your account on this PDS</p> 344 - </div> 345 - {/if} 346 - 347 <div class="button-row"> 348 - <button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button> 349 - <button type="submit" disabled={loading}> 350 - {loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')} 351 </button> 352 </div> 353 </form> ··· 355 356 {:else if flow.state.step === 'choose-handle'} 357 <div class="step-content"> 358 - <h2>Choose Your New Handle</h2> 359 - <p>Select a handle for your account on this PDS.</p> 360 361 <div class="current-info"> 362 - <span class="label">Migrating from:</span> 363 <span class="value">{flow.state.sourceHandle}</span> 364 </div> 365 366 <div class="field"> 367 - <label for="new-handle">New Handle</label> 368 <div class="handle-input-group"> 369 <input 370 id="new-handle" ··· 383 </div> 384 385 {#if checkingHandle} 386 - <p class="hint">Checking availability...</p> 387 {:else if handleAvailable === true} 388 - <p class="hint success">Handle is available!</p> 389 {:else if handleAvailable === false} 390 - <p class="hint error">Handle is already taken</p> 391 {:else} 392 - <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 393 {/if} 394 </div> 395 396 <div class="field"> 397 - <label for="email">Email Address</label> 398 <input 399 id="email" 400 type="email" ··· 406 </div> 407 408 <div class="field"> 409 - <label for="new-password">Password</label> 410 - <input 411 - id="new-password" 412 - type="password" 413 - placeholder="Password for your new account" 414 - bind:value={flow.state.targetPassword} 415 - oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 416 - required 417 - minlength="8" 418 - /> 419 - <p class="hint">At least 8 characters</p> 420 </div> 421 422 {#if serverInfo?.inviteCodeRequired} 423 <div class="field"> 424 - <label for="invite">Invite Code</label> 425 <input 426 id="invite" 427 type="text" ··· 434 {/if} 435 436 <div class="button-row"> 437 - <button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button> 438 <button 439 - disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false} 440 - onclick={proceedToReview} 441 > 442 - Continue 443 </button> 444 </div> 445 </div> 446 447 {:else if flow.state.step === 'review'} 448 <div class="step-content"> 449 - <h2>Review Migration</h2> 450 - <p>Please confirm the details of your migration.</p> 451 452 <div class="review-card"> 453 <div class="review-row"> 454 - <span class="label">Current Handle:</span> 455 <span class="value">{flow.state.sourceHandle}</span> 456 </div> 457 <div class="review-row"> 458 - <span class="label">New Handle:</span> 459 <span class="value">{flow.state.targetHandle}</span> 460 </div> 461 <div class="review-row"> 462 - <span class="label">DID:</span> 463 <span class="value mono">{flow.state.sourceDid}</span> 464 </div> 465 <div class="review-row"> 466 - <span class="label">From PDS:</span> 467 <span class="value">{flow.state.sourcePdsUrl}</span> 468 </div> 469 <div class="review-row"> 470 - <span class="label">To PDS:</span> 471 <span class="value">{window.location.origin}</span> 472 </div> 473 <div class="review-row"> 474 - <span class="label">Email:</span> 475 <span class="value">{flow.state.targetEmail}</span> 476 </div> 477 </div> 478 479 <div class="warning-box"> 480 - <strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin 481 - transferring. This process cannot be easily undone. 482 </div> 483 484 <div class="button-row"> 485 - <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button> 486 <button onclick={startMigration} disabled={loading}> 487 - {loading ? 'Starting...' : 'Start Migration'} 488 </button> 489 </div> 490 </div> 491 492 {:else if flow.state.step === 'migrating'} 493 <div class="step-content"> 494 - <h2>Migration in Progress</h2> 495 - <p>Please wait while your account is being transferred...</p> 496 497 <div class="progress-section"> 498 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 499 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 500 - <span>Export repository</span> 501 </div> 502 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 503 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 504 - <span>Import repository</span> 505 </div> 506 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 507 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 508 - <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 509 </div> 510 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 511 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 512 - <span>Migrate preferences</span> 513 </div> 514 </div> 515 ··· 525 <p class="status-text">{flow.state.progress.currentOperation}</p> 526 </div> 527 528 {:else if flow.state.step === 'email-verify'} 529 <div class="step-content"> 530 <h2>{$_('migration.inbound.emailVerify.title')}</h2> ··· 537 </div> 538 539 {#if flow.state.error} 540 - <div class="error-box"> 541 {flow.state.error} 542 </div> 543 {/if} ··· 569 570 {:else if flow.state.step === 'plc-token'} 571 <div class="step-content"> 572 - <h2>Verify Migration</h2> 573 - <p>A verification code has been sent to the email registered with your old account.</p> 574 575 <div class="info-box"> 576 - <p> 577 - This code confirms you have access to the account and authorizes updating your identity 578 - to point to this PDS. 579 - </p> 580 </div> 581 582 <form onsubmit={submitPlcToken}> 583 <div class="field"> 584 - <label for="plc-token">Verification Code</label> 585 <input 586 id="plc-token" 587 type="text" 588 - placeholder="Enter code from email" 589 bind:value={flow.state.plcToken} 590 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 591 disabled={loading} ··· 595 596 <div class="button-row"> 597 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 598 - Resend Code 599 </button> 600 <button type="submit" disabled={loading || !flow.state.plcToken}> 601 - {loading ? 'Verifying...' : 'Complete Migration'} 602 </button> 603 </div> 604 </form> ··· 653 </div> 654 655 <div class="button-row"> 656 - <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>Back</button> 657 <button onclick={completeDidWeb} disabled={loading}> 658 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')} 659 </button> ··· 662 663 {:else if flow.state.step === 'finalizing'} 664 <div class="step-content"> 665 - <h2>Finalizing Migration</h2> 666 - <p>Please wait while we complete the migration...</p> 667 668 <div class="progress-section"> 669 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 670 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 671 - <span>Sign identity update</span> 672 </div> 673 <div class="progress-item" class:completed={flow.state.progress.activated}> 674 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 675 - <span>Activate new account</span> 676 </div> 677 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 678 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 679 - <span>Deactivate old account</span> 680 </div> 681 </div> 682 ··· 686 {:else if flow.state.step === 'success'} 687 <div class="step-content success-content"> 688 <div class="success-icon">✓</div> 689 - <h2>Migration Complete!</h2> 690 - <p>Your account has been successfully migrated to this PDS.</p> 691 692 <div class="success-details"> 693 <div class="detail-row"> 694 - <span class="label">Your new handle:</span> 695 <span class="value">{flow.state.targetHandle}</span> 696 </div> 697 <div class="detail-row"> 698 - <span class="label">DID:</span> 699 <span class="value mono">{flow.state.sourceDid}</span> 700 </div> 701 </div> 702 703 {#if flow.state.progress.blobsFailed.length > 0} 704 - <div class="warning-box"> 705 - <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 706 - These may be images or other media that are no longer available. 707 </div> 708 {/if} 709 710 - <p class="redirect-text">Redirecting to dashboard...</p> 711 </div> 712 713 {:else if flow.state.step === 'error'} 714 <div class="step-content"> 715 - <h2>Migration Error</h2> 716 - <p>An error occurred during migration.</p> 717 718 - <div class="error-box"> 719 - {flow.state.error} 720 </div> 721 722 <div class="button-row"> 723 - <button class="ghost" onclick={onBack}>Start Over</button> 724 </div> 725 </div> 726 {/if} 727 </div> 728 729 <style> 730 - .inbound-wizard { 731 - max-width: 600px; 732 - margin: 0 auto; 733 - } 734 - 735 - .step-indicator { 736 - display: flex; 737 - align-items: center; 738 - justify-content: center; 739 - margin-bottom: var(--space-8); 740 - padding: 0 var(--space-4); 741 } 742 - 743 - .step { 744 - display: flex; 745 - flex-direction: column; 746 - align-items: center; 747 - gap: var(--space-2); 748 } 749 - 750 - .step-dot { 751 - width: 32px; 752 - height: 32px; 753 - border-radius: 50%; 754 - background: var(--bg-secondary); 755 - border: 2px solid var(--border); 756 - display: flex; 757 - align-items: center; 758 - justify-content: center; 759 - font-size: var(--text-sm); 760 - font-weight: var(--font-medium); 761 - color: var(--text-secondary); 762 - } 763 - 764 - .step.active .step-dot { 765 - background: var(--accent); 766 - border-color: var(--accent); 767 - color: var(--text-inverse); 768 - } 769 - 770 - .step.completed .step-dot { 771 - background: var(--success-bg); 772 - border-color: var(--success-text); 773 - color: var(--success-text); 774 - } 775 - 776 - .step-label { 777 - font-size: var(--text-xs); 778 - color: var(--text-secondary); 779 - } 780 - 781 - .step.active .step-label { 782 - color: var(--accent); 783 - font-weight: var(--font-medium); 784 - } 785 - 786 - .step-line { 787 - flex: 1; 788 - height: 2px; 789 - background: var(--border); 790 - margin: 0 var(--space-2); 791 - margin-bottom: var(--space-6); 792 - min-width: 20px; 793 - } 794 - 795 - .step-line.completed { 796 - background: var(--success-text); 797 - } 798 - 799 - .step-content { 800 - background: var(--bg-secondary); 801 border-radius: var(--radius-xl); 802 padding: var(--space-6); 803 - } 804 - 805 - .step-content h2 { 806 - margin: 0 0 var(--space-3) 0; 807 } 808 - 809 - .step-content > p { 810 color: var(--text-secondary); 811 - margin: 0 0 var(--space-5) 0; 812 } 813 - 814 - .info-box { 815 - background: var(--accent-muted); 816 - border: 1px solid var(--accent); 817 - border-radius: var(--radius-lg); 818 padding: var(--space-5); 819 - margin-bottom: var(--space-5); 820 - } 821 - 822 - .info-box h3 { 823 - margin: 0 0 var(--space-3) 0; 824 - font-size: var(--text-base); 825 - } 826 - 827 - .info-box ol, .info-box ul { 828 - margin: 0; 829 - padding-left: var(--space-5); 830 - } 831 - 832 - .info-box li { 833 - margin-bottom: var(--space-2); 834 - color: var(--text-secondary); 835 } 836 - 837 - .info-box p { 838 - margin: 0; 839 - color: var(--text-secondary); 840 - } 841 - 842 - .warning-box { 843 - background: var(--warning-bg); 844 - border: 1px solid var(--warning-border); 845 - border-radius: var(--radius-lg); 846 - padding: var(--space-5); 847 - margin-bottom: var(--space-5); 848 font-size: var(--text-sm); 849 } 850 - 851 - .warning-box strong { 852 - color: var(--warning-text); 853 - } 854 - 855 - .warning-box ul { 856 - margin: var(--space-3) 0 0 0; 857 - padding-left: var(--space-5); 858 - } 859 - 860 - .error-box { 861 - background: var(--error-bg); 862 - border: 1px solid var(--error-border); 863 - border-radius: var(--radius-lg); 864 - padding: var(--space-5); 865 margin-bottom: var(--space-5); 866 - color: var(--error-text); 867 } 868 - 869 - .checkbox-label { 870 - display: inline-flex; 871 - align-items: flex-start; 872 - gap: var(--space-3); 873 - cursor: pointer; 874 - margin-bottom: var(--space-5); 875 - text-align: left; 876 } 877 - 878 - .checkbox-label input[type="checkbox"] { 879 - width: 18px; 880 - height: 18px; 881 - margin: 0; 882 - flex-shrink: 0; 883 - } 884 - 885 - .button-row { 886 - display: flex; 887 - gap: var(--space-3); 888 - justify-content: flex-end; 889 - margin-top: var(--space-5); 890 - } 891 - 892 - .field { 893 - margin-bottom: var(--space-5); 894 - } 895 - 896 - .field label { 897 - display: block; 898 - margin-bottom: var(--space-2); 899 - font-weight: var(--font-medium); 900 - } 901 - 902 - .field input, .field select { 903 - width: 100%; 904 - padding: var(--space-3); 905 - border: 1px solid var(--border); 906 - border-radius: var(--radius-md); 907 - background: var(--bg-primary); 908 - color: var(--text-primary); 909 - } 910 - 911 - .field input:focus, .field select:focus { 912 - outline: none; 913 - border-color: var(--accent); 914 - } 915 - 916 - .hint { 917 - font-size: var(--text-sm); 918 - color: var(--text-secondary); 919 - margin: var(--space-2) 0 0 0; 920 - } 921 - 922 - .hint.success { 923 - color: var(--success-text); 924 - } 925 - 926 - .hint.error { 927 - color: var(--error-text); 928 - } 929 - 930 - .handle-input-group { 931 display: flex; 932 gap: var(--space-2); 933 } 934 - 935 - .handle-input-group input { 936 - flex: 1; 937 - } 938 - 939 - .handle-input-group select { 940 - width: auto; 941 - } 942 - 943 - .current-info { 944 - background: var(--bg-primary); 945 - border-radius: var(--radius-lg); 946 - padding: var(--space-4); 947 - margin-bottom: var(--space-5); 948 - display: flex; 949 - justify-content: space-between; 950 - } 951 - 952 - .current-info .label { 953 - color: var(--text-secondary); 954 - } 955 - 956 - .current-info .value { 957 - font-weight: var(--font-medium); 958 - } 959 - 960 - .review-card { 961 - background: var(--bg-primary); 962 - border-radius: var(--radius-lg); 963 - padding: var(--space-4); 964 - margin-bottom: var(--space-5); 965 - } 966 - 967 - .review-row { 968 display: flex; 969 justify-content: space-between; 970 - padding: var(--space-3) 0; 971 - border-bottom: 1px solid var(--border); 972 - } 973 - 974 - .review-row:last-child { 975 - border-bottom: none; 976 - } 977 - 978 - .review-row .label { 979 - color: var(--text-secondary); 980 - } 981 - 982 - .review-row .value { 983 - font-weight: var(--font-medium); 984 - text-align: right; 985 - word-break: break-all; 986 - } 987 - 988 - .review-row .value.mono { 989 - font-family: var(--font-mono); 990 font-size: var(--text-sm); 991 } 992 - 993 - .progress-section { 994 - margin-bottom: var(--space-5); 995 - } 996 - 997 - .progress-item { 998 - display: flex; 999 - align-items: center; 1000 - gap: var(--space-3); 1001 - padding: var(--space-3) 0; 1002 - color: var(--text-secondary); 1003 - } 1004 - 1005 - .progress-item.completed { 1006 - color: var(--success-text); 1007 - } 1008 - 1009 - .progress-item.active { 1010 - color: var(--accent); 1011 - } 1012 - 1013 - .progress-item .icon { 1014 - width: 24px; 1015 - text-align: center; 1016 - } 1017 - 1018 - .progress-bar { 1019 - height: 8px; 1020 - background: var(--bg-primary); 1021 - border-radius: 4px; 1022 - overflow: hidden; 1023 - margin-bottom: var(--space-4); 1024 - } 1025 - 1026 - .progress-fill { 1027 - height: 100%; 1028 - background: var(--accent); 1029 - transition: width 0.3s ease; 1030 - } 1031 - 1032 - .status-text { 1033 - text-align: center; 1034 - color: var(--text-secondary); 1035 - font-size: var(--text-sm); 1036 - } 1037 - 1038 - .success-content { 1039 - text-align: center; 1040 - } 1041 - 1042 - .success-icon { 1043 - width: 64px; 1044 - height: 64px; 1045 - background: var(--success-bg); 1046 - color: var(--success-text); 1047 - border-radius: 50%; 1048 - display: flex; 1049 - align-items: center; 1050 - justify-content: center; 1051 - font-size: var(--text-2xl); 1052 - margin: 0 auto var(--space-5) auto; 1053 - } 1054 - 1055 - .success-details { 1056 - background: var(--bg-primary); 1057 - border-radius: var(--radius-lg); 1058 - padding: var(--space-4); 1059 - margin: var(--space-5) 0; 1060 - text-align: left; 1061 - } 1062 - 1063 - .success-details .detail-row { 1064 - display: flex; 1065 - justify-content: space-between; 1066 - padding: var(--space-2) 0; 1067 - } 1068 - 1069 - .success-details .label { 1070 color: var(--text-secondary); 1071 } 1072 - 1073 - .success-details .value { 1074 font-weight: var(--font-medium); 1075 } 1076 - 1077 - .success-details .value.mono { 1078 - font-family: var(--font-mono); 1079 font-size: var(--text-sm); 1080 - } 1081 - 1082 - .redirect-text { 1083 - color: var(--text-secondary); 1084 font-style: italic; 1085 - } 1086 - 1087 - .message.error { 1088 - background: var(--error-bg); 1089 - border: 1px solid var(--error-border); 1090 - color: var(--error-text); 1091 - padding: var(--space-4); 1092 - border-radius: var(--radius-lg); 1093 - margin-bottom: var(--space-5); 1094 - } 1095 - 1096 - .code-block { 1097 - background: var(--bg-primary); 1098 - border: 1px solid var(--border); 1099 - border-radius: var(--radius-lg); 1100 - padding: var(--space-4); 1101 - margin-bottom: var(--space-5); 1102 - overflow-x: auto; 1103 - } 1104 - 1105 - .code-block pre { 1106 - margin: 0; 1107 - font-family: var(--font-mono); 1108 - font-size: var(--text-sm); 1109 - white-space: pre-wrap; 1110 - word-break: break-all; 1111 - } 1112 - 1113 - code { 1114 - font-family: var(--font-mono); 1115 - background: var(--bg-primary); 1116 - padding: 2px 6px; 1117 - border-radius: var(--radius-sm); 1118 - font-size: 0.9em; 1119 } 1120 </style>
··· 1 <script lang="ts"> 2 import type { InboundMigrationFlow } 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 + 9 + interface ResumeInfo { 10 + direction: 'inbound' | 'outbound' 11 + sourceHandle: string 12 + targetHandle: string 13 + sourcePdsUrl: string 14 + targetPdsUrl: string 15 + targetEmail: string 16 + authMethod?: AuthMethod 17 + progressSummary: string 18 + step: string 19 + } 20 21 interface Props { 22 flow: InboundMigrationFlow 23 + resumeInfo?: ResumeInfo | null 24 onBack: () => void 25 onComplete: () => void 26 } 27 28 + let { flow, resumeInfo = null, onBack, onComplete }: Props = $props() 29 30 let serverInfo = $state<ServerDescription | null>(null) 31 let loading = $state(false) 32 let handleInput = $state('') 33 let localPasswordInput = $state('') 34 let understood = $state(false) 35 let selectedDomain = $state('') 36 let handleAvailable = $state<boolean | null>(null) 37 let checkingHandle = $state(false) 38 + let selectedAuthMethod = $state<AuthMethod>('password') 39 + let passkeyName = $state('') 40 + let appPasswordCopied = $state(false) 41 + let appPasswordAcknowledged = $state(false) 42 43 + const isResuming = $derived(flow.state.needsReauth === true) 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) 45 46 $effect(() => { 47 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 48 loadServerInfo() 49 } 50 + if (flow.state.step === 'choose-handle') { 51 + handleInput = '' 52 + handleAvailable = null 53 + } 54 + if (flow.state.step === 'source-handle' && resumeInfo) { 55 + handleInput = resumeInfo.sourceHandle 56 + selectedAuthMethod = resumeInfo.authMethod ?? 'password' 57 + } 58 }) 59 60 ··· 88 } 89 } 90 91 async function checkHandle() { 92 if (!handleInput.trim()) return 93 ··· 121 try { 122 await flow.startMigration() 123 } catch (err) { 124 + flow.setError(getErrorMessage(err)) 125 } finally { 126 loading = false 127 } ··· 133 try { 134 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 135 } catch (err) { 136 + flow.setError(getErrorMessage(err)) 137 } finally { 138 loading = false 139 } ··· 145 await flow.resendEmailVerification() 146 flow.setError(null) 147 } catch (err) { 148 + flow.setError(getErrorMessage(err)) 149 } finally { 150 loading = false 151 } ··· 157 try { 158 await flow.submitPlcToken(flow.state.plcToken) 159 } catch (err) { 160 + flow.setError(getErrorMessage(err)) 161 } finally { 162 loading = false 163 } ··· 169 await flow.resendPlcToken() 170 flow.setError(null) 171 } catch (err) { 172 + flow.setError(getErrorMessage(err)) 173 } finally { 174 loading = false 175 } ··· 180 try { 181 await flow.completeDidWebMigration() 182 } catch (err) { 183 + flow.setError(getErrorMessage(err)) 184 + } finally { 185 + loading = false 186 + } 187 + } 188 + 189 + async function registerPasskey() { 190 + loading = true 191 + flow.setError(null) 192 + 193 + try { 194 + if (!window.PublicKeyCredential) { 195 + throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 196 + } 197 + 198 + const { options } = await flow.startPasskeyRegistration() 199 + 200 + const publicKeyOptions = prepareWebAuthnCreationOptions( 201 + options as { publicKey: Record<string, unknown> } 202 + ) 203 + const credential = await navigator.credentials.create({ 204 + publicKey: publicKeyOptions, 205 + }) 206 + 207 + if (!credential) { 208 + throw new Error('Passkey creation was cancelled') 209 + } 210 + 211 + const publicKeyCredential = credential as PublicKeyCredential 212 + const response = publicKeyCredential.response as AuthenticatorAttestationResponse 213 + 214 + const credentialData = { 215 + id: publicKeyCredential.id, 216 + rawId: base64UrlEncode(publicKeyCredential.rawId), 217 + type: publicKeyCredential.type, 218 + response: { 219 + clientDataJSON: base64UrlEncode(response.clientDataJSON), 220 + attestationObject: base64UrlEncode(response.attestationObject), 221 + }, 222 + } 223 + 224 + await flow.completePasskeyRegistration(credentialData, passkeyName || undefined) 225 + } catch (err) { 226 + const message = getErrorMessage(err) 227 + if (message.includes('cancelled') || message.includes('AbortError')) { 228 + flow.setError('Passkey registration was cancelled. Please try again.') 229 + } else { 230 + flow.setError(message) 231 + } 232 } finally { 233 loading = false 234 } 235 } 236 237 + function copyAppPassword() { 238 + if (flow.state.generatedAppPassword) { 239 + navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 + appPasswordCopied = true 241 + } 242 + } 243 + 244 + async function handleProceedFromAppPassword() { 245 + loading = true 246 + try { 247 + await flow.proceedFromAppPassword() 248 + } catch (err) { 249 + flow.setError(getErrorMessage(err)) 250 + } finally { 251 + loading = false 252 + } 253 + } 254 + 255 + async function handleSourceHandleSubmit(e: Event) { 256 + e.preventDefault() 257 + loading = true 258 + flow.updateField('error', null) 259 + 260 + try { 261 + await flow.initiateOAuthLogin(handleInput) 262 + } catch (err) { 263 + flow.setError(getErrorMessage(err)) 264 + } finally { 265 + loading = false 266 + } 267 + } 268 + 269 + function proceedToReviewWithAuth() { 270 + const fullHandle = handleInput.includes('.') 271 + ? handleInput 272 + : `${handleInput}.${selectedDomain}` 273 + 274 + flow.updateField('targetHandle', fullHandle) 275 + flow.updateField('authMethod', selectedAuthMethod) 276 + flow.setStep('review') 277 + } 278 + 279 const steps = $derived(isDidWeb 280 + ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete'] 281 + : flow.state.authMethod === 'passkey' 282 + ? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete'] 283 + : ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']) 284 + 285 function getCurrentStepIndex(): number { 286 + const isPasskey = flow.state.authMethod === 'passkey' 287 switch (flow.state.step) { 288 case 'welcome': 289 + case 'source-handle': return 0 290 case 'choose-handle': return 1 291 case 'review': return 2 292 case 'migrating': return 3 293 case 'email-verify': return 4 294 + case 'passkey-setup': return isPasskey ? 5 : 4 295 + case 'app-password': return 6 296 case 'plc-token': 297 case 'did-web-update': 298 + case 'finalizing': return isPasskey ? 7 : 5 299 + case 'success': return isPasskey ? 8 : 6 300 default: return 0 301 } 302 } 303 </script> 304 305 + <div class="migration-wizard"> 306 <div class="step-indicator"> 307 + {#each steps as _, i} 308 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 309 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 310 </div> 311 {#if i < steps.length - 1} 312 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 313 {/if} 314 {/each} 315 </div> 316 + <div class="current-step-label"> 317 + <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 318 + </div> 319 320 {#if flow.state.error} 321 <div class="message error">{flow.state.error}</div> ··· 323 324 {#if flow.state.step === 'welcome'} 325 <div class="step-content"> 326 + <h2>{$_('migration.inbound.welcome.title')}</h2> 327 + <p>{$_('migration.inbound.welcome.desc')}</p> 328 329 <div class="info-box"> 330 + <h3>{$_('migration.inbound.common.whatWillHappen')}</h3> 331 <ol> 332 + <li>{$_('migration.inbound.common.step1')}</li> 333 + <li>{$_('migration.inbound.common.step2')}</li> 334 + <li>{$_('migration.inbound.common.step3')}</li> 335 + <li>{$_('migration.inbound.common.step4')}</li> 336 + <li>{$_('migration.inbound.common.step5')}</li> 337 </ol> 338 </div> 339 340 <div class="warning-box"> 341 + <strong>{$_('migration.inbound.common.beforeProceed')}</strong> 342 <ul> 343 + <li>{$_('migration.inbound.common.warning1')}</li> 344 + <li>{$_('migration.inbound.common.warning2')}</li> 345 + <li>{$_('migration.inbound.common.warning3')}</li> 346 </ul> 347 </div> 348 349 <label class="checkbox-label"> 350 <input type="checkbox" bind:checked={understood} /> 351 + <span>{$_('migration.inbound.welcome.understand')}</span> 352 </label> 353 354 <div class="button-row"> 355 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 + <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 357 + {$_('migration.inbound.common.continue')} 358 </button> 359 </div> 360 </div> 361 362 + {:else if flow.state.step === 'source-handle'} 363 <div class="step-content"> 364 + <h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2> 365 + <p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p> 366 367 + {#if isResuming && resumeInfo} 368 + <div class="info-box resume-info"> 369 + <h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3> 370 + <div class="resume-details"> 371 + <div class="resume-row"> 372 + <span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span> 373 + <span class="value">@{resumeInfo.sourceHandle}</span> 374 + </div> 375 + <div class="resume-row"> 376 + <span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span> 377 + <span class="value">@{resumeInfo.targetHandle}</span> 378 + </div> 379 + <div class="resume-row"> 380 + <span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span> 381 + <span class="value">{resumeInfo.progressSummary}</span> 382 + </div> 383 + </div> 384 + <p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p> 385 </div> 386 {/if} 387 388 + <form onsubmit={handleSourceHandleSubmit}> 389 <div class="field"> 390 + <label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label> 391 <input 392 + id="source-handle" 393 type="text" 394 + placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')} 395 bind:value={handleInput} 396 + disabled={loading || isResuming} 397 required 398 /> 399 + <p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p> 400 </div> 401 402 <div class="button-row"> 403 + <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 404 + <button type="submit" disabled={loading || !handleInput.trim()}> 405 + {loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))} 406 </button> 407 </div> 408 </form> ··· 410 411 {:else if flow.state.step === 'choose-handle'} 412 <div class="step-content"> 413 + <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 + <p>{$_('migration.inbound.chooseHandle.desc')}</p> 415 416 <div class="current-info"> 417 + <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 418 <span class="value">{flow.state.sourceHandle}</span> 419 </div> 420 421 <div class="field"> 422 + <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 423 <div class="handle-input-group"> 424 <input 425 id="new-handle" ··· 438 </div> 439 440 {#if checkingHandle} 441 + <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 442 {:else if handleAvailable === true} 443 + <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 444 {:else if handleAvailable === false} 445 + <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 446 {:else} 447 + <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 448 {/if} 449 </div> 450 451 <div class="field"> 452 + <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 453 <input 454 id="email" 455 type="email" ··· 461 </div> 462 463 <div class="field"> 464 + <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 465 + <div class="auth-method-options"> 466 + <label class="auth-option" class:selected={selectedAuthMethod === 'password'}> 467 + <input 468 + type="radio" 469 + name="auth-method" 470 + value="password" 471 + bind:group={selectedAuthMethod} 472 + /> 473 + <div class="auth-option-content"> 474 + <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 475 + <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 476 + </div> 477 + </label> 478 + <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}> 479 + <input 480 + type="radio" 481 + name="auth-method" 482 + value="passkey" 483 + bind:group={selectedAuthMethod} 484 + /> 485 + <div class="auth-option-content"> 486 + <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 487 + <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 488 + </div> 489 + </label> 490 + </div> 491 </div> 492 493 + {#if selectedAuthMethod === 'password'} 494 + <div class="field"> 495 + <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 496 + <input 497 + id="new-password" 498 + type="password" 499 + placeholder="Password for your new account" 500 + bind:value={flow.state.targetPassword} 501 + oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 502 + required 503 + minlength="8" 504 + /> 505 + <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 506 + </div> 507 + {:else} 508 + <div class="info-box"> 509 + <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 510 + </div> 511 + {/if} 512 + 513 {#if serverInfo?.inviteCodeRequired} 514 <div class="field"> 515 + <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 516 <input 517 id="invite" 518 type="text" ··· 525 {/if} 526 527 <div class="button-row"> 528 + <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 529 <button 530 + disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 + onclick={proceedToReviewWithAuth} 532 > 533 + {$_('migration.inbound.common.continue')} 534 </button> 535 </div> 536 </div> 537 538 {:else if flow.state.step === 'review'} 539 <div class="step-content"> 540 + <h2>{$_('migration.inbound.review.title')}</h2> 541 + <p>{$_('migration.inbound.review.desc')}</p> 542 543 <div class="review-card"> 544 <div class="review-row"> 545 + <span class="label">{$_('migration.inbound.review.currentHandle')}:</span> 546 <span class="value">{flow.state.sourceHandle}</span> 547 </div> 548 <div class="review-row"> 549 + <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 550 <span class="value">{flow.state.targetHandle}</span> 551 </div> 552 <div class="review-row"> 553 + <span class="label">{$_('migration.inbound.review.did')}:</span> 554 <span class="value mono">{flow.state.sourceDid}</span> 555 </div> 556 <div class="review-row"> 557 + <span class="label">{$_('migration.inbound.review.sourcePds')}:</span> 558 <span class="value">{flow.state.sourcePdsUrl}</span> 559 </div> 560 <div class="review-row"> 561 + <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 562 <span class="value">{window.location.origin}</span> 563 </div> 564 <div class="review-row"> 565 + <span class="label">{$_('migration.inbound.review.email')}:</span> 566 <span class="value">{flow.state.targetEmail}</span> 567 </div> 568 + <div class="review-row"> 569 + <span class="label">{$_('migration.inbound.review.authentication')}:</span> 570 + <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 571 + </div> 572 </div> 573 574 <div class="warning-box"> 575 + {$_('migration.inbound.review.warning')} 576 </div> 577 578 <div class="button-row"> 579 + <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 580 <button onclick={startMigration} disabled={loading}> 581 + {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 582 </button> 583 </div> 584 </div> 585 586 {:else if flow.state.step === 'migrating'} 587 <div class="step-content"> 588 + <h2>{$_('migration.inbound.migrating.title')}</h2> 589 + <p>{$_('migration.inbound.migrating.desc')}</p> 590 591 <div class="progress-section"> 592 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 593 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 594 + <span>{$_('migration.inbound.migrating.exportRepo')}</span> 595 </div> 596 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 597 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 598 + <span>{$_('migration.inbound.migrating.importRepo')}</span> 599 </div> 600 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 601 <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 602 + <span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 603 </div> 604 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 605 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 606 + <span>{$_('migration.inbound.migrating.migratePrefs')}</span> 607 </div> 608 </div> 609 ··· 619 <p class="status-text">{flow.state.progress.currentOperation}</p> 620 </div> 621 622 + {:else if flow.state.step === 'passkey-setup'} 623 + <div class="step-content"> 624 + <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 625 + <p>{$_('migration.inbound.passkeySetup.desc')}</p> 626 + 627 + {#if flow.state.error} 628 + <div class="message error"> 629 + {flow.state.error} 630 + </div> 631 + {/if} 632 + 633 + <div class="field"> 634 + <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 635 + <input 636 + id="passkey-name" 637 + type="text" 638 + placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 639 + bind:value={passkeyName} 640 + disabled={loading} 641 + /> 642 + <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 643 + </div> 644 + 645 + <div class="passkey-section"> 646 + <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 647 + <button class="primary" onclick={registerPasskey} disabled={loading}> 648 + {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 649 + </button> 650 + </div> 651 + </div> 652 + 653 + {:else if flow.state.step === 'app-password'} 654 + <div class="step-content"> 655 + <h2>{$_('migration.inbound.appPassword.title')}</h2> 656 + <p>{$_('migration.inbound.appPassword.desc')}</p> 657 + 658 + <div class="warning-box"> 659 + <strong>{$_('migration.inbound.appPassword.warning')}</strong> 660 + </div> 661 + 662 + <div class="app-password-display"> 663 + <div class="app-password-label"> 664 + {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong> 665 + </div> 666 + <code class="app-password-code">{flow.state.generatedAppPassword}</code> 667 + <button type="button" class="copy-btn" onclick={copyAppPassword}> 668 + {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 669 + </button> 670 + </div> 671 + 672 + <label class="checkbox-label"> 673 + <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 674 + <span>{$_('migration.inbound.appPassword.saved')}</span> 675 + </label> 676 + 677 + <div class="button-row"> 678 + <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}> 679 + {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 680 + </button> 681 + </div> 682 + </div> 683 + 684 {:else if flow.state.step === 'email-verify'} 685 <div class="step-content"> 686 <h2>{$_('migration.inbound.emailVerify.title')}</h2> ··· 693 </div> 694 695 {#if flow.state.error} 696 + <div class="message error"> 697 {flow.state.error} 698 </div> 699 {/if} ··· 725 726 {:else if flow.state.step === 'plc-token'} 727 <div class="step-content"> 728 + <h2>{$_('migration.inbound.plcToken.title')}</h2> 729 + <p>{$_('migration.inbound.plcToken.desc')}</p> 730 731 <div class="info-box"> 732 + <p>{$_('migration.inbound.plcToken.info')}</p> 733 </div> 734 735 <form onsubmit={submitPlcToken}> 736 <div class="field"> 737 + <label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label> 738 <input 739 id="plc-token" 740 type="text" 741 + placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')} 742 bind:value={flow.state.plcToken} 743 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 744 disabled={loading} ··· 748 749 <div class="button-row"> 750 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 751 + {$_('migration.inbound.plcToken.resend')} 752 </button> 753 <button type="submit" disabled={loading || !flow.state.plcToken}> 754 + {loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')} 755 </button> 756 </div> 757 </form> ··· 806 </div> 807 808 <div class="button-row"> 809 + <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 810 <button onclick={completeDidWeb} disabled={loading}> 811 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')} 812 </button> ··· 815 816 {:else if flow.state.step === 'finalizing'} 817 <div class="step-content"> 818 + <h2>{$_('migration.inbound.finalizing.title')}</h2> 819 + <p>{$_('migration.inbound.finalizing.desc')}</p> 820 821 <div class="progress-section"> 822 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 823 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 824 + <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 825 </div> 826 <div class="progress-item" class:completed={flow.state.progress.activated}> 827 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 828 + <span>{$_('migration.inbound.finalizing.activating')}</span> 829 </div> 830 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 831 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 832 + <span>{$_('migration.inbound.finalizing.deactivating')}</span> 833 </div> 834 </div> 835 ··· 839 {:else if flow.state.step === 'success'} 840 <div class="step-content success-content"> 841 <div class="success-icon">✓</div> 842 + <h2>{$_('migration.inbound.success.title')}</h2> 843 + <p>{$_('migration.inbound.success.desc')}</p> 844 845 <div class="success-details"> 846 <div class="detail-row"> 847 + <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 848 <span class="value">{flow.state.targetHandle}</span> 849 </div> 850 <div class="detail-row"> 851 + <span class="label">{$_('migration.inbound.success.did')}:</span> 852 <span class="value mono">{flow.state.sourceDid}</span> 853 </div> 854 </div> 855 856 {#if flow.state.progress.blobsFailed.length > 0} 857 + <div class="message warning"> 858 + {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 859 </div> 860 {/if} 861 862 + <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 863 </div> 864 865 {:else if flow.state.step === 'error'} 866 <div class="step-content"> 867 + <h2>{$_('migration.inbound.error.title')}</h2> 868 + <p>{$_('migration.inbound.error.desc')}</p> 869 870 + <div class="message error"> 871 + {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 872 </div> 873 874 <div class="button-row"> 875 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 876 </div> 877 </div> 878 {/if} 879 </div> 880 881 <style> 882 + .passkey-section { 883 + margin-top: 16px; 884 } 885 + .passkey-section button { 886 + width: 100%; 887 + margin-top: 12px; 888 } 889 + .app-password-display { 890 + background: var(--bg-card); 891 + border: 2px solid var(--accent); 892 border-radius: var(--radius-xl); 893 padding: var(--space-6); 894 + text-align: center; 895 + margin: var(--space-4) 0; 896 } 897 + .app-password-label { 898 + font-size: var(--text-sm); 899 color: var(--text-secondary); 900 + margin-bottom: var(--space-4); 901 } 902 + .app-password-code { 903 + display: block; 904 + font-size: var(--text-xl); 905 + font-family: ui-monospace, monospace; 906 + letter-spacing: 0.1em; 907 padding: var(--space-5); 908 + background: var(--bg-input); 909 + border-radius: var(--radius-md); 910 + margin-bottom: var(--space-4); 911 + user-select: all; 912 } 913 + .copy-btn { 914 + padding: var(--space-3) var(--space-5); 915 font-size: var(--text-sm); 916 } 917 + .resume-info { 918 margin-bottom: var(--space-5); 919 } 920 + .resume-info h3 { 921 + margin: 0 0 var(--space-3) 0; 922 + font-size: var(--text-base); 923 } 924 + .resume-details { 925 display: flex; 926 + flex-direction: column; 927 gap: var(--space-2); 928 } 929 + .resume-row { 930 display: flex; 931 justify-content: space-between; 932 font-size: var(--text-sm); 933 } 934 + .resume-row .label { 935 color: var(--text-secondary); 936 } 937 + .resume-row .value { 938 font-weight: var(--font-medium); 939 } 940 + .resume-note { 941 + margin-top: var(--space-3); 942 font-size: var(--text-sm); 943 font-style: italic; 944 } 945 </style>
+20 -466
frontend/src/components/migration/OutboundWizard.svelte
··· 2 import type { OutboundMigrationFlow } from '../../lib/migration' 3 import type { ServerDescription } from '../../lib/migration/types' 4 import { getAuthState, logout } from '../../lib/auth.svelte' 5 6 interface Props { 7 flow: OutboundMigrationFlow ··· 119 } 120 </script> 121 122 - <div class="outbound-wizard"> 123 {#if flow.state.step !== 'welcome'} 124 <div class="step-indicator"> 125 {#each steps as stepName, i} ··· 135 {/if} 136 137 {#if flow.state.error} 138 - <div class="message error">{flow.state.error}</div> 139 {/if} 140 141 {#if flow.state.step === 'welcome'} ··· 149 </div> 150 151 {#if isDidWeb()} 152 - <div class="warning-box"> 153 <strong>did:web Migration Notice</strong> 154 <p> 155 Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will ··· 161 </div> 162 {/if} 163 164 - <div class="info-box"> 165 <h3>What will happen:</h3> 166 <ol> 167 <li>Choose your new PDS</li> ··· 173 </ol> 174 </div> 175 176 - <div class="warning-box"> 177 <strong>Before you proceed:</strong> 178 <ul> 179 <li>You need access to the email registered with this account</li> ··· 202 <p>Enter the URL of the PDS you want to migrate to.</p> 203 204 <form onsubmit={validatePds}> 205 - <div class="field"> 206 <label for="pds-url">PDS URL</label> 207 <input 208 id="pds-url" ··· 212 disabled={loading} 213 required 214 /> 215 - <p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 216 </div> 217 218 <div class="button-row"> ··· 264 <span class="value">{flow.state.targetPdsUrl}</span> 265 </div> 266 267 - <div class="field"> 268 <label for="new-handle">New Handle</label> 269 <div class="handle-input-group"> 270 <input ··· 281 </select> 282 {/if} 283 </div> 284 - <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 285 </div> 286 287 - <div class="field"> 288 <label for="email">Email Address</label> 289 <input 290 id="email" ··· 296 /> 297 </div> 298 299 - <div class="field"> 300 <label for="new-password">Password</label> 301 <input 302 id="new-password" ··· 307 required 308 minlength="8" 309 /> 310 - <p class="hint">At least 8 characters. This will be your password on the new PDS.</p> 311 </div> 312 313 {#if flow.state.targetServerInfo?.inviteCodeRequired} 314 - <div class="field"> 315 <label for="invite">Invite Code</label> 316 <input 317 id="invite" ··· 321 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 322 required 323 /> 324 - <p class="hint">Required by this PDS to create an account</p> 325 </div> 326 {/if} 327 ··· 368 </div> 369 </div> 370 371 - <div class="warning-box final-warning"> 372 <strong>This action cannot be easily undone!</strong> 373 <p> 374 After migration completes, your account on this PDS will be deactivated. ··· 430 <h2>Verify Migration</h2> 431 <p>A verification code has been sent to your email ({auth.session?.email}).</p> 432 433 - <div class="info-box"> 434 <p> 435 This code confirms you have access to the account and authorizes updating your identity 436 to point to the new PDS. ··· 438 </div> 439 440 <form onsubmit={submitPlcToken}> 441 - <div class="field"> 442 <label for="plc-token">Verification Code</label> 443 <input 444 id="plc-token" ··· 507 </div> 508 509 {#if flow.state.progress.blobsFailed.length > 0} 510 - <div class="warning-box"> 511 <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 512 These may be images or other media that are no longer available. 513 </div> ··· 530 <h2>Migration Error</h2> 531 <p>An error occurred during migration.</p> 532 533 - <div class="error-box"> 534 {flow.state.error} 535 </div> 536 ··· 542 </div> 543 544 <style> 545 - .outbound-wizard { 546 - max-width: 600px; 547 - margin: 0 auto; 548 - } 549 - 550 - .step-indicator { 551 - display: flex; 552 - align-items: center; 553 - justify-content: center; 554 - margin-bottom: var(--space-8); 555 - padding: 0 var(--space-4); 556 - } 557 - 558 - .step { 559 - display: flex; 560 - flex-direction: column; 561 - align-items: center; 562 - gap: var(--space-2); 563 - } 564 - 565 - .step-dot { 566 - width: 32px; 567 - height: 32px; 568 - border-radius: 50%; 569 - background: var(--bg-secondary); 570 - border: 2px solid var(--border); 571 - display: flex; 572 - align-items: center; 573 - justify-content: center; 574 - font-size: var(--text-sm); 575 - font-weight: var(--font-medium); 576 - color: var(--text-secondary); 577 - } 578 - 579 - .step.active .step-dot { 580 - background: var(--accent); 581 - border-color: var(--accent); 582 - color: var(--text-inverse); 583 - } 584 - 585 - .step.completed .step-dot { 586 - background: var(--success-bg); 587 - border-color: var(--success-text); 588 - color: var(--success-text); 589 - } 590 - 591 - .step-label { 592 - font-size: var(--text-xs); 593 - color: var(--text-secondary); 594 - } 595 - 596 - .step.active .step-label { 597 - color: var(--accent); 598 - font-weight: var(--font-medium); 599 - } 600 - 601 - .step-line { 602 - flex: 1; 603 - height: 2px; 604 - background: var(--border); 605 - margin: 0 var(--space-2); 606 - margin-bottom: var(--space-6); 607 - min-width: 20px; 608 - } 609 - 610 - .step-line.completed { 611 - background: var(--success-text); 612 - } 613 - 614 - .step-content { 615 - background: var(--bg-secondary); 616 - border-radius: var(--radius-xl); 617 - padding: var(--space-6); 618 - } 619 - 620 - .step-content h2 { 621 - margin: 0 0 var(--space-3) 0; 622 - } 623 - 624 - .step-content > p { 625 - color: var(--text-secondary); 626 - margin: 0 0 var(--space-5) 0; 627 - } 628 - 629 - .current-account { 630 - background: var(--bg-primary); 631 - border-radius: var(--radius-lg); 632 - padding: var(--space-4); 633 - margin-bottom: var(--space-5); 634 - display: flex; 635 - justify-content: space-between; 636 - align-items: center; 637 - } 638 - 639 - .current-account .label { 640 - color: var(--text-secondary); 641 - } 642 - 643 - .current-account .value { 644 - font-weight: var(--font-medium); 645 - font-size: var(--text-lg); 646 - } 647 - 648 - .info-box { 649 - background: var(--accent-muted); 650 - border: 1px solid var(--accent); 651 - border-radius: var(--radius-lg); 652 - padding: var(--space-5); 653 - margin-bottom: var(--space-5); 654 - } 655 - 656 - .info-box h3 { 657 - margin: 0 0 var(--space-3) 0; 658 - font-size: var(--text-base); 659 - } 660 - 661 - .info-box ol, .info-box ul { 662 - margin: 0; 663 - padding-left: var(--space-5); 664 - } 665 - 666 - .info-box li { 667 - margin-bottom: var(--space-2); 668 - color: var(--text-secondary); 669 - } 670 - 671 - .info-box p { 672 - margin: 0; 673 - color: var(--text-secondary); 674 - } 675 - 676 - .warning-box { 677 - background: var(--warning-bg); 678 - border: 1px solid var(--warning-border); 679 - border-radius: var(--radius-lg); 680 - padding: var(--space-5); 681 - margin-bottom: var(--space-5); 682 - font-size: var(--text-sm); 683 - } 684 - 685 - .warning-box strong { 686 - color: var(--warning-text); 687 - } 688 - 689 - .warning-box p { 690 - margin: var(--space-3) 0 0 0; 691 - color: var(--text-secondary); 692 - } 693 - 694 - .warning-box ul { 695 - margin: var(--space-3) 0 0 0; 696 - padding-left: var(--space-5); 697 - } 698 - 699 - .final-warning { 700 - background: var(--error-bg); 701 - border-color: var(--error-border); 702 - } 703 - 704 - .final-warning strong { 705 - color: var(--error-text); 706 - } 707 - 708 - .error-box { 709 - background: var(--error-bg); 710 - border: 1px solid var(--error-border); 711 - border-radius: var(--radius-lg); 712 - padding: var(--space-5); 713 - margin-bottom: var(--space-5); 714 - color: var(--error-text); 715 - } 716 - 717 - .checkbox-label { 718 - display: inline-flex; 719 - align-items: flex-start; 720 - gap: var(--space-3); 721 - cursor: pointer; 722 - margin-bottom: var(--space-5); 723 - text-align: left; 724 - } 725 - 726 - .checkbox-label input[type="checkbox"] { 727 - width: 18px; 728 - height: 18px; 729 - margin: 0; 730 - flex-shrink: 0; 731 - } 732 - 733 - .button-row { 734 - display: flex; 735 - gap: var(--space-3); 736 - justify-content: flex-end; 737 - margin-top: var(--space-5); 738 - } 739 - 740 - .field { 741 - margin-bottom: var(--space-5); 742 - } 743 - 744 - .field label { 745 - display: block; 746 - margin-bottom: var(--space-2); 747 - font-weight: var(--font-medium); 748 - } 749 - 750 - .field input, .field select { 751 - width: 100%; 752 - padding: var(--space-3); 753 - border: 1px solid var(--border); 754 - border-radius: var(--radius-md); 755 - background: var(--bg-primary); 756 - color: var(--text-primary); 757 - } 758 - 759 - .field input:focus, .field select:focus { 760 - outline: none; 761 - border-color: var(--accent); 762 - } 763 - 764 - .hint { 765 - font-size: var(--text-sm); 766 - color: var(--text-secondary); 767 - margin: var(--space-2) 0 0 0; 768 - } 769 - 770 - .handle-input-group { 771 - display: flex; 772 - gap: var(--space-2); 773 - } 774 - 775 - .handle-input-group input { 776 - flex: 1; 777 - } 778 - 779 - .handle-input-group select { 780 - width: auto; 781 - } 782 - 783 - .current-info { 784 - background: var(--bg-primary); 785 - border-radius: var(--radius-lg); 786 - padding: var(--space-4); 787 - margin-bottom: var(--space-5); 788 - display: flex; 789 - justify-content: space-between; 790 - } 791 - 792 - .current-info .label { 793 - color: var(--text-secondary); 794 - } 795 - 796 - .current-info .value { 797 - font-weight: var(--font-medium); 798 - } 799 - 800 - .server-info { 801 - background: var(--bg-primary); 802 - border-radius: var(--radius-lg); 803 - padding: var(--space-4); 804 - margin-top: var(--space-5); 805 - } 806 - 807 - .server-info h3 { 808 - margin: 0 0 var(--space-3) 0; 809 - font-size: var(--text-base); 810 - color: var(--success-text); 811 - } 812 - 813 - .server-info .info-row { 814 - display: flex; 815 - justify-content: space-between; 816 - padding: var(--space-2) 0; 817 - font-size: var(--text-sm); 818 - } 819 - 820 - .server-info .label { 821 - color: var(--text-secondary); 822 - } 823 - 824 - .server-info a { 825 - display: inline-block; 826 - margin-top: var(--space-2); 827 - margin-right: var(--space-3); 828 - color: var(--accent); 829 - font-size: var(--text-sm); 830 - } 831 - 832 - .review-card { 833 - background: var(--bg-primary); 834 - border-radius: var(--radius-lg); 835 - padding: var(--space-4); 836 - margin-bottom: var(--space-5); 837 - } 838 - 839 - .review-row { 840 - display: flex; 841 - justify-content: space-between; 842 - padding: var(--space-3) 0; 843 - border-bottom: 1px solid var(--border); 844 - } 845 - 846 - .review-row:last-child { 847 - border-bottom: none; 848 - } 849 - 850 - .review-row .label { 851 - color: var(--text-secondary); 852 - } 853 - 854 - .review-row .value { 855 - font-weight: var(--font-medium); 856 - text-align: right; 857 - word-break: break-all; 858 - } 859 - 860 - .review-row .value.mono { 861 - font-family: var(--font-mono); 862 - font-size: var(--text-sm); 863 - } 864 - 865 - .progress-section { 866 - margin-bottom: var(--space-5); 867 - } 868 - 869 - .progress-item { 870 - display: flex; 871 - align-items: center; 872 - gap: var(--space-3); 873 - padding: var(--space-3) 0; 874 - color: var(--text-secondary); 875 - } 876 - 877 - .progress-item.completed { 878 - color: var(--success-text); 879 - } 880 - 881 - .progress-item.active { 882 - color: var(--accent); 883 - } 884 - 885 - .progress-item .icon { 886 - width: 24px; 887 - text-align: center; 888 - } 889 - 890 - .progress-bar { 891 - height: 8px; 892 - background: var(--bg-primary); 893 - border-radius: 4px; 894 - overflow: hidden; 895 - margin-bottom: var(--space-4); 896 - } 897 - 898 - .progress-fill { 899 - height: 100%; 900 - background: var(--accent); 901 - transition: width 0.3s ease; 902 - } 903 - 904 - .status-text { 905 - text-align: center; 906 - color: var(--text-secondary); 907 - font-size: var(--text-sm); 908 - } 909 - 910 - .success-content { 911 - text-align: center; 912 - } 913 - 914 - .success-icon { 915 - width: 64px; 916 - height: 64px; 917 - background: var(--success-bg); 918 - color: var(--success-text); 919 - border-radius: 50%; 920 - display: flex; 921 - align-items: center; 922 - justify-content: center; 923 - font-size: var(--text-2xl); 924 - margin: 0 auto var(--space-5) auto; 925 - } 926 - 927 - .success-details { 928 - background: var(--bg-primary); 929 - border-radius: var(--radius-lg); 930 - padding: var(--space-4); 931 - margin: var(--space-5) 0; 932 - text-align: left; 933 - } 934 - 935 - .success-details .detail-row { 936 - display: flex; 937 - justify-content: space-between; 938 - padding: var(--space-2) 0; 939 - } 940 - 941 - .success-details .label { 942 - color: var(--text-secondary); 943 - } 944 - 945 - .success-details .value { 946 - font-weight: var(--font-medium); 947 - } 948 - 949 - .success-details .value.mono { 950 - font-family: var(--font-mono); 951 - font-size: var(--text-sm); 952 - } 953 - 954 - .next-steps { 955 - background: var(--accent-muted); 956 - border-radius: var(--radius-lg); 957 - padding: var(--space-5); 958 - margin: var(--space-5) 0; 959 - text-align: left; 960 - } 961 - 962 - .next-steps h3 { 963 - margin: 0 0 var(--space-3) 0; 964 - } 965 - 966 - .next-steps ol { 967 - margin: 0; 968 - padding-left: var(--space-5); 969 - } 970 - 971 - .next-steps li { 972 - margin-bottom: var(--space-2); 973 - } 974 - 975 - .next-steps a { 976 - color: var(--accent); 977 - } 978 - 979 - .redirect-text { 980 - color: var(--text-secondary); 981 - font-style: italic; 982 - } 983 - 984 - .message.error { 985 - background: var(--error-bg); 986 - border: 1px solid var(--error-border); 987 - color: var(--error-text); 988 - padding: var(--space-4); 989 - border-radius: var(--radius-lg); 990 - margin-bottom: var(--space-5); 991 - } 992 </style>
··· 2 import type { OutboundMigrationFlow } from '../../lib/migration' 3 import type { ServerDescription } from '../../lib/migration/types' 4 import { getAuthState, logout } from '../../lib/auth.svelte' 5 + import '../../styles/migration.css' 6 7 interface Props { 8 flow: OutboundMigrationFlow ··· 120 } 121 </script> 122 123 + <div class="migration-wizard"> 124 {#if flow.state.step !== 'welcome'} 125 <div class="step-indicator"> 126 {#each steps as stepName, i} ··· 136 {/if} 137 138 {#if flow.state.error} 139 + <div class="migration-message error">{flow.state.error}</div> 140 {/if} 141 142 {#if flow.state.step === 'welcome'} ··· 150 </div> 151 152 {#if isDidWeb()} 153 + <div class="migration-warning-box"> 154 <strong>did:web Migration Notice</strong> 155 <p> 156 Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will ··· 162 </div> 163 {/if} 164 165 + <div class="migration-info-box"> 166 <h3>What will happen:</h3> 167 <ol> 168 <li>Choose your new PDS</li> ··· 174 </ol> 175 </div> 176 177 + <div class="migration-warning-box"> 178 <strong>Before you proceed:</strong> 179 <ul> 180 <li>You need access to the email registered with this account</li> ··· 203 <p>Enter the URL of the PDS you want to migrate to.</p> 204 205 <form onsubmit={validatePds}> 206 + <div class="migration-field"> 207 <label for="pds-url">PDS URL</label> 208 <input 209 id="pds-url" ··· 213 disabled={loading} 214 required 215 /> 216 + <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 217 </div> 218 219 <div class="button-row"> ··· 265 <span class="value">{flow.state.targetPdsUrl}</span> 266 </div> 267 268 + <div class="migration-field"> 269 <label for="new-handle">New Handle</label> 270 <div class="handle-input-group"> 271 <input ··· 282 </select> 283 {/if} 284 </div> 285 + <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 286 </div> 287 288 + <div class="migration-field"> 289 <label for="email">Email Address</label> 290 <input 291 id="email" ··· 297 /> 298 </div> 299 300 + <div class="migration-field"> 301 <label for="new-password">Password</label> 302 <input 303 id="new-password" ··· 308 required 309 minlength="8" 310 /> 311 + <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p> 312 </div> 313 314 {#if flow.state.targetServerInfo?.inviteCodeRequired} 315 + <div class="migration-field"> 316 <label for="invite">Invite Code</label> 317 <input 318 id="invite" ··· 322 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 323 required 324 /> 325 + <p class="migration-hint">Required by this PDS to create an account</p> 326 </div> 327 {/if} 328 ··· 369 </div> 370 </div> 371 372 + <div class="migration-warning-box final-warning"> 373 <strong>This action cannot be easily undone!</strong> 374 <p> 375 After migration completes, your account on this PDS will be deactivated. ··· 431 <h2>Verify Migration</h2> 432 <p>A verification code has been sent to your email ({auth.session?.email}).</p> 433 434 + <div class="migration-info-box"> 435 <p> 436 This code confirms you have access to the account and authorizes updating your identity 437 to point to the new PDS. ··· 439 </div> 440 441 <form onsubmit={submitPlcToken}> 442 + <div class="migration-field"> 443 <label for="plc-token">Verification Code</label> 444 <input 445 id="plc-token" ··· 508 </div> 509 510 {#if flow.state.progress.blobsFailed.length > 0} 511 + <div class="migration-warning-box"> 512 <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 513 These may be images or other media that are no longer available. 514 </div> ··· 531 <h2>Migration Error</h2> 532 <p>An error occurred during migration.</p> 533 534 + <div class="migration-error-box"> 535 {flow.state.error} 536 </div> 537 ··· 543 </div> 544 545 <style> 546 </style>
+5 -1
frontend/src/lib/auth.svelte.ts
··· 444 state.savedAccounts = newState.savedAccounts ?? []; 445 } 446 447 - export function _testReset() { 448 state.session = null; 449 state.loading = true; 450 state.error = null; 451 state.savedAccounts = []; 452 localStorage.removeItem(STORAGE_KEY); 453 localStorage.removeItem(ACCOUNTS_KEY); 454 }
··· 444 state.savedAccounts = newState.savedAccounts ?? []; 445 } 446 447 + export function _testResetState() { 448 state.session = null; 449 state.loading = true; 450 state.error = null; 451 state.savedAccounts = []; 452 + } 453 + 454 + export function _testReset() { 455 + _testResetState(); 456 localStorage.removeItem(STORAGE_KEY); 457 localStorage.removeItem(ACCOUNTS_KEY); 458 }
+1 -1
frontend/src/lib/crypto.ts
··· 10 publicKeyDidKey: string; 11 } 12 13 - export async function generateKeypair(): Promise<Keypair> { 14 const privateKey = secp.utils.randomPrivateKey(); 15 const publicKey = secp.getPublicKey(privateKey, true); 16
··· 10 publicKeyDidKey: string; 11 } 12 13 + export function generateKeypair(): Keypair { 14 const privateKey = secp.utils.randomPrivateKey(); 15 const publicKey = secp.getPublicKey(privateKey, true); 16
+488 -25
frontend/src/lib/migration/atproto-client.ts
··· 1 import type { 2 AccountStatus, 3 BlobRef, 4 CreateAccountParams, 5 DidCredentials, 6 DidDocument, 7 - MigrationError, 8 PlcOperation, 9 Preferences, 10 ServerDescription, 11 Session, 12 } from "./types"; 13 14 function apiLog( ··· 28 export class AtprotoClient { 29 private baseUrl: string; 30 private accessToken: string | null = null; 31 32 constructor(pdsUrl: string) { 33 this.baseUrl = pdsUrl.replace(/\/$/, ""); ··· 41 return this.accessToken; 42 } 43 44 private async xrpc<T>( 45 method: string, 46 options?: { ··· 67 url += `?${searchParams}`; 68 } 69 70 - const headers: Record<string, string> = {}; 71 - const token = authToken ?? this.accessToken; 72 - if (token) { 73 - headers["Authorization"] = `Bearer ${token}`; 74 - } 75 76 - let requestBody: BodyInit | undefined; 77 - if (rawBody) { 78 - headers["Content-Type"] = contentType ?? "application/octet-stream"; 79 - requestBody = rawBody; 80 - } else if (body) { 81 - headers["Content-Type"] = "application/json"; 82 - requestBody = JSON.stringify(body); 83 - } else if (httpMethod === "POST") { 84 - headers["Content-Type"] = "application/json"; 85 } 86 87 - const res = await fetch(url, { 88 - method: httpMethod, 89 - headers, 90 - body: requestBody, 91 - }); 92 - 93 if (!res.ok) { 94 const err = await res.json().catch(() => ({ 95 error: "Unknown", 96 message: res.statusText, 97 })); 98 - const error = new Error(err.message) as Error & { 99 status: number; 100 error: string; 101 }; 102 error.status = res.status; 103 error.error = err.error; 104 throw error; 105 } 106 107 const responseContentType = res.headers.get("content-type") ?? ""; ··· 231 error: "Unknown", 232 message: res.statusText, 233 })); 234 - const error = new Error(err.message) as Error & { 235 status: number; 236 error: string; 237 }; ··· 436 httpMethod: "POST", 437 }); 438 } 439 } 440 441 export async function resolveDidDocument(did: string): Promise<DidDocument> { ··· 466 export async function resolvePdsUrl( 467 handleOrDid: string, 468 ): Promise<{ did: string; pdsUrl: string }> { 469 - let did: string; 470 471 if (handleOrDid.startsWith("did:")) { 472 did = handleOrDid; ··· 515 } 516 } 517 518 const didDoc = await resolveDidDocument(did); 519 520 const pdsService = didDoc.service?.find( ··· 529 } 530 531 export function createLocalClient(): AtprotoClient { 532 - return new AtprotoClient(window.location.origin); 533 }
··· 1 import type { 2 AccountStatus, 3 BlobRef, 4 + CompletePasskeySetupResponse, 5 CreateAccountParams, 6 + CreatePasskeyAccountParams, 7 DidCredentials, 8 DidDocument, 9 + OAuthServerMetadata, 10 + OAuthTokenResponse, 11 + PasskeyAccountSetup, 12 PlcOperation, 13 Preferences, 14 ServerDescription, 15 Session, 16 + StartPasskeyRegistrationResponse, 17 } from "./types"; 18 19 function apiLog( ··· 33 export class AtprotoClient { 34 private baseUrl: string; 35 private accessToken: string | null = null; 36 + private dpopKeyPair: DPoPKeyPair | null = null; 37 + private dpopNonce: string | null = null; 38 39 constructor(pdsUrl: string) { 40 this.baseUrl = pdsUrl.replace(/\/$/, ""); ··· 48 return this.accessToken; 49 } 50 51 + setDPoPKeyPair(keyPair: DPoPKeyPair | null) { 52 + this.dpopKeyPair = keyPair; 53 + } 54 + 55 private async xrpc<T>( 56 method: string, 57 options?: { ··· 78 url += `?${searchParams}`; 79 } 80 81 + const makeRequest = async (nonce?: string): Promise<Response> => { 82 + const headers: Record<string, string> = {}; 83 + const token = authToken ?? this.accessToken; 84 + if (token) { 85 + if (this.dpopKeyPair) { 86 + headers["Authorization"] = `DPoP ${token}`; 87 + const tokenHash = await computeAccessTokenHash(token); 88 + const dpopProof = await createDPoPProof( 89 + this.dpopKeyPair, 90 + httpMethod, 91 + url.split("?")[0], 92 + nonce, 93 + tokenHash, 94 + ); 95 + headers["DPoP"] = dpopProof; 96 + } else { 97 + headers["Authorization"] = `Bearer ${token}`; 98 + } 99 + } 100 + 101 + let requestBody: BodyInit | undefined; 102 + if (rawBody) { 103 + headers["Content-Type"] = contentType ?? "application/octet-stream"; 104 + requestBody = rawBody; 105 + } else if (body) { 106 + headers["Content-Type"] = "application/json"; 107 + requestBody = JSON.stringify(body); 108 + } else if (httpMethod === "POST") { 109 + headers["Content-Type"] = "application/json"; 110 + } 111 112 + return fetch(url, { 113 + method: httpMethod, 114 + headers, 115 + body: requestBody, 116 + }); 117 + }; 118 + 119 + let res = await makeRequest(this.dpopNonce ?? undefined); 120 + 121 + if (!res.ok && this.dpopKeyPair) { 122 + const dpopNonce = res.headers.get("DPoP-Nonce"); 123 + if (dpopNonce && dpopNonce !== this.dpopNonce) { 124 + this.dpopNonce = dpopNonce; 125 + res = await makeRequest(dpopNonce); 126 + } 127 } 128 129 if (!res.ok) { 130 const err = await res.json().catch(() => ({ 131 error: "Unknown", 132 message: res.statusText, 133 })); 134 + const error = new Error(err.message || err.error || res.statusText) as Error & { 135 status: number; 136 error: string; 137 }; 138 error.status = res.status; 139 error.error = err.error; 140 throw error; 141 + } 142 + 143 + const newNonce = res.headers.get("DPoP-Nonce"); 144 + if (newNonce) { 145 + this.dpopNonce = newNonce; 146 } 147 148 const responseContentType = res.headers.get("content-type") ?? ""; ··· 272 error: "Unknown", 273 message: res.statusText, 274 })); 275 + const error = new Error(err.message || err.error || res.statusText) as Error & { 276 status: number; 277 error: string; 278 }; ··· 477 httpMethod: "POST", 478 }); 479 } 480 + 481 + async createPasskeyAccount( 482 + params: CreatePasskeyAccountParams, 483 + serviceToken?: string, 484 + ): Promise<PasskeyAccountSetup> { 485 + const headers: Record<string, string> = { 486 + "Content-Type": "application/json", 487 + }; 488 + if (serviceToken) { 489 + headers["Authorization"] = `Bearer ${serviceToken}`; 490 + } 491 + 492 + const res = await fetch( 493 + `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 494 + { 495 + method: "POST", 496 + headers, 497 + body: JSON.stringify(params), 498 + }, 499 + ); 500 + 501 + if (!res.ok) { 502 + const err = await res.json().catch(() => ({ 503 + error: "Unknown", 504 + message: res.statusText, 505 + })); 506 + const error = new Error(err.message || err.error || res.statusText) as Error & { 507 + status: number; 508 + error: string; 509 + }; 510 + error.status = res.status; 511 + error.error = err.error; 512 + throw error; 513 + } 514 + 515 + return res.json(); 516 + } 517 + 518 + async startPasskeyRegistrationForSetup( 519 + did: string, 520 + setupToken: string, 521 + friendlyName?: string, 522 + ): Promise<StartPasskeyRegistrationResponse> { 523 + return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 524 + httpMethod: "POST", 525 + body: { did, setupToken, friendlyName }, 526 + }); 527 + } 528 + 529 + async completePasskeySetup( 530 + did: string, 531 + setupToken: string, 532 + passkeyCredential: unknown, 533 + passkeyFriendlyName?: string, 534 + ): Promise<CompletePasskeySetupResponse> { 535 + return this.xrpc("com.tranquil.account.completePasskeySetup", { 536 + httpMethod: "POST", 537 + body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 538 + }); 539 + } 540 + } 541 + 542 + export async function getOAuthServerMetadata( 543 + pdsUrl: string, 544 + ): Promise<OAuthServerMetadata | null> { 545 + try { 546 + const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`; 547 + const directRes = await fetch(directUrl); 548 + if (directRes.ok) { 549 + return directRes.json(); 550 + } 551 + 552 + const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`; 553 + const protectedRes = await fetch(protectedResourceUrl); 554 + if (!protectedRes.ok) { 555 + return null; 556 + } 557 + 558 + const protectedMetadata = await protectedRes.json(); 559 + const authServers = protectedMetadata.authorization_servers; 560 + if (!authServers || authServers.length === 0) { 561 + return null; 562 + } 563 + 564 + const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`; 565 + const authServerRes = await fetch(authServerUrl); 566 + if (!authServerRes.ok) { 567 + return null; 568 + } 569 + 570 + return authServerRes.json(); 571 + } catch { 572 + return null; 573 + } 574 + } 575 + 576 + export async function generatePKCE(): Promise<{ 577 + codeVerifier: string; 578 + codeChallenge: string; 579 + }> { 580 + const array = new Uint8Array(32); 581 + crypto.getRandomValues(array); 582 + const codeVerifier = base64UrlEncode(array); 583 + 584 + const encoder = new TextEncoder(); 585 + const data = encoder.encode(codeVerifier); 586 + const digest = await crypto.subtle.digest("SHA-256", data); 587 + const codeChallenge = base64UrlEncode(new Uint8Array(digest)); 588 + 589 + return { codeVerifier, codeChallenge }; 590 + } 591 + 592 + export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 593 + const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; 594 + let binary = ""; 595 + for (let i = 0; i < bytes.length; i++) { 596 + binary += String.fromCharCode(bytes[i]); 597 + } 598 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 599 + } 600 + 601 + export function base64UrlDecode(base64url: string): Uint8Array { 602 + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); 603 + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); 604 + const binary = atob(padded); 605 + const bytes = new Uint8Array(binary.length); 606 + for (let i = 0; i < binary.length; i++) { 607 + bytes[i] = binary.charCodeAt(i); 608 + } 609 + return bytes; 610 + } 611 + 612 + export function prepareWebAuthnCreationOptions( 613 + options: { publicKey: Record<string, unknown> }, 614 + ): PublicKeyCredentialCreationOptions { 615 + const pk = options.publicKey; 616 + return { 617 + ...pk, 618 + challenge: base64UrlDecode(pk.challenge as string), 619 + user: { 620 + ...(pk.user as Record<string, unknown>), 621 + id: base64UrlDecode((pk.user as Record<string, unknown>).id as string), 622 + }, 623 + excludeCredentials: 624 + ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map( 625 + (cred) => ({ 626 + ...cred, 627 + id: base64UrlDecode(cred.id as string), 628 + }), 629 + ), 630 + } as PublicKeyCredentialCreationOptions; 631 + } 632 + 633 + async function computeAccessTokenHash(accessToken: string): Promise<string> { 634 + const encoder = new TextEncoder(); 635 + const data = encoder.encode(accessToken); 636 + const hash = await crypto.subtle.digest("SHA-256", data); 637 + return base64UrlEncode(new Uint8Array(hash)); 638 + } 639 + 640 + export function generateOAuthState(): string { 641 + const array = new Uint8Array(16); 642 + crypto.getRandomValues(array); 643 + return base64UrlEncode(array); 644 + } 645 + 646 + export function buildOAuthAuthorizationUrl( 647 + metadata: OAuthServerMetadata, 648 + params: { 649 + clientId: string; 650 + redirectUri: string; 651 + codeChallenge: string; 652 + state: string; 653 + scope?: string; 654 + dpopJkt?: string; 655 + loginHint?: string; 656 + }, 657 + ): string { 658 + const url = new URL(metadata.authorization_endpoint); 659 + url.searchParams.set("response_type", "code"); 660 + url.searchParams.set("client_id", params.clientId); 661 + url.searchParams.set("redirect_uri", params.redirectUri); 662 + url.searchParams.set("code_challenge", params.codeChallenge); 663 + url.searchParams.set("code_challenge_method", "S256"); 664 + url.searchParams.set("state", params.state); 665 + url.searchParams.set("scope", params.scope ?? "atproto"); 666 + if (params.dpopJkt) { 667 + url.searchParams.set("dpop_jkt", params.dpopJkt); 668 + } 669 + if (params.loginHint) { 670 + url.searchParams.set("login_hint", params.loginHint); 671 + } 672 + return url.toString(); 673 + } 674 + 675 + export async function exchangeOAuthCode( 676 + metadata: OAuthServerMetadata, 677 + params: { 678 + code: string; 679 + codeVerifier: string; 680 + clientId: string; 681 + redirectUri: string; 682 + dpopKeyPair?: DPoPKeyPair; 683 + }, 684 + ): Promise<OAuthTokenResponse> { 685 + const body = new URLSearchParams({ 686 + grant_type: "authorization_code", 687 + code: params.code, 688 + code_verifier: params.codeVerifier, 689 + client_id: params.clientId, 690 + redirect_uri: params.redirectUri, 691 + }); 692 + 693 + const makeRequest = async (nonce?: string): Promise<Response> => { 694 + const headers: Record<string, string> = { 695 + "Content-Type": "application/x-www-form-urlencoded", 696 + }; 697 + 698 + if (params.dpopKeyPair) { 699 + const dpopProof = await createDPoPProof( 700 + params.dpopKeyPair, 701 + "POST", 702 + metadata.token_endpoint, 703 + nonce, 704 + ); 705 + headers["DPoP"] = dpopProof; 706 + } 707 + 708 + return fetch(metadata.token_endpoint, { 709 + method: "POST", 710 + headers, 711 + body: body.toString(), 712 + }); 713 + }; 714 + 715 + let res = await makeRequest(); 716 + 717 + if (!res.ok) { 718 + const err = await res.json().catch(() => ({ 719 + error: "token_error", 720 + error_description: res.statusText, 721 + })); 722 + 723 + if (err.error === "use_dpop_nonce" && params.dpopKeyPair) { 724 + const dpopNonce = res.headers.get("DPoP-Nonce"); 725 + if (dpopNonce) { 726 + res = await makeRequest(dpopNonce); 727 + if (!res.ok) { 728 + const retryErr = await res.json().catch(() => ({ 729 + error: "token_error", 730 + error_description: res.statusText, 731 + })); 732 + throw new Error( 733 + retryErr.error_description || retryErr.error || "Token exchange failed", 734 + ); 735 + } 736 + return res.json(); 737 + } 738 + } 739 + 740 + throw new Error(err.error_description || err.error || "Token exchange failed"); 741 + } 742 + 743 + return res.json(); 744 } 745 746 export async function resolveDidDocument(did: string): Promise<DidDocument> { ··· 771 export async function resolvePdsUrl( 772 handleOrDid: string, 773 ): Promise<{ did: string; pdsUrl: string }> { 774 + let did: string | undefined; 775 776 if (handleOrDid.startsWith("did:")) { 777 did = handleOrDid; ··· 820 } 821 } 822 823 + if (!did) { 824 + throw new Error("Could not resolve DID"); 825 + } 826 + 827 const didDoc = await resolveDidDocument(did); 828 829 const pdsService = didDoc.service?.find( ··· 838 } 839 840 export function createLocalClient(): AtprotoClient { 841 + return new AtprotoClient(globalThis.location.origin); 842 + } 843 + 844 + export function getMigrationOAuthClientId(): string { 845 + return `${globalThis.location.origin}/oauth/client-metadata.json`; 846 + } 847 + 848 + export function getMigrationOAuthRedirectUri(): string { 849 + return `${globalThis.location.origin}/migrate`; 850 + } 851 + 852 + export interface DPoPKeyPair { 853 + privateKey: CryptoKey; 854 + publicKey: CryptoKey; 855 + jwk: JsonWebKey; 856 + thumbprint: string; 857 + } 858 + 859 + const DPOP_KEY_STORAGE = "migration_dpop_key"; 860 + const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; 861 + 862 + export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 863 + const keyPair = await crypto.subtle.generateKey( 864 + { 865 + name: "ECDSA", 866 + namedCurve: "P-256", 867 + }, 868 + true, 869 + ["sign", "verify"], 870 + ); 871 + 872 + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 873 + const thumbprint = await computeJwkThumbprint(publicJwk); 874 + 875 + return { 876 + privateKey: keyPair.privateKey, 877 + publicKey: keyPair.publicKey, 878 + jwk: publicJwk, 879 + thumbprint, 880 + }; 881 + } 882 + 883 + async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 884 + const thumbprintInput = JSON.stringify({ 885 + crv: jwk.crv, 886 + kty: jwk.kty, 887 + x: jwk.x, 888 + y: jwk.y, 889 + }); 890 + 891 + const encoder = new TextEncoder(); 892 + const data = encoder.encode(thumbprintInput); 893 + const hash = await crypto.subtle.digest("SHA-256", data); 894 + return base64UrlEncode(new Uint8Array(hash)); 895 + } 896 + 897 + export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> { 898 + const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); 899 + const stored = { 900 + privateJwk, 901 + publicJwk: keyPair.jwk, 902 + thumbprint: keyPair.thumbprint, 903 + createdAt: Date.now(), 904 + }; 905 + localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 906 + } 907 + 908 + export async function loadDPoPKey(): Promise<DPoPKeyPair | null> { 909 + const stored = localStorage.getItem(DPOP_KEY_STORAGE); 910 + if (!stored) return null; 911 + 912 + try { 913 + const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored); 914 + 915 + if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) { 916 + localStorage.removeItem(DPOP_KEY_STORAGE); 917 + return null; 918 + } 919 + 920 + const privateKey = await crypto.subtle.importKey( 921 + "jwk", 922 + privateJwk, 923 + { name: "ECDSA", namedCurve: "P-256" }, 924 + true, 925 + ["sign"], 926 + ); 927 + 928 + const publicKey = await crypto.subtle.importKey( 929 + "jwk", 930 + publicJwk, 931 + { name: "ECDSA", namedCurve: "P-256" }, 932 + true, 933 + ["verify"], 934 + ); 935 + 936 + return { privateKey, publicKey, jwk: publicJwk, thumbprint }; 937 + } catch { 938 + localStorage.removeItem(DPOP_KEY_STORAGE); 939 + return null; 940 + } 941 + } 942 + 943 + export function clearDPoPKey(): void { 944 + localStorage.removeItem(DPOP_KEY_STORAGE); 945 + } 946 + 947 + export async function createDPoPProof( 948 + keyPair: DPoPKeyPair, 949 + httpMethod: string, 950 + httpUri: string, 951 + nonce?: string, 952 + accessTokenHash?: string, 953 + ): Promise<string> { 954 + const header = { 955 + typ: "dpop+jwt", 956 + alg: "ES256", 957 + jwk: { 958 + kty: keyPair.jwk.kty, 959 + crv: keyPair.jwk.crv, 960 + x: keyPair.jwk.x, 961 + y: keyPair.jwk.y, 962 + }, 963 + }; 964 + 965 + const payload: Record<string, unknown> = { 966 + jti: crypto.randomUUID(), 967 + htm: httpMethod, 968 + htu: httpUri, 969 + iat: Math.floor(Date.now() / 1000), 970 + }; 971 + 972 + if (nonce) { 973 + payload.nonce = nonce; 974 + } 975 + 976 + if (accessTokenHash) { 977 + payload.ath = accessTokenHash; 978 + } 979 + 980 + const headerB64 = base64UrlEncode( 981 + new TextEncoder().encode(JSON.stringify(header)), 982 + ); 983 + const payloadB64 = base64UrlEncode( 984 + new TextEncoder().encode(JSON.stringify(payload)), 985 + ); 986 + 987 + const signingInput = `${headerB64}.${payloadB64}`; 988 + const signature = await crypto.subtle.sign( 989 + { name: "ECDSA", hash: "SHA-256" }, 990 + keyPair.privateKey, 991 + new TextEncoder().encode(signingInput), 992 + ); 993 + 994 + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); 995 + return `${headerB64}.${payloadB64}.${signatureB64}`; 996 }
+350 -78
frontend/src/lib/migration/flow.svelte.ts
··· 2 InboundMigrationState, 3 InboundStep, 4 MigrationProgress, 5 OutboundMigrationState, 6 OutboundStep, 7 ServerDescription, 8 StoredMigrationState, 9 } from "./types"; 10 import { 11 AtprotoClient, 12 createLocalClient, 13 resolvePdsUrl, 14 } from "./atproto-client"; 15 import { 16 clearMigrationState, 17 - loadMigrationState, 18 saveMigrationState, 19 updateProgress, 20 updateStep, ··· 63 plcToken: "", 64 progress: createInitialProgress(), 65 error: null, 66 - requires2FA: false, 67 - twoFactorCode: "", 68 targetVerificationMethod: null, 69 }); 70 71 let sourceClient: AtprotoClient | null = null; 72 let localClient: AtprotoClient | null = null; 73 let localServerInfo: ServerDescription | null = null; 74 75 function setStep(step: InboundStep) { 76 state.step = step; ··· 111 } 112 } 113 114 - async function loginToSource( 115 - handle: string, 116 - password: string, 117 - twoFactorCode?: string, 118 - ): Promise<void> { 119 - migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode }); 120 121 if (!state.sourcePdsUrl) { 122 await resolveSourcePds(handle); 123 } 124 125 - if (!sourceClient) { 126 - sourceClient = new AtprotoClient(state.sourcePdsUrl); 127 } 128 129 try { 130 - migrationLog("loginToSource: Calling createSession on OLD PDS", { 131 - pdsUrl: state.sourcePdsUrl, 132 }); 133 - const session = await sourceClient.login(handle, password, twoFactorCode); 134 - migrationLog("loginToSource SUCCESS", { 135 - did: session.did, 136 - handle: session.handle, 137 - pdsUrl: state.sourcePdsUrl, 138 - }); 139 - state.sourceAccessToken = session.accessJwt; 140 - state.sourceRefreshToken = session.refreshJwt; 141 - state.sourceDid = session.did; 142 - state.sourceHandle = session.handle; 143 - state.requires2FA = false; 144 - saveMigrationState(state); 145 - } catch (e) { 146 - const err = e as Error & { error?: string }; 147 - migrationLog("loginToSource FAILED", { 148 - error: err.message, 149 - errorCode: err.error, 150 - }); 151 - if (err.error === "AuthFactorTokenRequired") { 152 - state.requires2FA = true; 153 - throw new Error( 154 - "Two-factor authentication required. Please enter the code sent to your email.", 155 - ); 156 } 157 - throw e; 158 } 159 } 160 161 async function checkHandleAvailability(handle: string): Promise<boolean> { ··· 180 await localClient.loginDeactivated(email, password); 181 } 182 183 async function startMigration(): Promise<void> { 184 migrationLog("startMigration START", { 185 sourceDid: state.sourceDid, 186 sourceHandle: state.sourceHandle, 187 targetHandle: state.targetHandle, 188 sourcePdsUrl: state.sourcePdsUrl, 189 }); 190 191 if (!sourceClient || !state.sourceAccessToken) { 192 - migrationLog("startMigration ERROR: Not logged in to source PDS"); 193 - throw new Error("Not logged in to source PDS"); 194 } 195 196 if (!localClient) { ··· 198 } 199 200 setStep("migrating"); 201 - setProgress({ currentOperation: "Getting service auth token..." }); 202 203 try { 204 migrationLog("startMigration: Loading local server info"); 205 const serverInfo = await loadLocalServerInfo(); 206 migrationLog("startMigration: Got server info", { 207 serverDid: serverInfo.did, 208 }); 209 210 - migrationLog("startMigration: Getting service auth token from OLD PDS"); 211 const { token } = await sourceClient.getServiceAuth( 212 serverInfo.did, 213 "com.atproto.server.createAccount", ··· 217 218 setProgress({ currentOperation: "Creating account on new PDS..." }); 219 220 - const accountParams = { 221 - did: state.sourceDid, 222 - handle: state.targetHandle, 223 - email: state.targetEmail, 224 - password: state.targetPassword, 225 - inviteCode: state.inviteCode || undefined, 226 - }; 227 228 - migrationLog("startMigration: Creating account on NEW PDS", { 229 - did: accountParams.did, 230 - handle: accountParams.handle, 231 - }); 232 - const session = await localClient.createAccount(accountParams, token); 233 - migrationLog("startMigration: Account created on NEW PDS", { 234 - did: session.did, 235 - }); 236 - localClient.setAccessToken(session.accessJwt); 237 238 setProgress({ currentOperation: "Exporting repository..." }); 239 - migrationLog("startMigration: Exporting repo from OLD PDS"); 240 const exportStart = Date.now(); 241 const car = await sourceClient.getRepo(state.sourceDid); 242 migrationLog("startMigration: Repo exported", { ··· 320 await localClient.uploadBlob(blobData, "application/octet-stream"); 321 migrated++; 322 setProgress({ blobsMigrated: migrated }); 323 - } catch (e) { 324 state.progress.blobsFailed.push(blob.cid); 325 } 326 } ··· 336 const prefs = await sourceClient.getPreferences(); 337 await localClient.putPreferences(prefs); 338 setProgress({ prefsMigrated: true }); 339 - } catch { 340 - } 341 } 342 343 async function submitEmailVerifyToken( ··· 355 await localClient.verifyToken(token, state.targetEmail); 356 357 if (!sourceClient) { 358 - setStep("source-login"); 359 setError( 360 "Email verified! Please log in to your old account again to complete the migration.", 361 ); 362 return; 363 } 364 365 if (localPassword) { 366 setProgress({ currentOperation: "Authenticating to new PDS..." }); 367 await localClient.loginDeactivated(state.targetEmail, localPassword); ··· 403 if (checkingEmailVerification) return false; 404 if (!sourceClient || !localClient) return false; 405 406 checkingEmailVerification = true; 407 try { 408 await localClient.loginDeactivated( ··· 460 services: credentials.services, 461 }); 462 463 - migrationLog("Step 2: Signing PLC operation on OLD PDS", { 464 sourcePdsUrl: state.sourcePdsUrl, 465 }); 466 const signStart = Date.now(); ··· 497 setProgress({ activated: true }); 498 499 setProgress({ currentOperation: "Deactivating old account..." }); 500 - migrationLog("Step 5: Deactivating account on OLD PDS", { 501 sourcePdsUrl: state.sourcePdsUrl, 502 }); 503 const deactivateStart = Date.now(); 504 try { 505 await sourceClient.deactivateAccount(); 506 - migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", { 507 durationMs: Date.now() - deactivateStart, 508 success: true, 509 }); ··· 513 error?: string; 514 status?: number; 515 }; 516 - migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", { 517 durationMs: Date.now() - deactivateStart, 518 error: err.message, 519 errorCode: err.error, ··· 581 setProgress({ activated: true }); 582 583 setProgress({ currentOperation: "Deactivating old account..." }); 584 - migrationLog("Deactivating account on OLD PDS"); 585 const deactivateStart = Date.now(); 586 try { 587 await sourceClient.deactivateAccount(); 588 - migrationLog("Account deactivated on OLD PDS", { 589 durationMs: Date.now() - deactivateStart, 590 }); 591 setProgress({ deactivated: true }); 592 } catch (deactivateErr) { 593 const err = deactivateErr as Error & { error?: string }; 594 - migrationLog("Could not deactivate on OLD PDS", { error: err.message }); 595 } 596 597 migrationLog("completeDidWebMigration SUCCESS"); ··· 607 } 608 } 609 610 function reset(): void { 611 state = { 612 direction: "inbound", ··· 625 plcToken: "", 626 progress: createInitialProgress(), 627 error: null, 628 - requires2FA: false, 629 - twoFactorCode: "", 630 targetVerificationMethod: null, 631 }; 632 sourceClient = null; 633 clearMigrationState(); 634 } 635 636 async function resumeFromState(stored: StoredMigrationState): Promise<void> { ··· 641 state.sourceHandle = stored.sourceHandle; 642 state.targetHandle = stored.targetHandle; 643 state.targetEmail = stored.targetEmail; 644 state.progress = { 645 ...createInitialProgress(), 646 ...stored.progress, 647 }; 648 649 - state.step = "source-login"; 650 } 651 652 function getLocalSession(): ··· 666 get state() { 667 return state; 668 }, 669 setStep, 670 setError, 671 loadLocalServerInfo, 672 - loginToSource, 673 authenticateToLocal, 674 checkHandleAvailability, 675 startMigration, ··· 680 submitPlcToken, 681 resendPlcToken, 682 completeDidWebMigration, 683 reset, 684 resumeFromState, 685 getLocalSession, ··· 856 await targetClient.uploadBlob(blobData, "application/octet-stream"); 857 migrated++; 858 setProgress({ blobsMigrated: migrated }); 859 - } catch (e) { 860 state.progress.blobsFailed.push(blob.cid); 861 } 862 } ··· 872 const prefs = await localClient.getPreferences(); 873 await targetClient.putPreferences(prefs); 874 setProgress({ prefsMigrated: true }); 875 - } catch { 876 - } 877 } 878 879 async function submitPlcToken(token: string): Promise<void> { ··· 908 try { 909 await localClient.deactivateAccount(state.targetPdsUrl); 910 setProgress({ deactivated: true }); 911 - } catch { 912 - } 913 914 setStep("success"); 915 clearMigrationState();
··· 2 InboundMigrationState, 3 InboundStep, 4 MigrationProgress, 5 + OAuthServerMetadata, 6 OutboundMigrationState, 7 OutboundStep, 8 + PasskeyAccountSetup, 9 ServerDescription, 10 StoredMigrationState, 11 } from "./types"; 12 import { 13 AtprotoClient, 14 + buildOAuthAuthorizationUrl, 15 + clearDPoPKey, 16 createLocalClient, 17 + exchangeOAuthCode, 18 + generateDPoPKeyPair, 19 + generateOAuthState, 20 + generatePKCE, 21 + getMigrationOAuthClientId, 22 + getMigrationOAuthRedirectUri, 23 + getOAuthServerMetadata, 24 + loadDPoPKey, 25 resolvePdsUrl, 26 + saveDPoPKey, 27 } from "./atproto-client"; 28 import { 29 clearMigrationState, 30 saveMigrationState, 31 updateProgress, 32 updateStep, ··· 75 plcToken: "", 76 progress: createInitialProgress(), 77 error: null, 78 targetVerificationMethod: null, 79 + authMethod: "password", 80 + passkeySetupToken: null, 81 + oauthCodeVerifier: null, 82 + generatedAppPassword: null, 83 + generatedAppPasswordName: null, 84 }); 85 86 let sourceClient: AtprotoClient | null = null; 87 let localClient: AtprotoClient | null = null; 88 let localServerInfo: ServerDescription | null = null; 89 + let sourceOAuthMetadata: OAuthServerMetadata | null = null; 90 91 function setStep(step: InboundStep) { 92 state.step = step; ··· 127 } 128 } 129 130 + async function initiateOAuthLogin(handle: string): Promise<void> { 131 + migrationLog("initiateOAuthLogin START", { handle }); 132 133 if (!state.sourcePdsUrl) { 134 await resolveSourcePds(handle); 135 } 136 137 + const metadata = await getOAuthServerMetadata(state.sourcePdsUrl); 138 + if (!metadata) { 139 + throw new Error( 140 + "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", 141 + ); 142 + } 143 + sourceOAuthMetadata = metadata; 144 + 145 + const { codeVerifier, codeChallenge } = await generatePKCE(); 146 + const oauthState = generateOAuthState(); 147 + 148 + const dpopKeyPair = await generateDPoPKeyPair(); 149 + await saveDPoPKey(dpopKeyPair); 150 + 151 + localStorage.setItem("migration_oauth_state", oauthState); 152 + localStorage.setItem("migration_oauth_code_verifier", codeVerifier); 153 + localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl); 154 + localStorage.setItem("migration_source_did", state.sourceDid); 155 + localStorage.setItem("migration_source_handle", state.sourceHandle); 156 + localStorage.setItem("migration_oauth_issuer", metadata.issuer); 157 + 158 + const authUrl = buildOAuthAuthorizationUrl(metadata, { 159 + clientId: getMigrationOAuthClientId(), 160 + redirectUri: getMigrationOAuthRedirectUri(), 161 + codeChallenge, 162 + state: oauthState, 163 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 164 + dpopJkt: dpopKeyPair.thumbprint, 165 + loginHint: state.sourceHandle, 166 + }); 167 + 168 + migrationLog("initiateOAuthLogin: Redirecting to authorization", { 169 + sourcePdsUrl: state.sourcePdsUrl, 170 + authEndpoint: metadata.authorization_endpoint, 171 + dpopJkt: dpopKeyPair.thumbprint, 172 + }); 173 + 174 + state.oauthCodeVerifier = codeVerifier; 175 + saveMigrationState(state); 176 + 177 + globalThis.location.href = authUrl; 178 + } 179 + 180 + function cleanupOAuthSessionData(): void { 181 + localStorage.removeItem("migration_oauth_state"); 182 + localStorage.removeItem("migration_oauth_code_verifier"); 183 + localStorage.removeItem("migration_source_pds_url"); 184 + localStorage.removeItem("migration_source_did"); 185 + localStorage.removeItem("migration_source_handle"); 186 + localStorage.removeItem("migration_oauth_issuer"); 187 + } 188 + 189 + async function handleOAuthCallback( 190 + code: string, 191 + returnedState: string, 192 + ): Promise<void> { 193 + migrationLog("handleOAuthCallback START"); 194 + 195 + const savedState = localStorage.getItem("migration_oauth_state"); 196 + const codeVerifier = localStorage.getItem("migration_oauth_code_verifier"); 197 + const sourcePdsUrl = localStorage.getItem("migration_source_pds_url"); 198 + const sourceDid = localStorage.getItem("migration_source_did"); 199 + const sourceHandle = localStorage.getItem("migration_source_handle"); 200 + const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); 201 + 202 + if (returnedState !== savedState) { 203 + cleanupOAuthSessionData(); 204 + throw new Error("OAuth state mismatch - possible CSRF attack"); 205 } 206 207 + if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) { 208 + cleanupOAuthSessionData(); 209 + throw new Error("Missing OAuth session data"); 210 + } 211 + 212 + const dpopKeyPair = await loadDPoPKey(); 213 + if (!dpopKeyPair) { 214 + cleanupOAuthSessionData(); 215 + throw new Error("Missing DPoP key - please restart the migration"); 216 + } 217 + 218 + state.sourcePdsUrl = sourcePdsUrl; 219 + state.sourceDid = sourceDid; 220 + state.sourceHandle = sourceHandle; 221 + sourceClient = new AtprotoClient(sourcePdsUrl); 222 + 223 + let metadata = await getOAuthServerMetadata(sourcePdsUrl); 224 + if (!metadata && oauthIssuer) { 225 + metadata = await getOAuthServerMetadata(oauthIssuer); 226 + } 227 + if (!metadata) { 228 + cleanupOAuthSessionData(); 229 + throw new Error("Could not fetch OAuth server metadata"); 230 + } 231 + sourceOAuthMetadata = metadata; 232 + 233 + migrationLog("handleOAuthCallback: Exchanging code for tokens"); 234 + 235 + let tokenResponse; 236 try { 237 + tokenResponse = await exchangeOAuthCode(metadata, { 238 + code, 239 + codeVerifier, 240 + clientId: getMigrationOAuthClientId(), 241 + redirectUri: getMigrationOAuthRedirectUri(), 242 + dpopKeyPair, 243 }); 244 + } catch (err) { 245 + cleanupOAuthSessionData(); 246 + throw err; 247 + } 248 + 249 + migrationLog("handleOAuthCallback: Got access token"); 250 + 251 + state.sourceAccessToken = tokenResponse.access_token; 252 + state.sourceRefreshToken = tokenResponse.refresh_token ?? null; 253 + sourceClient.setAccessToken(tokenResponse.access_token); 254 + sourceClient.setDPoPKeyPair(dpopKeyPair); 255 + 256 + cleanupOAuthSessionData(); 257 + 258 + if (state.needsReauth && state.resumeToStep) { 259 + const targetStep = state.resumeToStep; 260 + state.needsReauth = false; 261 + state.resumeToStep = undefined; 262 + 263 + const postEmailSteps = [ 264 + "plc-token", 265 + "did-web-update", 266 + "finalizing", 267 + "app-password", 268 + ]; 269 + 270 + if (postEmailSteps.includes(targetStep)) { 271 + if (state.authMethod === "passkey" && state.passkeySetupToken) { 272 + localClient = createLocalClient(); 273 + setStep("passkey-setup"); 274 + migrationLog("handleOAuthCallback: Resuming passkey flow at passkey-setup"); 275 + } else { 276 + setStep("email-verify"); 277 + migrationLog("handleOAuthCallback: Resuming at email-verify for re-auth"); 278 + } 279 + } else { 280 + setStep(targetStep); 281 } 282 + } else { 283 + setStep("choose-handle"); 284 } 285 + saveMigrationState(state); 286 } 287 288 async function checkHandleAvailability(handle: string): Promise<boolean> { ··· 307 await localClient.loginDeactivated(email, password); 308 } 309 310 + let passkeySetup: PasskeyAccountSetup | null = null; 311 + 312 async function startMigration(): Promise<void> { 313 migrationLog("startMigration START", { 314 sourceDid: state.sourceDid, 315 sourceHandle: state.sourceHandle, 316 targetHandle: state.targetHandle, 317 sourcePdsUrl: state.sourcePdsUrl, 318 + authMethod: state.authMethod, 319 }); 320 321 if (!sourceClient || !state.sourceAccessToken) { 322 + migrationLog("startMigration ERROR: Not authenticated to source PDS"); 323 + throw new Error("Not authenticated to source PDS"); 324 } 325 326 if (!localClient) { ··· 328 } 329 330 setStep("migrating"); 331 332 try { 333 + setProgress({ currentOperation: "Getting service auth token..." }); 334 migrationLog("startMigration: Loading local server info"); 335 const serverInfo = await loadLocalServerInfo(); 336 migrationLog("startMigration: Got server info", { 337 serverDid: serverInfo.did, 338 }); 339 340 + migrationLog("startMigration: Getting service auth token from source PDS"); 341 const { token } = await sourceClient.getServiceAuth( 342 serverInfo.did, 343 "com.atproto.server.createAccount", ··· 347 348 setProgress({ currentOperation: "Creating account on new PDS..." }); 349 350 + if (state.authMethod === "passkey") { 351 + const passkeyParams = { 352 + did: state.sourceDid, 353 + handle: state.targetHandle, 354 + email: state.targetEmail, 355 + inviteCode: state.inviteCode || undefined, 356 + }; 357 358 + migrationLog("startMigration: Creating passkey account on NEW PDS", { 359 + did: passkeyParams.did, 360 + handle: passkeyParams.handle, 361 + inviteCode: passkeyParams.inviteCode, 362 + stateInviteCode: state.inviteCode, 363 + }); 364 + passkeySetup = await localClient.createPasskeyAccount(passkeyParams, token); 365 + migrationLog("startMigration: Passkey account created on NEW PDS", { 366 + did: passkeySetup.did, 367 + hasAccessJwt: !!passkeySetup.accessJwt, 368 + }); 369 + state.passkeySetupToken = passkeySetup.setupToken; 370 + if (passkeySetup.accessJwt) { 371 + localClient.setAccessToken(passkeySetup.accessJwt); 372 + } 373 + } else { 374 + const accountParams = { 375 + did: state.sourceDid, 376 + handle: state.targetHandle, 377 + email: state.targetEmail, 378 + password: state.targetPassword, 379 + inviteCode: state.inviteCode || undefined, 380 + }; 381 + 382 + migrationLog("startMigration: Creating account on NEW PDS", { 383 + did: accountParams.did, 384 + handle: accountParams.handle, 385 + }); 386 + const session = await localClient.createAccount(accountParams, token); 387 + migrationLog("startMigration: Account created on NEW PDS", { 388 + did: session.did, 389 + }); 390 + localClient.setAccessToken(session.accessJwt); 391 + } 392 393 setProgress({ currentOperation: "Exporting repository..." }); 394 + migrationLog("startMigration: Exporting repo from source PDS"); 395 const exportStart = Date.now(); 396 const car = await sourceClient.getRepo(state.sourceDid); 397 migrationLog("startMigration: Repo exported", { ··· 475 await localClient.uploadBlob(blobData, "application/octet-stream"); 476 migrated++; 477 setProgress({ blobsMigrated: migrated }); 478 + } catch { 479 state.progress.blobsFailed.push(blob.cid); 480 } 481 } ··· 491 const prefs = await sourceClient.getPreferences(); 492 await localClient.putPreferences(prefs); 493 setProgress({ prefsMigrated: true }); 494 + } catch { /* optional, best-effort */ } 495 } 496 497 async function submitEmailVerifyToken( ··· 509 await localClient.verifyToken(token, state.targetEmail); 510 511 if (!sourceClient) { 512 + setStep("source-handle"); 513 setError( 514 "Email verified! Please log in to your old account again to complete the migration.", 515 ); 516 return; 517 } 518 519 + if (state.authMethod === "passkey") { 520 + migrationLog( 521 + "submitEmailVerifyToken: Email verified, proceeding to passkey setup", 522 + ); 523 + setStep("passkey-setup"); 524 + return; 525 + } 526 + 527 if (localPassword) { 528 setProgress({ currentOperation: "Authenticating to new PDS..." }); 529 await localClient.loginDeactivated(state.targetEmail, localPassword); ··· 565 if (checkingEmailVerification) return false; 566 if (!sourceClient || !localClient) return false; 567 568 + if (state.authMethod === "passkey") { 569 + return false; 570 + } 571 + 572 checkingEmailVerification = true; 573 try { 574 await localClient.loginDeactivated( ··· 626 services: credentials.services, 627 }); 628 629 + migrationLog("Step 2: Signing PLC operation on source PDS", { 630 sourcePdsUrl: state.sourcePdsUrl, 631 }); 632 const signStart = Date.now(); ··· 663 setProgress({ activated: true }); 664 665 setProgress({ currentOperation: "Deactivating old account..." }); 666 + migrationLog("Step 5: Deactivating account on source PDS", { 667 sourcePdsUrl: state.sourcePdsUrl, 668 }); 669 const deactivateStart = Date.now(); 670 try { 671 await sourceClient.deactivateAccount(); 672 + migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", { 673 durationMs: Date.now() - deactivateStart, 674 success: true, 675 }); ··· 679 error?: string; 680 status?: number; 681 }; 682 + migrationLog("Step 5 FAILED: Could not deactivate on source PDS", { 683 durationMs: Date.now() - deactivateStart, 684 error: err.message, 685 errorCode: err.error, ··· 747 setProgress({ activated: true }); 748 749 setProgress({ currentOperation: "Deactivating old account..." }); 750 + migrationLog("Deactivating account on source PDS"); 751 const deactivateStart = Date.now(); 752 try { 753 await sourceClient.deactivateAccount(); 754 + migrationLog("Account deactivated on source PDS", { 755 durationMs: Date.now() - deactivateStart, 756 }); 757 setProgress({ deactivated: true }); 758 } catch (deactivateErr) { 759 const err = deactivateErr as Error & { error?: string }; 760 + migrationLog("Could not deactivate on source PDS", { error: err.message }); 761 } 762 763 migrationLog("completeDidWebMigration SUCCESS"); ··· 773 } 774 } 775 776 + async function startPasskeyRegistration(): Promise<{ options: unknown }> { 777 + if (!localClient || !state.passkeySetupToken) { 778 + throw new Error("Not ready for passkey registration"); 779 + } 780 + 781 + migrationLog("startPasskeyRegistration START", { did: state.sourceDid }); 782 + const result = await localClient.startPasskeyRegistrationForSetup( 783 + state.sourceDid, 784 + state.passkeySetupToken, 785 + ); 786 + migrationLog("startPasskeyRegistration: Got WebAuthn options"); 787 + return result; 788 + } 789 + 790 + async function completePasskeyRegistration( 791 + credential: unknown, 792 + friendlyName?: string, 793 + ): Promise<void> { 794 + if (!localClient || !state.passkeySetupToken || !sourceClient) { 795 + throw new Error("Not ready for passkey registration"); 796 + } 797 + 798 + migrationLog("completePasskeyRegistration START", { did: state.sourceDid }); 799 + 800 + const result = await localClient.completePasskeySetup( 801 + state.sourceDid, 802 + state.passkeySetupToken, 803 + credential, 804 + friendlyName, 805 + ); 806 + migrationLog("completePasskeyRegistration: Passkey registered", { 807 + appPassword: "***", 808 + }); 809 + 810 + setProgress({ currentOperation: "Authenticating with app password..." }); 811 + await localClient.loginDeactivated(state.targetEmail, result.appPassword); 812 + migrationLog("completePasskeyRegistration: Authenticated to new PDS"); 813 + 814 + state.generatedAppPassword = result.appPassword; 815 + state.generatedAppPasswordName = result.appPasswordName; 816 + setStep("app-password"); 817 + } 818 + 819 + async function proceedFromAppPassword(): Promise<void> { 820 + if (!sourceClient || !localClient) { 821 + throw new Error("Clients not initialized"); 822 + } 823 + 824 + migrationLog("proceedFromAppPassword: Starting"); 825 + 826 + if (state.sourceDid.startsWith("did:web:")) { 827 + const credentials = await localClient.getRecommendedDidCredentials(); 828 + state.targetVerificationMethod = 829 + credentials.verificationMethods?.atproto || null; 830 + setStep("did-web-update"); 831 + } else { 832 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 833 + await sourceClient.requestPlcOperationSignature(); 834 + setStep("plc-token"); 835 + } 836 + } 837 + 838 function reset(): void { 839 state = { 840 direction: "inbound", ··· 853 plcToken: "", 854 progress: createInitialProgress(), 855 error: null, 856 targetVerificationMethod: null, 857 + authMethod: "password", 858 + passkeySetupToken: null, 859 + oauthCodeVerifier: null, 860 + generatedAppPassword: null, 861 + generatedAppPasswordName: null, 862 }; 863 sourceClient = null; 864 + passkeySetup = null; 865 + sourceOAuthMetadata = null; 866 clearMigrationState(); 867 + clearDPoPKey(); 868 } 869 870 async function resumeFromState(stored: StoredMigrationState): Promise<void> { ··· 875 state.sourceHandle = stored.sourceHandle; 876 state.targetHandle = stored.targetHandle; 877 state.targetEmail = stored.targetEmail; 878 + state.authMethod = stored.authMethod ?? "password"; 879 state.progress = { 880 ...createInitialProgress(), 881 ...stored.progress, 882 }; 883 884 + const stepsRequiringSourceAuth = [ 885 + "choose-handle", 886 + "review", 887 + "migrating", 888 + "email-verify", 889 + "plc-token", 890 + "did-web-update", 891 + "finalizing", 892 + "app-password", 893 + ]; 894 + 895 + if (stepsRequiringSourceAuth.includes(stored.step)) { 896 + state.step = "source-handle"; 897 + state.needsReauth = true; 898 + state.resumeToStep = stored.step as InboundMigrationState["step"]; 899 + migrationLog("resumeFromState: Requiring re-auth for step", { 900 + originalStep: stored.step, 901 + }); 902 + } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) { 903 + state.passkeySetupToken = stored.passkeySetupToken; 904 + localClient = createLocalClient(); 905 + state.step = "passkey-setup"; 906 + migrationLog("resumeFromState: Restored passkey-setup with token"); 907 + } else if (stored.step === "success") { 908 + state.step = "success"; 909 + } else if (stored.step === "error") { 910 + state.step = "source-handle"; 911 + state.needsReauth = true; 912 + migrationLog("resumeFromState: Error state, requiring re-auth"); 913 + } else { 914 + state.step = stored.step as InboundMigrationState["step"]; 915 + } 916 } 917 918 function getLocalSession(): ··· 932 get state() { 933 return state; 934 }, 935 + get passkeySetup() { 936 + return passkeySetup; 937 + }, 938 setStep, 939 setError, 940 loadLocalServerInfo, 941 + resolveSourcePds, 942 + initiateOAuthLogin, 943 + handleOAuthCallback, 944 authenticateToLocal, 945 checkHandleAvailability, 946 startMigration, ··· 951 submitPlcToken, 952 resendPlcToken, 953 completeDidWebMigration, 954 + startPasskeyRegistration, 955 + completePasskeyRegistration, 956 + proceedFromAppPassword, 957 reset, 958 resumeFromState, 959 getLocalSession, ··· 1130 await targetClient.uploadBlob(blobData, "application/octet-stream"); 1131 migrated++; 1132 setProgress({ blobsMigrated: migrated }); 1133 + } catch { 1134 state.progress.blobsFailed.push(blob.cid); 1135 } 1136 } ··· 1146 const prefs = await localClient.getPreferences(); 1147 await targetClient.putPreferences(prefs); 1148 setProgress({ prefsMigrated: true }); 1149 + } catch { /* optional, best-effort */ } 1150 } 1151 1152 async function submitPlcToken(token: string): Promise<void> { ··· 1181 try { 1182 await localClient.deactivateAccount(state.targetPdsUrl); 1183 setProgress({ deactivated: true }); 1184 + } catch { /* optional, best-effort */ } 1185 1186 setStep("success"); 1187 clearMigrationState();
+28 -19
frontend/src/lib/migration/storage.ts
··· 3 MigrationState, 4 StoredMigrationState, 5 } from "./types"; 6 7 const STORAGE_KEY = "tranquil_migration_state"; 8 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 15 startedAt: new Date().toISOString(), 16 sourcePdsUrl: state.direction === "inbound" 17 ? state.sourcePdsUrl 18 - : window.location.origin, 19 targetPdsUrl: state.direction === "inbound" 20 - ? window.location.origin 21 : state.targetPdsUrl, 22 sourceDid: state.direction === "inbound" ? state.sourceDid : "", 23 sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", 24 targetHandle: state.targetHandle, 25 targetEmail: state.targetEmail, 26 progress: { 27 repoExported: state.progress.repoExported, 28 repoImported: state.progress.repoImported, ··· 36 }; 37 38 try { 39 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); 40 - } catch { 41 - } 42 } 43 44 export function loadMigrationState(): StoredMigrationState | null { 45 try { 46 - const stored = sessionStorage.getItem(STORAGE_KEY); 47 if (!stored) return null; 48 49 const state = JSON.parse(stored) as StoredMigrationState; 50 51 - if (state.version !== 1) return null; 52 53 const startedAt = new Date(state.startedAt).getTime(); 54 if (Date.now() - startedAt > MAX_AGE_MS) { ··· 58 59 return state; 60 } catch { 61 return null; 62 } 63 } 64 65 export function clearMigrationState(): void { 66 try { 67 - sessionStorage.removeItem(STORAGE_KEY); 68 - } catch { 69 - } 70 } 71 72 export function hasPendingMigration(): boolean { ··· 79 targetHandle: string; 80 sourcePdsUrl: string; 81 targetPdsUrl: string; 82 progressSummary: string; 83 step: string; 84 } | null { ··· 102 targetHandle: state.targetHandle, 103 sourcePdsUrl: state.sourcePdsUrl, 104 targetPdsUrl: state.targetPdsUrl, 105 progressSummary: progressParts.length > 0 106 ? progressParts.join(", ") 107 : "just started", ··· 117 118 state.progress = { ...state.progress, ...updates }; 119 try { 120 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 121 - } catch { 122 - } 123 } 124 125 export function updateStep(step: string): void { ··· 128 129 state.step = step; 130 try { 131 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 132 - } catch { 133 - } 134 } 135 136 export function setError(error: string, step: string): void { ··· 140 state.lastError = error; 141 state.lastErrorStep = step; 142 try { 143 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 144 - } catch { 145 - } 146 }
··· 3 MigrationState, 4 StoredMigrationState, 5 } from "./types"; 6 + import { clearDPoPKey } from "./atproto-client"; 7 8 const STORAGE_KEY = "tranquil_migration_state"; 9 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 16 startedAt: new Date().toISOString(), 17 sourcePdsUrl: state.direction === "inbound" 18 ? state.sourcePdsUrl 19 + : globalThis.location.origin, 20 targetPdsUrl: state.direction === "inbound" 21 + ? globalThis.location.origin 22 : state.targetPdsUrl, 23 sourceDid: state.direction === "inbound" ? state.sourceDid : "", 24 sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", 25 targetHandle: state.targetHandle, 26 targetEmail: state.targetEmail, 27 + authMethod: state.direction === "inbound" ? state.authMethod : undefined, 28 + passkeySetupToken: state.direction === "inbound" 29 + ? state.passkeySetupToken ?? undefined 30 + : undefined, 31 progress: { 32 repoExported: state.progress.repoExported, 33 repoImported: state.progress.repoImported, ··· 41 }; 42 43 try { 44 + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); 45 + } catch { /* localStorage unavailable */ } 46 } 47 48 export function loadMigrationState(): StoredMigrationState | null { 49 try { 50 + const stored = localStorage.getItem(STORAGE_KEY); 51 if (!stored) return null; 52 53 const state = JSON.parse(stored) as StoredMigrationState; 54 55 + if (state.version !== 1) { 56 + clearMigrationState(); 57 + return null; 58 + } 59 60 const startedAt = new Date(state.startedAt).getTime(); 61 if (Date.now() - startedAt > MAX_AGE_MS) { ··· 65 66 return state; 67 } catch { 68 + clearMigrationState(); 69 return null; 70 } 71 } 72 73 export function clearMigrationState(): void { 74 try { 75 + localStorage.removeItem(STORAGE_KEY); 76 + clearDPoPKey(); 77 + } catch { /* localStorage unavailable */ } 78 } 79 80 export function hasPendingMigration(): boolean { ··· 87 targetHandle: string; 88 sourcePdsUrl: string; 89 targetPdsUrl: string; 90 + targetEmail: string; 91 + authMethod?: "password" | "passkey"; 92 progressSummary: string; 93 step: string; 94 } | null { ··· 112 targetHandle: state.targetHandle, 113 sourcePdsUrl: state.sourcePdsUrl, 114 targetPdsUrl: state.targetPdsUrl, 115 + targetEmail: state.targetEmail, 116 + authMethod: state.authMethod, 117 progressSummary: progressParts.length > 0 118 ? progressParts.join(", ") 119 : "just started", ··· 129 130 state.progress = { ...state.progress, ...updates }; 131 try { 132 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 133 + } catch { /* localStorage unavailable */ } 134 } 135 136 export function updateStep(step: string): void { ··· 139 140 state.step = step; 141 try { 142 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 143 + } catch { /* localStorage unavailable */ } 144 } 145 146 export function setError(error: string, step: string): void { ··· 150 state.lastError = error; 151 state.lastErrorStep = step; 152 try { 153 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 154 + } catch { /* localStorage unavailable */ } 155 }
+69 -3
frontend/src/lib/migration/types.ts
··· 1 export type InboundStep = 2 | "welcome" 3 - | "source-login" 4 | "choose-handle" 5 | "review" 6 | "migrating" 7 | "email-verify" 8 | "plc-token" 9 | "did-web-update" 10 | "finalizing" 11 | "success" 12 | "error"; 13 14 export type OutboundStep = 15 | "welcome" ··· 54 plcToken: string; 55 progress: MigrationProgress; 56 error: string | null; 57 - requires2FA: boolean; 58 - twoFactorCode: string; 59 targetVerificationMethod: string | null; 60 } 61 62 export interface OutboundMigrationState { ··· 92 sourceHandle: string; 93 targetHandle: string; 94 targetEmail: string; 95 progress: { 96 repoExported: boolean; 97 repoImported: boolean; ··· 199 recoveryKey?: string; 200 } 201 202 export interface Preferences { 203 preferences: unknown[]; 204 } ··· 214 this.name = "MigrationError"; 215 } 216 }
··· 1 export type InboundStep = 2 | "welcome" 3 + | "source-handle" 4 | "choose-handle" 5 | "review" 6 | "migrating" 7 + | "passkey-setup" 8 + | "app-password" 9 | "email-verify" 10 | "plc-token" 11 | "did-web-update" 12 | "finalizing" 13 | "success" 14 | "error"; 15 + 16 + export type AuthMethod = "password" | "passkey"; 17 18 export type OutboundStep = 19 | "welcome" ··· 58 plcToken: string; 59 progress: MigrationProgress; 60 error: string | null; 61 targetVerificationMethod: string | null; 62 + authMethod: AuthMethod; 63 + passkeySetupToken: string | null; 64 + oauthCodeVerifier: string | null; 65 + generatedAppPassword: string | null; 66 + generatedAppPasswordName: string | null; 67 + needsReauth?: boolean; 68 + resumeToStep?: InboundStep; 69 } 70 71 export interface OutboundMigrationState { ··· 101 sourceHandle: string; 102 targetHandle: string; 103 targetEmail: string; 104 + authMethod?: AuthMethod; 105 + passkeySetupToken?: string; 106 progress: { 107 repoExported: boolean; 108 repoImported: boolean; ··· 210 recoveryKey?: string; 211 } 212 213 + export interface CreatePasskeyAccountParams { 214 + did?: string; 215 + handle: string; 216 + email: string; 217 + inviteCode?: string; 218 + } 219 + 220 + export interface PasskeyAccountSetup { 221 + setupToken: string; 222 + did: string; 223 + handle: string; 224 + setupExpiresAt: string; 225 + accessJwt?: string; 226 + } 227 + 228 + export interface CompletePasskeySetupResponse { 229 + did: string; 230 + handle: string; 231 + appPassword: string; 232 + appPasswordName: string; 233 + } 234 + 235 + export interface StartPasskeyRegistrationResponse { 236 + options: unknown; 237 + } 238 + 239 + export interface OAuthServerMetadata { 240 + issuer: string; 241 + authorization_endpoint: string; 242 + token_endpoint: string; 243 + scopes_supported?: string[]; 244 + response_types_supported?: string[]; 245 + grant_types_supported?: string[]; 246 + code_challenge_methods_supported?: string[]; 247 + dpop_signing_alg_values_supported?: string[]; 248 + } 249 + 250 + export interface OAuthTokenResponse { 251 + access_token: string; 252 + token_type: string; 253 + expires_in?: number; 254 + refresh_token?: string; 255 + scope?: string; 256 + } 257 + 258 export interface Preferences { 259 preferences: unknown[]; 260 } ··· 270 this.name = "MigrationError"; 271 } 272 } 273 + 274 + export function getErrorMessage(err: unknown): string { 275 + if (err instanceof Error) { 276 + return err.message; 277 + } 278 + if (typeof err === "string") { 279 + return err; 280 + } 281 + return String(err); 282 + }
+11 -7
frontend/src/lib/oauth.ts
··· 8 "blob:*/*", 9 ].join(" "); 10 const CLIENT_ID = !(import.meta.env.DEV) 11 - ? `${window.location.origin}/oauth/client-metadata.json` 12 : `http://localhost/?scope=${SCOPES}`; 13 - const REDIRECT_URI = `${window.location.origin}/`; 14 15 interface OAuthState { 16 state: string; ··· 106 107 const { request_uri } = await parResponse.json(); 108 109 - const authorizeUrl = new URL("/oauth/authorize", window.location.origin); 110 authorizeUrl.searchParams.set("client_id", CLIENT_ID); 111 authorizeUrl.searchParams.set("request_uri", request_uri); 112 113 - window.location.href = authorizeUrl.toString(); 114 } 115 116 export interface OAuthTokens { ··· 191 export function checkForOAuthCallback(): 192 | { code: string; state: string } 193 | null { 194 - const params = new URLSearchParams(window.location.search); 195 const code = params.get("code"); 196 const state = params.get("state"); 197 ··· 203 } 204 205 export function clearOAuthCallbackParams(): void { 206 - const url = new URL(window.location.href); 207 url.search = ""; 208 - window.history.replaceState({}, "", url.toString()); 209 }
··· 8 "blob:*/*", 9 ].join(" "); 10 const CLIENT_ID = !(import.meta.env.DEV) 11 + ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 : `http://localhost/?scope=${SCOPES}`; 13 + const REDIRECT_URI = `${globalThis.location.origin}/`; 14 15 interface OAuthState { 16 state: string; ··· 106 107 const { request_uri } = await parResponse.json(); 108 109 + const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin); 110 authorizeUrl.searchParams.set("client_id", CLIENT_ID); 111 authorizeUrl.searchParams.set("request_uri", request_uri); 112 113 + globalThis.location.href = authorizeUrl.toString(); 114 } 115 116 export interface OAuthTokens { ··· 191 export function checkForOAuthCallback(): 192 | { code: string; state: string } 193 | null { 194 + if (globalThis.location.hash === "#/migrate") { 195 + return null; 196 + } 197 + 198 + const params = new URLSearchParams(globalThis.location.search); 199 const code = params.get("code"); 200 const state = params.get("state"); 201 ··· 207 } 208 209 export function clearOAuthCallbackParams(): void { 210 + const url = new URL(globalThis.location.href); 211 url.search = ""; 212 + globalThis.history.replaceState({}, "", url.toString()); 213 }
+1 -1
frontend/src/lib/registration/flow.svelte.ts
··· 104 state.externalDidWeb.reservedSigningKey = result.signingKey; 105 publicKeyMultibase = result.signingKey.replace("did:key:", ""); 106 } else { 107 - const keypair = await generateKeypair(); 108 state.externalDidWeb.byodPrivateKey = keypair.privateKey; 109 state.externalDidWeb.byodPublicKeyMultibase = 110 keypair.publicKeyMultibase;
··· 104 state.externalDidWeb.reservedSigningKey = result.signingKey; 105 publicKeyMultibase = result.signingKey.replace("did:key:", ""); 106 } else { 107 + const keypair = generateKeypair(); 108 state.externalDidWeb.byodPrivateKey = keypair.privateKey; 109 state.externalDidWeb.byodPublicKeyMultibase = 110 keypair.publicKeyMultibase;
+4 -4
frontend/src/lib/router.svelte.ts
··· 1 let currentPath = $state( 2 - getPathWithoutQuery(window.location.hash.slice(1) || "/"), 3 ); 4 5 function getPathWithoutQuery(hash: string): string { ··· 7 return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 8 } 9 10 - window.addEventListener("hashchange", () => { 11 - currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/"); 12 }); 13 14 export function navigate(path: string) { 15 currentPath = path; 16 - window.location.hash = path; 17 } 18 19 export function getCurrentPath() {
··· 1 let currentPath = $state( 2 + getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), 3 ); 4 5 function getPathWithoutQuery(hash: string): string { ··· 7 return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 8 } 9 10 + globalThis.addEventListener("hashchange", () => { 11 + currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); 12 }); 13 14 export function navigate(path: string) { 15 currentPath = path; 16 + globalThis.location.hash = path; 17 } 18 19 export function getCurrentPath() {
+1 -1
frontend/src/lib/serverConfig.svelte.ts
··· 74 if (initialized) return; 75 initialized = true; 76 77 - darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 78 darkModeQuery.addEventListener("change", applyColors); 79 80 try {
··· 74 if (initialized) return; 75 initialized = true; 76 77 + darkModeQuery = globalThis.matchMedia("(prefers-color-scheme: dark)"); 78 darkModeQuery.addEventListener("change", applyColors); 79 80 try {
+106 -32
frontend/src/locales/en.json
··· 902 "reauth": { 903 "title": "Re-authentication Required", 904 "subtitle": "Please verify your identity to continue.", 905 "usePassword": "Use Password", 906 "usePasskey": "Use Passkey", 907 "useTotp": "Use Authenticator", ··· 909 "totpPlaceholder": "Enter 6-digit code", 910 "verify": "Verify", 911 "verifying": "Verifying...", 912 "cancel": "Cancel" 913 }, 914 "delegation": { ··· 1071 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1072 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1073 "learnMore": "Learn more about migration risks", 1074 "resume": { 1075 "title": "Resume Migration?", 1076 "incomplete": "You have an incomplete migration in progress:", ··· 1090 "desc": "Move your existing AT Protocol account to this server.", 1091 "understand": "I understand the risks and want to proceed" 1092 }, 1093 - "sourceLogin": { 1094 - "title": "Sign In to Your Current PDS", 1095 - "desc": "Enter your credentials for the account you want to migrate.", 1096 "handle": "Handle", 1097 - "handlePlaceholder": "you.bsky.social", 1098 - "password": "Password", 1099 - "twoFactorCode": "Two-Factor Code", 1100 - "twoFactorRequired": "Two-factor authentication required", 1101 - "signIn": "Sign In & Continue" 1102 }, 1103 "chooseHandle": { 1104 "title": "Choose Your New Handle", 1105 "desc": "Select a handle for your account on this PDS.", 1106 - "handleHint": "Your full handle will be: @{handle}" 1107 }, 1108 "review": { 1109 "title": "Review Migration", 1110 - "desc": "Please review and confirm your migration details.", 1111 "currentHandle": "Current Handle", 1112 "newHandle": "New Handle", 1113 - "sourcePds": "Source PDS", 1114 - "targetPds": "This PDS", 1115 "email": "Email", 1116 "inviteCode": "Invite Code", 1117 - "confirm": "I confirm I want to migrate my account", 1118 - "startMigration": "Start Migration" 1119 }, 1120 "migrating": { 1121 - "title": "Migrating Your Account", 1122 - "desc": "Please wait while we transfer your data...", 1123 - "gettingServiceAuth": "Getting service authorization...", 1124 - "creatingAccount": "Creating account on new PDS...", 1125 - "exportingRepo": "Exporting repository...", 1126 - "importingRepo": "Importing repository...", 1127 - "countingBlobs": "Counting blobs...", 1128 - "migratingBlobs": "Migrating blobs ({current}/{total})...", 1129 - "migratingPrefs": "Migrating preferences...", 1130 - "requestingPlc": "Requesting PLC operation..." 1131 }, 1132 "emailVerify": { 1133 "title": "Verify Your Email", ··· 1140 "verifying": "Verifying..." 1141 }, 1142 "plcToken": { 1143 - "title": "Verify Your Identity", 1144 - "desc": "A verification code has been sent to your email on your current PDS.", 1145 - "tokenLabel": "Verification Token", 1146 - "tokenPlaceholder": "Enter the token from your email", 1147 - "resend": "Resend Token", 1148 - "resending": "Resending..." 1149 }, 1150 "didWebUpdate": { 1151 "title": "Update Your DID Document", ··· 1168 "success": { 1169 "title": "Migration Complete!", 1170 "desc": "Your account has been successfully migrated to this PDS.", 1171 - "newHandle": "New Handle", 1172 "did": "DID", 1173 - "goToDashboard": "Go to Dashboard" 1174 } 1175 }, 1176 "outbound": {
··· 902 "reauth": { 903 "title": "Re-authentication Required", 904 "subtitle": "Please verify your identity to continue.", 905 + "password": "Password", 906 + "totp": "TOTP", 907 + "passkey": "Passkey", 908 + "authenticatorCode": "Authenticator Code", 909 "usePassword": "Use Password", 910 "usePasskey": "Use Passkey", 911 "useTotp": "Use Authenticator", ··· 913 "totpPlaceholder": "Enter 6-digit code", 914 "verify": "Verify", 915 "verifying": "Verifying...", 916 + "authenticating": "Authenticating...", 917 + "passkeyPrompt": "Click the button below to authenticate with your passkey.", 918 "cancel": "Cancel" 919 }, 920 "delegation": { ··· 1077 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1078 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1079 "learnMore": "Learn more about migration risks", 1080 + "comingSoon": "Coming soon", 1081 + "oauthCompleting": "Completing authentication...", 1082 + "oauthFailed": "Authentication Failed", 1083 + "tryAgain": "Try Again", 1084 "resume": { 1085 "title": "Resume Migration?", 1086 "incomplete": "You have an incomplete migration in progress:", ··· 1100 "desc": "Move your existing AT Protocol account to this server.", 1101 "understand": "I understand the risks and want to proceed" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "Enter Your Current Handle", 1105 + "titleResume": "Resume Migration", 1106 + "desc": "Enter the handle of the account you want to migrate.", 1107 + "descResume": "Re-authenticate to your source PDS to continue the migration.", 1108 "handle": "Handle", 1109 + "handlePlaceholder": "alice.bsky.social", 1110 + "handleHint": "Your current handle on your existing PDS", 1111 + "continue": "Continue", 1112 + "connecting": "Connecting...", 1113 + "reauthenticate": "Re-authenticate", 1114 + "resumeTitle": "Migration in Progress", 1115 + "resumeFrom": "From", 1116 + "resumeTo": "To", 1117 + "resumeProgress": "Progress", 1118 + "resumeOAuthNote": "You need to re-authenticate via OAuth to continue." 1119 }, 1120 "chooseHandle": { 1121 "title": "Choose Your New Handle", 1122 "desc": "Select a handle for your account on this PDS.", 1123 + "migratingFrom": "Migrating from", 1124 + "newHandle": "New Handle", 1125 + "checkingAvailability": "Checking availability...", 1126 + "handleAvailable": "Handle is available!", 1127 + "handleTaken": "Handle is already taken", 1128 + "handleHint": "You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)", 1129 + "email": "Email Address", 1130 + "authMethod": "Authentication Method", 1131 + "authPassword": "Password", 1132 + "authPasswordDesc": "Traditional password-based login", 1133 + "authPasskey": "Passkey", 1134 + "authPasskeyDesc": "Passwordless login using biometrics or security key", 1135 + "password": "Password", 1136 + "passwordHint": "At least 8 characters", 1137 + "passkeyInfo": "You'll set up a passkey after your account is created. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.", 1138 + "inviteCode": "Invite Code" 1139 }, 1140 "review": { 1141 "title": "Review Migration", 1142 + "desc": "Please confirm the details of your migration.", 1143 "currentHandle": "Current Handle", 1144 "newHandle": "New Handle", 1145 + "did": "DID", 1146 + "sourcePds": "From PDS", 1147 + "targetPds": "To PDS", 1148 "email": "Email", 1149 + "authentication": "Authentication", 1150 + "authPasskey": "Passkey (passwordless)", 1151 + "authPassword": "Password", 1152 "inviteCode": "Invite Code", 1153 + "warning": "After you click \"Start Migration\", your repository and data will begin transferring. This process cannot be easily undone.", 1154 + "startMigration": "Start Migration", 1155 + "starting": "Starting..." 1156 }, 1157 "migrating": { 1158 + "title": "Migration in Progress", 1159 + "desc": "Please wait while your account is being transferred...", 1160 + "exportRepo": "Export repository", 1161 + "importRepo": "Import repository", 1162 + "migrateBlobs": "Migrate blobs", 1163 + "migratePrefs": "Migrate preferences" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "Set Up Your Passkey", 1167 + "desc": "Your email has been verified. Now set up your passkey for secure, passwordless login.", 1168 + "nameLabel": "Passkey Name (optional)", 1169 + "namePlaceholder": "e.g., MacBook Pro, iPhone", 1170 + "nameHint": "A friendly name to identify this passkey", 1171 + "instructions": "Click the button below to register your passkey. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.", 1172 + "register": "Register Passkey", 1173 + "registering": "Registering..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "Save Your App Password", 1177 + "desc": "Your passkey has been created. An app password has been generated for you to use with apps that don't support passkeys yet.", 1178 + "warning": "This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.", 1179 + "label": "App Password for", 1180 + "saved": "I have saved my app password in a secure location", 1181 + "continue": "Continue" 1182 }, 1183 "emailVerify": { 1184 "title": "Verify Your Email", ··· 1191 "verifying": "Verifying..." 1192 }, 1193 "plcToken": { 1194 + "title": "Verify Migration", 1195 + "desc": "A verification code has been sent to the email registered with your old account.", 1196 + "info": "This code confirms you have access to the account and authorizes updating your identity to point to this PDS.", 1197 + "tokenLabel": "Verification Code", 1198 + "tokenPlaceholder": "Enter code from email", 1199 + "resend": "Resend Code", 1200 + "complete": "Complete Migration", 1201 + "completing": "Verifying..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "Update Your DID Document", ··· 1221 "success": { 1222 "title": "Migration Complete!", 1223 "desc": "Your account has been successfully migrated to this PDS.", 1224 + "yourNewHandle": "Your new handle", 1225 "did": "DID", 1226 + "blobsWarning": "{count} blobs could not be migrated. These may be images or other media that are no longer available.", 1227 + "redirecting": "Redirecting to dashboard..." 1228 + }, 1229 + "error": { 1230 + "title": "Migration Error", 1231 + "desc": "An error occurred during migration.", 1232 + "startOver": "Start Over" 1233 + }, 1234 + "common": { 1235 + "back": "Back", 1236 + "cancel": "Cancel", 1237 + "continue": "Continue", 1238 + "whatWillHappen": "What will happen:", 1239 + "step1": "Log in to your current PDS", 1240 + "step2": "Choose your new handle on this server", 1241 + "step3": "Your repository and blobs will be transferred", 1242 + "step4": "Verify the migration via email", 1243 + "step5": "Your identity will be updated to point here", 1244 + "beforeProceed": "Before you proceed:", 1245 + "warning1": "You need access to the email registered with your current account", 1246 + "warning2": "Large accounts may take several minutes to transfer", 1247 + "warning3": "Your old account will be deactivated after migration" 1248 } 1249 }, 1250 "outbound": {
+103 -29
frontend/src/locales/fi.json
··· 902 "reauth": { 903 "title": "Uudelleentodennus vaaditaan", 904 "subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.", 905 "usePassword": "Käytä salasanaa", 906 "usePasskey": "Käytä pääsyavainta", 907 "useTotp": "Käytä todentajaa", ··· 909 "totpPlaceholder": "Syötä 6-numeroinen koodi", 910 "verify": "Vahvista", 911 "verifying": "Vahvistetaan...", 912 "cancel": "Peruuta" 913 }, 914 "verifyChannel": { ··· 1071 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1072 "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.", 1073 "learnMore": "Lue lisää siirron riskeistä", 1074 "resume": { 1075 "title": "Jatka siirtoa?", 1076 "incomplete": "Sinulla on keskeneräinen siirto:", ··· 1090 "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", 1091 "understand": "Ymmärrän riskit ja haluan jatkaa" 1092 }, 1093 - "sourceLogin": { 1094 - "title": "Kirjaudu nykyiseen PDS:ääsi", 1095 - "desc": "Syötä siirrettävän tilin tunnukset.", 1096 "handle": "Käyttäjätunnus", 1097 - "handlePlaceholder": "sinä.bsky.social", 1098 - "password": "Salasana", 1099 - "twoFactorCode": "Kaksivaiheinen koodi", 1100 - "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", 1101 - "signIn": "Kirjaudu ja jatka" 1102 }, 1103 "chooseHandle": { 1104 "title": "Valitse uusi käyttäjätunnuksesi", 1105 "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", 1106 - "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}" 1107 }, 1108 "review": { 1109 "title": "Tarkista siirto", 1110 - "desc": "Tarkista ja vahvista siirtotietosi.", 1111 "currentHandle": "Nykyinen käyttäjätunnus", 1112 "newHandle": "Uusi käyttäjätunnus", 1113 "sourcePds": "Lähde-PDS", 1114 - "targetPds": "Tämä PDS", 1115 "email": "Sähköposti", 1116 "inviteCode": "Kutsukoodi", 1117 - "confirm": "Vahvistan haluavani siirtää tilini", 1118 - "startMigration": "Aloita siirto" 1119 }, 1120 "migrating": { 1121 - "title": "Siirretään tiliäsi", 1122 - "desc": "Odota, kun siirrämme tietojasi...", 1123 - "gettingServiceAuth": "Haetaan palveluvaltuutusta...", 1124 - "creatingAccount": "Luodaan tiliä uuteen PDS:ään...", 1125 - "exportingRepo": "Viedään tietovarastoa...", 1126 - "importingRepo": "Tuodaan tietovarastoa...", 1127 - "countingBlobs": "Lasketaan blob-tiedostoja...", 1128 - "migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...", 1129 - "migratingPrefs": "Siirretään asetuksia...", 1130 - "requestingPlc": "Pyydetään PLC-toimintoa..." 1131 }, 1132 "emailVerify": { 1133 "title": "Vahvista sähköpostisi", ··· 1140 "verifying": "Vahvistetaan..." 1141 }, 1142 "plcToken": { 1143 - "title": "Vahvista henkilöllisyytesi", 1144 - "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.", 1145 "tokenLabel": "Vahvistuskoodi", 1146 "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1147 - "resend": "Lähetä uudelleen", 1148 - "resending": "Lähetetään..." 1149 }, 1150 "didWebUpdate": { 1151 "title": "Päivitä DID-dokumenttisi", ··· 1168 "success": { 1169 "title": "Siirto valmis!", 1170 "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", 1171 - "newHandle": "Uusi käyttäjätunnus", 1172 "did": "DID", 1173 - "goToDashboard": "Siirry hallintapaneeliin" 1174 } 1175 }, 1176 "outbound": {
··· 902 "reauth": { 903 "title": "Uudelleentodennus vaaditaan", 904 "subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.", 905 + "password": "Salasana", 906 + "totp": "TOTP", 907 + "passkey": "Pääsyavain", 908 + "authenticatorCode": "Todentajan koodi", 909 "usePassword": "Käytä salasanaa", 910 "usePasskey": "Käytä pääsyavainta", 911 "useTotp": "Käytä todentajaa", ··· 913 "totpPlaceholder": "Syötä 6-numeroinen koodi", 914 "verify": "Vahvista", 915 "verifying": "Vahvistetaan...", 916 + "authenticating": "Todennetaan...", 917 + "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 918 "cancel": "Peruuta" 919 }, 920 "verifyChannel": { ··· 1077 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1078 "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.", 1079 "learnMore": "Lue lisää siirron riskeistä", 1080 + "comingSoon": "Tulossa pian", 1081 + "oauthCompleting": "Viimeistellään todennusta...", 1082 + "oauthFailed": "Todennus epäonnistui", 1083 + "tryAgain": "Yritä uudelleen", 1084 "resume": { 1085 "title": "Jatka siirtoa?", 1086 "incomplete": "Sinulla on keskeneräinen siirto:", ··· 1100 "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", 1101 "understand": "Ymmärrän riskit ja haluan jatkaa" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "Syötä nykyinen käyttäjätunnuksesi", 1105 + "titleResume": "Jatka siirtoa", 1106 + "desc": "Syötä siirrettävän tilin käyttäjätunnus.", 1107 + "descResume": "Tunnistaudu uudelleen lähde-PDS:ään jatkaaksesi siirtoa.", 1108 "handle": "Käyttäjätunnus", 1109 + "handlePlaceholder": "maija.bsky.social", 1110 + "handleHint": "Nykyinen käyttäjätunnuksesi nykyisessä PDS:ssäsi", 1111 + "continue": "Jatka", 1112 + "connecting": "Yhdistetään...", 1113 + "reauthenticate": "Tunnistaudu uudelleen", 1114 + "resumeTitle": "Siirto käynnissä", 1115 + "resumeFrom": "Mistä", 1116 + "resumeTo": "Minne", 1117 + "resumeProgress": "Edistyminen", 1118 + "resumeOAuthNote": "Sinun täytyy tunnistautua uudelleen OAuth:n kautta jatkaaksesi." 1119 }, 1120 "chooseHandle": { 1121 "title": "Valitse uusi käyttäjätunnuksesi", 1122 "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", 1123 + "migratingFrom": "Siirretään tililtä", 1124 + "newHandle": "Uusi käyttäjätunnus", 1125 + "checkingAvailability": "Tarkistetaan saatavuutta...", 1126 + "handleAvailable": "Käyttäjätunnus on saatavilla!", 1127 + "handleTaken": "Käyttäjätunnus on jo varattu", 1128 + "handleHint": "Voit myös käyttää omaa verkkotunnustasi syöttämällä täydellisen käyttäjätunnuksen (esim. maija.omadomain.fi)", 1129 + "email": "Sähköpostiosoite", 1130 + "authMethod": "Tunnistautumistapa", 1131 + "authPassword": "Salasana", 1132 + "authPasswordDesc": "Perinteinen salasanapohjainen kirjautuminen", 1133 + "authPasskey": "Pääsyavain", 1134 + "authPasskeyDesc": "Salasanaton kirjautuminen biometriikalla tai suojausavaimella", 1135 + "password": "Salasana", 1136 + "passwordHint": "Vähintään 8 merkkiä", 1137 + "passkeyInfo": "Määrität pääsyavaimen tilisi luomisen jälkeen. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.", 1138 + "inviteCode": "Kutsukoodi" 1139 }, 1140 "review": { 1141 "title": "Tarkista siirto", 1142 + "desc": "Vahvista siirtosi tiedot.", 1143 "currentHandle": "Nykyinen käyttäjätunnus", 1144 "newHandle": "Uusi käyttäjätunnus", 1145 + "did": "DID", 1146 "sourcePds": "Lähde-PDS", 1147 + "targetPds": "Kohde-PDS", 1148 "email": "Sähköposti", 1149 + "authentication": "Tunnistautuminen", 1150 + "authPasskey": "Pääsyavain (salasanaton)", 1151 + "authPassword": "Salasana", 1152 "inviteCode": "Kutsukoodi", 1153 + "warning": "Kun klikkaat \"Aloita siirto\", tietovarastosi ja datasi alkavat siirtyä. Tätä prosessia ei voi helposti peruuttaa.", 1154 + "startMigration": "Aloita siirto", 1155 + "starting": "Aloitetaan..." 1156 }, 1157 "migrating": { 1158 + "title": "Siirto käynnissä", 1159 + "desc": "Odota, kun tiliäsi siirretään...", 1160 + "exportRepo": "Vie tietovarasto", 1161 + "importRepo": "Tuo tietovarasto", 1162 + "migrateBlobs": "Siirrä blob-tiedostot", 1163 + "migratePrefs": "Siirrä asetukset" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "Määritä pääsyavaimesi", 1167 + "desc": "Sähköpostisi on vahvistettu. Määritä nyt pääsyavaimesi turvallista, salasanatonta kirjautumista varten.", 1168 + "nameLabel": "Pääsyavaimen nimi (valinnainen)", 1169 + "namePlaceholder": "esim. MacBook Pro, iPhone", 1170 + "nameHint": "Kutsumanimi tämän pääsyavaimen tunnistamiseen", 1171 + "instructions": "Klikkaa alla olevaa painiketta rekisteröidäksesi pääsyavaimesi. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.", 1172 + "register": "Rekisteröi pääsyavain", 1173 + "registering": "Rekisteröidään..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "Tallenna sovellussalasanasi", 1177 + "desc": "Pääsyavaimesi on luotu. Sovellussalasana on luotu sinulle käytettäväksi sovellusten kanssa, jotka eivät vielä tue pääsyavaimia.", 1178 + "warning": "Tämä sovellussalasana vaaditaan kirjautumiseen sovelluksissa, jotka eivät vielä tue pääsyavaimia (kuten bsky.app). Näet tämän salasanan vain kerran.", 1179 + "label": "Sovellussalasana kohteelle", 1180 + "saved": "Olen tallentanut sovellussalasanani turvalliseen paikkaan", 1181 + "continue": "Jatka" 1182 }, 1183 "emailVerify": { 1184 "title": "Vahvista sähköpostisi", ··· 1191 "verifying": "Vahvistetaan..." 1192 }, 1193 "plcToken": { 1194 + "title": "Vahvista siirto", 1195 + "desc": "Vahvistuskoodi on lähetetty vanhaan tiliisi rekisteröityyn sähköpostiin.", 1196 + "info": "Tämä koodi vahvistaa, että sinulla on pääsy tiliin ja valtuuttaa identiteettisi päivityksen osoittamaan tähän PDS:ään.", 1197 "tokenLabel": "Vahvistuskoodi", 1198 "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1199 + "resend": "Lähetä koodi uudelleen", 1200 + "complete": "Viimeistele siirto", 1201 + "completing": "Vahvistetaan..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "Päivitä DID-dokumenttisi", ··· 1221 "success": { 1222 "title": "Siirto valmis!", 1223 "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", 1224 + "yourNewHandle": "Uusi käyttäjätunnuksesi", 1225 "did": "DID", 1226 + "blobsWarning": "{count} blob-tiedostoa ei voitu siirtää. Nämä voivat olla kuvia tai muuta mediaa, jotka eivät ole enää saatavilla.", 1227 + "redirecting": "Uudelleenohjataan hallintapaneeliin..." 1228 + }, 1229 + "error": { 1230 + "title": "Siirtovirhe", 1231 + "desc": "Siirron aikana tapahtui virhe.", 1232 + "startOver": "Aloita alusta" 1233 + }, 1234 + "common": { 1235 + "back": "Takaisin", 1236 + "cancel": "Peruuta", 1237 + "continue": "Jatka", 1238 + "whatWillHappen": "Mitä tapahtuu:", 1239 + "step1": "Kirjaudu nykyiseen PDS:ääsi", 1240 + "step2": "Valitse uusi käyttäjätunnus tällä palvelimella", 1241 + "step3": "Tietovarastosi ja blob-tiedostosi siirretään", 1242 + "step4": "Vahvista siirto sähköpostilla", 1243 + "step5": "Identiteettisi päivitetään osoittamaan tänne", 1244 + "beforeProceed": "Ennen kuin jatkat:", 1245 + "warning1": "Tarvitset pääsyn nykyiseen tiliisi rekisteröityyn sähköpostiin", 1246 + "warning2": "Suurten tilien siirto voi kestää useita minuutteja", 1247 + "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" 1248 } 1249 }, 1250 "outbound": {
+117 -31
frontend/src/locales/ja.json
··· 189 "title": "DID ドキュメントエディター", 190 "preview": "現在の DID ドキュメント", 191 "verificationMethods": "検証方法(署名キー)", 192 "addKey": "キーを追加", 193 "removeKey": "削除", 194 "keyId": "キー ID", 195 "keyIdPlaceholder": "#atproto", 196 "publicKey": "公開キー(Multibase)", 197 "publicKeyPlaceholder": "zQ3sh...", 198 "alsoKnownAs": "別名(ハンドル)", 199 "addHandle": "ハンドルを追加", 200 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "サービスエンドポイント(現在の PDS)", 202 "save": "変更を保存", 203 "saving": "保存中...", 204 "success": "DID ドキュメントを更新しました", 205 "helpTitle": "これは何ですか?", 206 "helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。" 207 }, ··· 890 "reauth": { 891 "title": "再認証が必要です", 892 "subtitle": "続行するには本人確認を行ってください。", 893 "usePassword": "パスワードを使用", 894 "usePasskey": "パスキーを使用", 895 "useTotp": "認証アプリを使用", ··· 897 "totpPlaceholder": "6桁のコードを入力", 898 "verify": "確認", 899 "verifying": "確認中...", 900 "cancel": "キャンセル" 901 }, 902 "verifyChannel": { ··· 1059 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1060 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1061 "learnMore": "移行のリスクについて詳しく", 1062 "resume": { 1063 "title": "移行を再開しますか?", 1064 "incomplete": "未完了の移行があります:", ··· 1078 "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", 1079 "understand": "リスクを理解し、続行します" 1080 }, 1081 - "sourceLogin": { 1082 - "title": "現在のPDSにサインイン", 1083 - "desc": "移行するアカウントの認証情報を入力してください。", 1084 "handle": "ハンドル", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "パスワード", 1087 - "twoFactorCode": "2要素認証コード", 1088 - "twoFactorRequired": "2要素認証が必要です", 1089 - "signIn": "サインインして続行" 1090 }, 1091 "chooseHandle": { 1092 "title": "新しいハンドルを選択", 1093 "desc": "このPDSでのアカウントのハンドルを選択してください。", 1094 - "handleHint": "完全なハンドル: @{handle}" 1095 }, 1096 "review": { 1097 "title": "移行の確認", 1098 "desc": "移行の詳細を確認してください。", 1099 "currentHandle": "現在のハンドル", 1100 "newHandle": "新しいハンドル", 1101 "sourcePds": "移行元PDS", 1102 - "targetPds": "このPDS", 1103 "email": "メール", 1104 "inviteCode": "招待コード", 1105 - "confirm": "アカウントを移行することを確認します", 1106 - "startMigration": "移行を開始" 1107 }, 1108 "migrating": { 1109 - "title": "アカウントを移行中", 1110 - "desc": "データを転送しています...", 1111 - "gettingServiceAuth": "サービス認証を取得中...", 1112 - "creatingAccount": "新しいPDSにアカウントを作成中...", 1113 - "exportingRepo": "リポジトリをエクスポート中...", 1114 - "importingRepo": "リポジトリをインポート中...", 1115 - "countingBlobs": "blobをカウント中...", 1116 - "migratingBlobs": "blobを移行中 ({current}/{total})...", 1117 - "migratingPrefs": "設定を移行中...", 1118 - "requestingPlc": "PLC操作をリクエスト中..." 1119 }, 1120 "emailVerify": { 1121 "title": "メールアドレスを確認", ··· 1128 "verifying": "確認中..." 1129 }, 1130 "plcToken": { 1131 - "title": "本人確認", 1132 - "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。", 1133 - "tokenLabel": "確認トークン", 1134 - "tokenPlaceholder": "メールに記載されたトークンを入力", 1135 - "resend": "再送信", 1136 - "resending": "送信中..." 1137 }, 1138 "didWebUpdate": { 1139 "title": "DIDドキュメントを更新", ··· 1156 "success": { 1157 "title": "移行完了!", 1158 "desc": "アカウントはこのPDSに正常に移行されました。", 1159 - "newHandle": "新しいハンドル", 1160 "did": "DID", 1161 - "goToDashboard": "ダッシュボードへ" 1162 } 1163 }, 1164 "outbound": {
··· 189 "title": "DID ドキュメントエディター", 190 "preview": "現在の DID ドキュメント", 191 "verificationMethods": "検証方法(署名キー)", 192 + "verificationMethodsDesc": "DIDの代わりに動作できる署名キー。新しいPDSに移行する際は、そのPDSの署名キーをここに追加してください。", 193 "addKey": "キーを追加", 194 "removeKey": "削除", 195 "keyId": "キー ID", 196 "keyIdPlaceholder": "#atproto", 197 "publicKey": "公開キー(Multibase)", 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "検証方法が設定されていません。ローカルPDSキーを使用しています。", 200 "alsoKnownAs": "別名(ハンドル)", 201 + "alsoKnownAsDesc": "DIDを指すハンドル。新しいPDSでハンドルが変更されたら更新してください。", 202 "addHandle": "ハンドルを追加", 203 + "removeHandle": "削除", 204 + "handle": "ハンドル", 205 "handlePlaceholder": "at://handle.pds.com", 206 + "noHandles": "ハンドルが設定されていません。ローカルハンドルを使用しています。", 207 + "serviceEndpoint": "サービスエンドポイント", 208 + "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 209 + "currentPds": "現在のPDS URL", 210 "save": "変更を保存", 211 "saving": "保存中...", 212 "success": "DID ドキュメントを更新しました", 213 + "saveFailed": "DIDドキュメントの保存に失敗しました", 214 + "loadFailed": "DIDドキュメントの読み込みに失敗しました", 215 + "invalidMultibase": "公開キーは'z'で始まる有効なmultibase文字列である必要があります", 216 + "invalidHandle": "ハンドルはat:// URIである必要があります(例:at://handle.example.com)", 217 "helpTitle": "これは何ですか?", 218 "helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。" 219 }, ··· 902 "reauth": { 903 "title": "再認証が必要です", 904 "subtitle": "続行するには本人確認を行ってください。", 905 + "password": "パスワード", 906 + "totp": "TOTP", 907 + "passkey": "パスキー", 908 + "authenticatorCode": "認証コード", 909 "usePassword": "パスワードを使用", 910 "usePasskey": "パスキーを使用", 911 "useTotp": "認証アプリを使用", ··· 913 "totpPlaceholder": "6桁のコードを入力", 914 "verify": "確認", 915 "verifying": "確認中...", 916 + "authenticating": "認証中...", 917 + "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 918 "cancel": "キャンセル" 919 }, 920 "verifyChannel": { ··· 1077 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1078 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1079 "learnMore": "移行のリスクについて詳しく", 1080 + "comingSoon": "近日公開", 1081 + "oauthCompleting": "認証を完了しています...", 1082 + "oauthFailed": "認証に失敗しました", 1083 + "tryAgain": "再試行", 1084 "resume": { 1085 "title": "移行を再開しますか?", 1086 "incomplete": "未完了の移行があります:", ··· 1100 "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", 1101 "understand": "リスクを理解し、続行します" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "現在のハンドルを入力", 1105 + "titleResume": "移行を再開", 1106 + "desc": "移行するアカウントのハンドルを入力してください。", 1107 + "descResume": "移行を続行するには、元のPDSに再認証してください。", 1108 "handle": "ハンドル", 1109 + "handlePlaceholder": "alice.bsky.social", 1110 + "handleHint": "現在のPDSでのハンドル", 1111 + "continue": "続行", 1112 + "connecting": "接続中...", 1113 + "reauthenticate": "再認証", 1114 + "resumeTitle": "移行中", 1115 + "resumeFrom": "移行元", 1116 + "resumeTo": "移行先", 1117 + "resumeProgress": "進行状況", 1118 + "resumeOAuthNote": "続行するにはOAuthで再認証が必要です。" 1119 }, 1120 "chooseHandle": { 1121 "title": "新しいハンドルを選択", 1122 "desc": "このPDSでのアカウントのハンドルを選択してください。", 1123 + "migratingFrom": "移行元", 1124 + "newHandle": "新しいハンドル", 1125 + "checkingAvailability": "利用可能か確認中...", 1126 + "handleAvailable": "ハンドルは利用可能です!", 1127 + "handleTaken": "このハンドルは既に使用されています", 1128 + "handleHint": "フルハンドル(例:alice.mydomain.com)を入力して独自ドメインを使用することもできます", 1129 + "email": "メールアドレス", 1130 + "authMethod": "認証方法", 1131 + "authPassword": "パスワード", 1132 + "authPasswordDesc": "従来のパスワードベースのログイン", 1133 + "authPasskey": "パスキー", 1134 + "authPasskeyDesc": "生体認証やセキュリティキーを使用したパスワードレスログイン", 1135 + "password": "パスワード", 1136 + "passwordHint": "8文字以上", 1137 + "passkeyInfo": "アカウント作成後にパスキーを設定します。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。", 1138 + "inviteCode": "招待コード" 1139 }, 1140 "review": { 1141 "title": "移行の確認", 1142 "desc": "移行の詳細を確認してください。", 1143 "currentHandle": "現在のハンドル", 1144 "newHandle": "新しいハンドル", 1145 + "did": "DID", 1146 "sourcePds": "移行元PDS", 1147 + "targetPds": "移行先PDS", 1148 "email": "メール", 1149 + "authentication": "認証", 1150 + "authPasskey": "パスキー(パスワードレス)", 1151 + "authPassword": "パスワード", 1152 "inviteCode": "招待コード", 1153 + "warning": "「移行を開始」をクリックすると、リポジトリとデータの転送が始まります。このプロセスは簡単に元に戻すことができません。", 1154 + "startMigration": "移行を開始", 1155 + "starting": "開始中..." 1156 }, 1157 "migrating": { 1158 + "title": "移行中", 1159 + "desc": "アカウントを転送しています...", 1160 + "exportRepo": "リポジトリをエクスポート", 1161 + "importRepo": "リポジトリをインポート", 1162 + "migrateBlobs": "blobを移行", 1163 + "migratePrefs": "設定を移行" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "パスキーを設定", 1167 + "desc": "メールが確認されました。安全なパスワードレスログインのためにパスキーを設定してください。", 1168 + "nameLabel": "パスキー名(任意)", 1169 + "namePlaceholder": "例:MacBook Pro、iPhone", 1170 + "nameHint": "このパスキーを識別するためのわかりやすい名前", 1171 + "instructions": "下のボタンをクリックしてパスキーを登録してください。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。", 1172 + "register": "パスキーを登録", 1173 + "registering": "登録中..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "アプリパスワードを保存", 1177 + "desc": "パスキーが作成されました。パスキーをまだサポートしていないアプリで使用するためのアプリパスワードが生成されました。", 1178 + "warning": "このアプリパスワードは、パスキーをまだサポートしていないアプリ(bsky.appなど)へのサインインに必要です。このパスワードは一度しか表示されません。", 1179 + "label": "アプリパスワード:", 1180 + "saved": "アプリパスワードを安全な場所に保存しました", 1181 + "continue": "続ける" 1182 }, 1183 "emailVerify": { 1184 "title": "メールアドレスを確認", ··· 1191 "verifying": "確認中..." 1192 }, 1193 "plcToken": { 1194 + "title": "移行を確認", 1195 + "desc": "古いアカウントに登録されているメールアドレスに確認コードが送信されました。", 1196 + "info": "このコードはアカウントへのアクセス権を確認し、このPDSを指すようにアイデンティティを更新することを承認します。", 1197 + "tokenLabel": "確認コード", 1198 + "tokenPlaceholder": "メールに記載されたコードを入力", 1199 + "resend": "コードを再送信", 1200 + "complete": "移行を完了", 1201 + "completing": "確認中..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "DIDドキュメントを更新", ··· 1221 "success": { 1222 "title": "移行完了!", 1223 "desc": "アカウントはこのPDSに正常に移行されました。", 1224 + "yourNewHandle": "新しいハンドル", 1225 "did": "DID", 1226 + "blobsWarning": "{count}個のblobを移行できませんでした。これらは利用できなくなった画像やその他のメディアの可能性があります。", 1227 + "redirecting": "ダッシュボードにリダイレクト中..." 1228 + }, 1229 + "error": { 1230 + "title": "移行エラー", 1231 + "desc": "移行中にエラーが発生しました。", 1232 + "startOver": "最初からやり直す" 1233 + }, 1234 + "common": { 1235 + "back": "戻る", 1236 + "cancel": "キャンセル", 1237 + "continue": "続行", 1238 + "whatWillHappen": "何が起こるか:", 1239 + "step1": "現在のPDSにログイン", 1240 + "step2": "このサーバーでの新しいハンドルを選択", 1241 + "step3": "リポジトリとblobが転送されます", 1242 + "step4": "メールで移行を確認", 1243 + "step5": "アイデンティティがここを指すように更新されます", 1244 + "beforeProceed": "続行する前に:", 1245 + "warning1": "現在のアカウントに登録されているメールへのアクセスが必要です", 1246 + "warning2": "大きなアカウントの転送には数分かかる場合があります", 1247 + "warning3": "移行後、古いアカウントは無効化されます" 1248 } 1249 }, 1250 "outbound": {
+118 -32
frontend/src/locales/ko.json
··· 189 "title": "DID 문서 편집기", 190 "preview": "현재 DID 문서", 191 "verificationMethods": "검증 방법 (서명 키)", 192 "addKey": "키 추가", 193 "removeKey": "삭제", 194 "keyId": "키 ID", 195 "keyIdPlaceholder": "#atproto", 196 "publicKey": "공개 키 (Multibase)", 197 "publicKeyPlaceholder": "zQ3sh...", 198 "alsoKnownAs": "다른 이름 (핸들)", 199 "addHandle": "핸들 추가", 200 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "서비스 엔드포인트 (현재 PDS)", 202 "save": "변경사항 저장", 203 "saving": "저장 중...", 204 "success": "DID 문서가 업데이트되었습니다", 205 "helpTitle": "이것은 무엇인가요?", 206 "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요." 207 }, ··· 890 "reauth": { 891 "title": "재인증 필요", 892 "subtitle": "계속하려면 본인 확인을 해주세요.", 893 "usePassword": "비밀번호 사용", 894 "usePasskey": "패스키 사용", 895 "useTotp": "인증 앱 사용", ··· 897 "totpPlaceholder": "6자리 코드 입력", 898 "verify": "확인", 899 "verifying": "확인 중...", 900 "cancel": "취소" 901 }, 902 "verifyChannel": { ··· 1059 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1060 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1061 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1062 "resume": { 1063 "title": "마이그레이션을 재개하시겠습니까?", 1064 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", ··· 1078 "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", 1079 "understand": "위험을 이해하고 계속 진행합니다" 1080 }, 1081 - "sourceLogin": { 1082 - "title": "현재 PDS에 로그인", 1083 - "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.", 1084 "handle": "핸들", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "비밀번호", 1087 - "twoFactorCode": "2단계 인증 코드", 1088 - "twoFactorRequired": "2단계 인증이 필요합니다", 1089 - "signIn": "로그인 및 계속" 1090 }, 1091 "chooseHandle": { 1092 "title": "새 핸들 선택", 1093 "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", 1094 - "handleHint": "전체 핸들: @{handle}" 1095 }, 1096 "review": { 1097 "title": "마이그레이션 검토", 1098 - "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1099 "currentHandle": "현재 핸들", 1100 "newHandle": "새 핸들", 1101 "sourcePds": "소스 PDS", 1102 - "targetPds": "이 PDS", 1103 "email": "이메일", 1104 "inviteCode": "초대 코드", 1105 - "confirm": "계정 마이그레이션을 확인합니다", 1106 - "startMigration": "마이그레이션 시작" 1107 }, 1108 "migrating": { 1109 - "title": "계정 마이그레이션 중", 1110 - "desc": "데이터를 전송하는 중입니다...", 1111 - "gettingServiceAuth": "서비스 인증 획득 중...", 1112 - "creatingAccount": "새 PDS에 계정 생성 중...", 1113 - "exportingRepo": "저장소 내보내기 중...", 1114 - "importingRepo": "저장소 가져오기 중...", 1115 - "countingBlobs": "blob 개수 세는 중...", 1116 - "migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...", 1117 - "migratingPrefs": "환경설정 마이그레이션 중...", 1118 - "requestingPlc": "PLC 작업 요청 중..." 1119 }, 1120 "emailVerify": { 1121 "title": "이메일 인증", ··· 1128 "verifying": "인증 중..." 1129 }, 1130 "plcToken": { 1131 - "title": "신원 확인", 1132 - "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.", 1133 - "tokenLabel": "인증 토큰", 1134 - "tokenPlaceholder": "이메일에서 받은 토큰 입력", 1135 - "resend": "재전송", 1136 - "resending": "전송 중..." 1137 }, 1138 "didWebUpdate": { 1139 "title": "DID 문서 업데이트", ··· 1156 "success": { 1157 "title": "마이그레이션 완료!", 1158 "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", 1159 - "newHandle": "새 핸들", 1160 "did": "DID", 1161 - "goToDashboard": "대시보드로 이동" 1162 } 1163 }, 1164 "outbound": {
··· 189 "title": "DID 문서 편집기", 190 "preview": "현재 DID 문서", 191 "verificationMethods": "검증 방법 (서명 키)", 192 + "verificationMethodsDesc": "DID를 대신하여 동작할 수 있는 서명 키입니다. 새 PDS로 마이그레이션할 때 해당 서명 키를 여기에 추가하세요.", 193 "addKey": "키 추가", 194 "removeKey": "삭제", 195 "keyId": "키 ID", 196 "keyIdPlaceholder": "#atproto", 197 "publicKey": "공개 키 (Multibase)", 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "구성된 검증 방법이 없습니다. 로컬 PDS 키를 사용 중입니다.", 200 "alsoKnownAs": "다른 이름 (핸들)", 201 + "alsoKnownAsDesc": "DID를 가리키는 핸들입니다. 새 PDS에서 핸들이 변경되면 업데이트하세요.", 202 "addHandle": "핸들 추가", 203 + "removeHandle": "삭제", 204 + "handle": "핸들", 205 "handlePlaceholder": "at://handle.pds.com", 206 + "noHandles": "구성된 핸들이 없습니다. 로컬 핸들을 사용 중입니다.", 207 + "serviceEndpoint": "서비스 엔드포인트", 208 + "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 209 + "currentPds": "현재 PDS URL", 210 "save": "변경사항 저장", 211 "saving": "저장 중...", 212 "success": "DID 문서가 업데이트되었습니다", 213 + "saveFailed": "DID 문서 저장에 실패했습니다", 214 + "loadFailed": "DID 문서 로드에 실패했습니다", 215 + "invalidMultibase": "공개 키는 'z'로 시작하는 유효한 multibase 문자열이어야 합니다", 216 + "invalidHandle": "핸들은 at:// URI여야 합니다 (예: at://handle.example.com)", 217 "helpTitle": "이것은 무엇인가요?", 218 "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요." 219 }, ··· 902 "reauth": { 903 "title": "재인증 필요", 904 "subtitle": "계속하려면 본인 확인을 해주세요.", 905 + "password": "비밀번호", 906 + "totp": "TOTP", 907 + "passkey": "패스키", 908 + "authenticatorCode": "인증 코드", 909 "usePassword": "비밀번호 사용", 910 "usePasskey": "패스키 사용", 911 "useTotp": "인증 앱 사용", ··· 913 "totpPlaceholder": "6자리 코드 입력", 914 "verify": "확인", 915 "verifying": "확인 중...", 916 + "authenticating": "인증 중...", 917 + "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 918 "cancel": "취소" 919 }, 920 "verifyChannel": { ··· 1077 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1078 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1079 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1080 + "comingSoon": "곧 출시 예정", 1081 + "oauthCompleting": "인증 완료 중...", 1082 + "oauthFailed": "인증 실패", 1083 + "tryAgain": "다시 시도", 1084 "resume": { 1085 "title": "마이그레이션을 재개하시겠습니까?", 1086 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", ··· 1100 "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", 1101 "understand": "위험을 이해하고 계속 진행합니다" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "현재 핸들 입력", 1105 + "titleResume": "마이그레이션 재개", 1106 + "desc": "마이그레이션할 계정의 핸들을 입력하세요.", 1107 + "descResume": "마이그레이션을 계속하려면 소스 PDS에 재인증하세요.", 1108 "handle": "핸들", 1109 + "handlePlaceholder": "alice.bsky.social", 1110 + "handleHint": "현재 PDS에서의 핸들", 1111 + "continue": "계속", 1112 + "connecting": "연결 중...", 1113 + "reauthenticate": "재인증", 1114 + "resumeTitle": "마이그레이션 진행 중", 1115 + "resumeFrom": "출발지", 1116 + "resumeTo": "목적지", 1117 + "resumeProgress": "진행 상황", 1118 + "resumeOAuthNote": "계속하려면 OAuth로 재인증이 필요합니다." 1119 }, 1120 "chooseHandle": { 1121 "title": "새 핸들 선택", 1122 "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", 1123 + "migratingFrom": "마이그레이션 원본", 1124 + "newHandle": "새 핸들", 1125 + "checkingAvailability": "사용 가능 여부 확인 중...", 1126 + "handleAvailable": "핸들을 사용할 수 있습니다!", 1127 + "handleTaken": "핸들이 이미 사용 중입니다", 1128 + "handleHint": "전체 핸들(예: alice.mydomain.com)을 입력하여 자체 도메인을 사용할 수도 있습니다", 1129 + "email": "이메일 주소", 1130 + "authMethod": "인증 방법", 1131 + "authPassword": "비밀번호", 1132 + "authPasswordDesc": "기존 비밀번호 기반 로그인", 1133 + "authPasskey": "패스키", 1134 + "authPasskeyDesc": "생체 인식 또는 보안 키를 사용한 비밀번호 없는 로그인", 1135 + "password": "비밀번호", 1136 + "passwordHint": "최소 8자", 1137 + "passkeyInfo": "계정 생성 후 패스키를 설정합니다. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.", 1138 + "inviteCode": "초대 코드" 1139 }, 1140 "review": { 1141 "title": "마이그레이션 검토", 1142 + "desc": "마이그레이션 세부 정보를 확인하세요.", 1143 "currentHandle": "현재 핸들", 1144 "newHandle": "새 핸들", 1145 + "did": "DID", 1146 "sourcePds": "소스 PDS", 1147 + "targetPds": "대상 PDS", 1148 "email": "이메일", 1149 + "authentication": "인증", 1150 + "authPasskey": "패스키 (비밀번호 없음)", 1151 + "authPassword": "비밀번호", 1152 "inviteCode": "초대 코드", 1153 + "warning": "\"마이그레이션 시작\"을 클릭하면 저장소와 데이터 전송이 시작됩니다. 이 과정은 쉽게 되돌릴 수 없습니다.", 1154 + "startMigration": "마이그레이션 시작", 1155 + "starting": "시작 중..." 1156 }, 1157 "migrating": { 1158 + "title": "마이그레이션 진행 중", 1159 + "desc": "계정을 전송하는 중입니다...", 1160 + "exportRepo": "저장소 내보내기", 1161 + "importRepo": "저장소 가져오기", 1162 + "migrateBlobs": "blob 마이그레이션", 1163 + "migratePrefs": "환경설정 마이그레이션" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "패스키 설정", 1167 + "desc": "이메일이 인증되었습니다. 안전한 비밀번호 없는 로그인을 위해 패스키를 설정하세요.", 1168 + "nameLabel": "패스키 이름 (선택사항)", 1169 + "namePlaceholder": "예: MacBook Pro, iPhone", 1170 + "nameHint": "이 패스키를 식별하기 위한 이름", 1171 + "instructions": "아래 버튼을 클릭하여 패스키를 등록하세요. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.", 1172 + "register": "패스키 등록", 1173 + "registering": "등록 중..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "앱 비밀번호 저장", 1177 + "desc": "패스키가 생성되었습니다. 아직 패스키를 지원하지 않는 앱에서 사용할 앱 비밀번호가 생성되었습니다.", 1178 + "warning": "이 앱 비밀번호는 아직 패스키를 지원하지 않는 앱(예: bsky.app)에 로그인할 때 필요합니다. 이 비밀번호는 한 번만 표시됩니다.", 1179 + "label": "앱 비밀번호:", 1180 + "saved": "앱 비밀번호를 안전한 곳에 저장했습니다", 1181 + "continue": "계속" 1182 }, 1183 "emailVerify": { 1184 "title": "이메일 인증", ··· 1191 "verifying": "인증 중..." 1192 }, 1193 "plcToken": { 1194 + "title": "마이그레이션 확인", 1195 + "desc": "이전 계정에 등록된 이메일로 인증 코드가 전송되었습니다.", 1196 + "info": "이 코드는 계정 접근 권한을 확인하고 이 PDS를 가리키도록 아이덴티티 업데이트를 승인합니다.", 1197 + "tokenLabel": "인증 코드", 1198 + "tokenPlaceholder": "이메일에서 받은 코드 입력", 1199 + "resend": "코드 재전송", 1200 + "complete": "마이그레이션 완료", 1201 + "completing": "확인 중..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "DID 문서 업데이트", ··· 1221 "success": { 1222 "title": "마이그레이션 완료!", 1223 "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", 1224 + "yourNewHandle": "새 핸들", 1225 "did": "DID", 1226 + "blobsWarning": "{count}개의 blob을 마이그레이션할 수 없습니다. 더 이상 사용할 수 없는 이미지나 기타 미디어일 수 있습니다.", 1227 + "redirecting": "대시보드로 리디렉션 중..." 1228 + }, 1229 + "error": { 1230 + "title": "마이그레이션 오류", 1231 + "desc": "마이그레이션 중 오류가 발생했습니다.", 1232 + "startOver": "처음부터 다시 시작" 1233 + }, 1234 + "common": { 1235 + "back": "뒤로", 1236 + "cancel": "취소", 1237 + "continue": "계속", 1238 + "whatWillHappen": "진행 과정:", 1239 + "step1": "현재 PDS에 로그인", 1240 + "step2": "이 서버에서 새 핸들 선택", 1241 + "step3": "저장소와 blob이 전송됩니다", 1242 + "step4": "이메일로 마이그레이션 확인", 1243 + "step5": "아이덴티티가 여기를 가리키도록 업데이트됩니다", 1244 + "beforeProceed": "진행하기 전에:", 1245 + "warning1": "현재 계정에 등록된 이메일에 접근할 수 있어야 합니다", 1246 + "warning2": "대용량 계정 전송에는 몇 분이 걸릴 수 있습니다", 1247 + "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" 1248 } 1249 }, 1250 "outbound": {
+119 -33
frontend/src/locales/sv.json
··· 189 "title": "DID-dokumentredigerare", 190 "preview": "Nuvarande DID-dokument", 191 "verificationMethods": "Verifieringsmetoder (signeringsnycklar)", 192 "addKey": "Lägg till nyckel", 193 "removeKey": "Ta bort", 194 "keyId": "Nyckel-ID", 195 "keyIdPlaceholder": "#atproto", 196 "publicKey": "Publik nyckel (Multibase)", 197 "publicKeyPlaceholder": "zQ3sh...", 198 "alsoKnownAs": "Även känd som (användarnamn)", 199 "addHandle": "Lägg till användarnamn", 200 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)", 202 "save": "Spara ändringar", 203 "saving": "Sparar...", 204 "success": "DID-dokumentet har uppdaterats", 205 "helpTitle": "Vad är detta?", 206 "helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats." 207 }, ··· 890 "reauth": { 891 "title": "Återautentisering krävs", 892 "subtitle": "Verifiera din identitet för att fortsätta.", 893 "usePassword": "Använd lösenord", 894 "usePasskey": "Använd nyckel", 895 "useTotp": "Använd autentiserare", ··· 897 "totpPlaceholder": "Ange 6-siffrig kod", 898 "verify": "Verifiera", 899 "verifying": "Verifierar...", 900 "cancel": "Avbryt" 901 }, 902 "verifyChannel": { ··· 1059 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1060 "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.", 1061 "learnMore": "Läs mer om flyttningsrisker", 1062 "resume": { 1063 "title": "Återuppta flytt?", 1064 "incomplete": "Du har en ofullständig flytt pågående:", ··· 1078 "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", 1079 "understand": "Jag förstår riskerna och vill fortsätta" 1080 }, 1081 - "sourceLogin": { 1082 - "title": "Logga in på din nuvarande PDS", 1083 - "desc": "Ange uppgifterna för kontot du vill flytta.", 1084 "handle": "Användarnamn", 1085 - "handlePlaceholder": "du.bsky.social", 1086 - "password": "Lösenord", 1087 - "twoFactorCode": "Tvåfaktorkod", 1088 - "twoFactorRequired": "Tvåfaktorautentisering krävs", 1089 - "signIn": "Logga in och fortsätt" 1090 }, 1091 "chooseHandle": { 1092 "title": "Välj ditt nya användarnamn", 1093 "desc": "Välj ett användarnamn för ditt konto på denna PDS.", 1094 - "handleHint": "Ditt fullständiga användarnamn blir: @{handle}" 1095 }, 1096 "review": { 1097 "title": "Granska flytt", 1098 - "desc": "Granska och bekräfta dina flyttdetaljer.", 1099 "currentHandle": "Nuvarande användarnamn", 1100 "newHandle": "Nytt användarnamn", 1101 - "sourcePds": "Käll-PDS", 1102 - "targetPds": "Denna PDS", 1103 "email": "E-post", 1104 "inviteCode": "Inbjudningskod", 1105 - "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1106 - "startMigration": "Starta flytt" 1107 }, 1108 "migrating": { 1109 - "title": "Flyttar ditt konto", 1110 - "desc": "Vänta medan vi överför din data...", 1111 - "gettingServiceAuth": "Hämtar tjänstauktorisering...", 1112 - "creatingAccount": "Skapar konto på ny PDS...", 1113 - "exportingRepo": "Exporterar arkiv...", 1114 - "importingRepo": "Importerar arkiv...", 1115 - "countingBlobs": "Räknar blobbar...", 1116 - "migratingBlobs": "Flyttar blobbar ({current}/{total})...", 1117 - "migratingPrefs": "Flyttar inställningar...", 1118 - "requestingPlc": "Begär PLC-operation..." 1119 }, 1120 "emailVerify": { 1121 "title": "Verifiera din e-post", ··· 1128 "verifying": "Verifierar..." 1129 }, 1130 "plcToken": { 1131 - "title": "Verifiera din identitet", 1132 - "desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.", 1133 - "tokenLabel": "Verifieringstoken", 1134 - "tokenPlaceholder": "Ange token från din e-post", 1135 - "resend": "Skicka igen", 1136 - "resending": "Skickar..." 1137 }, 1138 "didWebUpdate": { 1139 "title": "Uppdatera ditt DID-dokument", ··· 1156 "success": { 1157 "title": "Flytt klar!", 1158 "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", 1159 - "newHandle": "Nytt användarnamn", 1160 "did": "DID", 1161 - "goToDashboard": "Gå till instrumentpanel" 1162 } 1163 }, 1164 "outbound": {
··· 189 "title": "DID-dokumentredigerare", 190 "preview": "Nuvarande DID-dokument", 191 "verificationMethods": "Verifieringsmetoder (signeringsnycklar)", 192 + "verificationMethodsDesc": "Signeringsnycklar som kan agera å din DIDs vägnar. När du migrerar till en ny PDS, lägg till deras signeringsnyckel här.", 193 "addKey": "Lägg till nyckel", 194 "removeKey": "Ta bort", 195 "keyId": "Nyckel-ID", 196 "keyIdPlaceholder": "#atproto", 197 "publicKey": "Publik nyckel (Multibase)", 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "Inga verifieringsmetoder konfigurerade. Använder lokal PDS-nyckel.", 200 "alsoKnownAs": "Även känd som (användarnamn)", 201 + "alsoKnownAsDesc": "Användarnamn som pekar på din DID. Uppdatera detta när ditt användarnamn ändras på en ny PDS.", 202 "addHandle": "Lägg till användarnamn", 203 + "removeHandle": "Ta bort", 204 + "handle": "Användarnamn", 205 "handlePlaceholder": "at://handle.pds.com", 206 + "noHandles": "Inga användarnamn konfigurerade. Använder lokalt användarnamn.", 207 + "serviceEndpoint": "Tjänstslutpunkt", 208 + "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", 209 + "currentPds": "Nuvarande PDS-URL", 210 "save": "Spara ändringar", 211 "saving": "Sparar...", 212 "success": "DID-dokumentet har uppdaterats", 213 + "saveFailed": "Kunde inte spara DID-dokument", 214 + "loadFailed": "Kunde inte ladda DID-dokument", 215 + "invalidMultibase": "Publik nyckel måste vara en giltig multibase-sträng som börjar med 'z'", 216 + "invalidHandle": "Användarnamn måste vara en at:// URI (t.ex. at://handle.example.com)", 217 "helpTitle": "Vad är detta?", 218 "helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats." 219 }, ··· 902 "reauth": { 903 "title": "Återautentisering krävs", 904 "subtitle": "Verifiera din identitet för att fortsätta.", 905 + "password": "Lösenord", 906 + "totp": "TOTP", 907 + "passkey": "Passkey", 908 + "authenticatorCode": "Autentiseringskod", 909 "usePassword": "Använd lösenord", 910 "usePasskey": "Använd nyckel", 911 "useTotp": "Använd autentiserare", ··· 913 "totpPlaceholder": "Ange 6-siffrig kod", 914 "verify": "Verifiera", 915 "verifying": "Verifierar...", 916 + "authenticating": "Autentiserar...", 917 + "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 918 "cancel": "Avbryt" 919 }, 920 "verifyChannel": { ··· 1077 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1078 "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.", 1079 "learnMore": "Läs mer om flyttningsrisker", 1080 + "comingSoon": "Kommer snart", 1081 + "oauthCompleting": "Slutför autentisering...", 1082 + "oauthFailed": "Autentisering misslyckades", 1083 + "tryAgain": "Försök igen", 1084 "resume": { 1085 "title": "Återuppta flytt?", 1086 "incomplete": "Du har en ofullständig flytt pågående:", ··· 1100 "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", 1101 "understand": "Jag förstår riskerna och vill fortsätta" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "Ange ditt nuvarande användarnamn", 1105 + "titleResume": "Återuppta flytt", 1106 + "desc": "Ange användarnamnet för kontot du vill flytta.", 1107 + "descResume": "Autentisera dig igen till din käll-PDS för att fortsätta flytten.", 1108 "handle": "Användarnamn", 1109 + "handlePlaceholder": "alice.bsky.social", 1110 + "handleHint": "Ditt nuvarande användarnamn på din befintliga PDS", 1111 + "continue": "Fortsätt", 1112 + "connecting": "Ansluter...", 1113 + "reauthenticate": "Autentisera igen", 1114 + "resumeTitle": "Flytt pågår", 1115 + "resumeFrom": "Från", 1116 + "resumeTo": "Till", 1117 + "resumeProgress": "Framsteg", 1118 + "resumeOAuthNote": "Du måste autentisera dig igen via OAuth för att fortsätta." 1119 }, 1120 "chooseHandle": { 1121 "title": "Välj ditt nya användarnamn", 1122 "desc": "Välj ett användarnamn för ditt konto på denna PDS.", 1123 + "migratingFrom": "Flyttar från", 1124 + "newHandle": "Nytt användarnamn", 1125 + "checkingAvailability": "Kontrollerar tillgänglighet...", 1126 + "handleAvailable": "Användarnamnet är tillgängligt!", 1127 + "handleTaken": "Användarnamnet är redan taget", 1128 + "handleHint": "Du kan också använda din egen domän genom att ange det fullständiga användarnamnet (t.ex. alice.mindomän.se)", 1129 + "email": "E-postadress", 1130 + "authMethod": "Autentiseringsmetod", 1131 + "authPassword": "Lösenord", 1132 + "authPasswordDesc": "Traditionell lösenordsbaserad inloggning", 1133 + "authPasskey": "Passkey", 1134 + "authPasskeyDesc": "Lösenordslös inloggning med biometri eller säkerhetsnyckel", 1135 + "password": "Lösenord", 1136 + "passwordHint": "Minst 8 tecken", 1137 + "passkeyInfo": "Du kommer att konfigurera en passkey efter att ditt konto skapats. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.", 1138 + "inviteCode": "Inbjudningskod" 1139 }, 1140 "review": { 1141 "title": "Granska flytt", 1142 + "desc": "Bekräfta detaljerna för din flytt.", 1143 "currentHandle": "Nuvarande användarnamn", 1144 "newHandle": "Nytt användarnamn", 1145 + "did": "DID", 1146 + "sourcePds": "Från PDS", 1147 + "targetPds": "Till PDS", 1148 "email": "E-post", 1149 + "authentication": "Autentisering", 1150 + "authPasskey": "Passkey (lösenordslös)", 1151 + "authPassword": "Lösenord", 1152 "inviteCode": "Inbjudningskod", 1153 + "warning": "När du klickar på \"Starta flytt\" börjar ditt arkiv och data överföras. Denna process kan inte enkelt ångras.", 1154 + "startMigration": "Starta flytt", 1155 + "starting": "Startar..." 1156 }, 1157 "migrating": { 1158 + "title": "Flytt pågår", 1159 + "desc": "Vänta medan ditt konto överförs...", 1160 + "exportRepo": "Exportera arkiv", 1161 + "importRepo": "Importera arkiv", 1162 + "migrateBlobs": "Flytta blobbar", 1163 + "migratePrefs": "Flytta inställningar" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "Konfigurera din passkey", 1167 + "desc": "Din e-post har verifierats. Konfigurera nu din passkey för säker, lösenordslös inloggning.", 1168 + "nameLabel": "Passkey-namn (valfritt)", 1169 + "namePlaceholder": "t.ex. MacBook Pro, iPhone", 1170 + "nameHint": "Ett vänligt namn för att identifiera denna passkey", 1171 + "instructions": "Klicka på knappen nedan för att registrera din passkey. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.", 1172 + "register": "Registrera passkey", 1173 + "registering": "Registrerar..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "Spara ditt applösenord", 1177 + "desc": "Din passkey har skapats. Ett applösenord har genererats för dig att använda med appar som inte stödjer passkeys ännu.", 1178 + "warning": "Detta applösenord krävs för att logga in i appar som inte stödjer passkeys ännu (som bsky.app). Du kommer bara att se detta lösenord en gång.", 1179 + "label": "Applösenord för", 1180 + "saved": "Jag har sparat mitt applösenord på en säker plats", 1181 + "continue": "Fortsätt" 1182 }, 1183 "emailVerify": { 1184 "title": "Verifiera din e-post", ··· 1191 "verifying": "Verifierar..." 1192 }, 1193 "plcToken": { 1194 + "title": "Verifiera flytt", 1195 + "desc": "En verifieringskod har skickats till e-posten registrerad på ditt gamla konto.", 1196 + "info": "Denna kod bekräftar att du har tillgång till kontot och auktoriserar uppdatering av din identitet för att peka på denna PDS.", 1197 + "tokenLabel": "Verifieringskod", 1198 + "tokenPlaceholder": "Ange kod från e-post", 1199 + "resend": "Skicka kod igen", 1200 + "complete": "Slutför flytt", 1201 + "completing": "Verifierar..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "Uppdatera ditt DID-dokument", ··· 1221 "success": { 1222 "title": "Flytt klar!", 1223 "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", 1224 + "yourNewHandle": "Ditt nya användarnamn", 1225 "did": "DID", 1226 + "blobsWarning": "{count} blobbar kunde inte flyttas. Dessa kan vara bilder eller annan media som inte längre är tillgängliga.", 1227 + "redirecting": "Omdirigerar till instrumentpanel..." 1228 + }, 1229 + "error": { 1230 + "title": "Flyttfel", 1231 + "desc": "Ett fel uppstod under flytten.", 1232 + "startOver": "Börja om" 1233 + }, 1234 + "common": { 1235 + "back": "Tillbaka", 1236 + "cancel": "Avbryt", 1237 + "continue": "Fortsätt", 1238 + "whatWillHappen": "Vad som kommer att hända:", 1239 + "step1": "Logga in på din nuvarande PDS", 1240 + "step2": "Välj ditt nya användarnamn på denna server", 1241 + "step3": "Ditt arkiv och blobbar kommer att överföras", 1242 + "step4": "Verifiera flytten via e-post", 1243 + "step5": "Din identitet kommer att uppdateras för att peka hit", 1244 + "beforeProceed": "Innan du fortsätter:", 1245 + "warning1": "Du behöver tillgång till e-posten registrerad på ditt nuvarande konto", 1246 + "warning2": "Stora konton kan ta flera minuter att överföra", 1247 + "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1248 } 1249 }, 1250 "outbound": {
+117 -31
frontend/src/locales/zh.json
··· 189 "title": "DID 文档编辑器", 190 "preview": "当前 DID 文档", 191 "verificationMethods": "验证方法(签名密钥)", 192 "addKey": "添加密钥", 193 "removeKey": "删除", 194 "keyId": "密钥 ID", 195 "keyIdPlaceholder": "#atproto", 196 "publicKey": "公钥(Multibase)", 197 "publicKeyPlaceholder": "zQ3sh...", 198 "alsoKnownAs": "别名(用户名)", 199 "addHandle": "添加用户名", 200 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "服务端点(当前 PDS)", 202 "save": "保存更改", 203 "saving": "保存中...", 204 "success": "DID 文档已更新", 205 "helpTitle": "这是什么?", 206 "helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。" 207 }, ··· 890 "reauth": { 891 "title": "需要重新验证", 892 "subtitle": "请验证您的身份以继续。", 893 "usePassword": "使用密码", 894 "usePasskey": "使用通行密钥", 895 "useTotp": "使用身份验证器", ··· 897 "totpPlaceholder": "输入6位验证码", 898 "verify": "验证", 899 "verifying": "验证中...", 900 "cancel": "取消" 901 }, 902 "verifyChannel": { ··· 1059 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1060 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1061 "learnMore": "了解更多迁移风险", 1062 "resume": { 1063 "title": "恢复迁移?", 1064 "incomplete": "您有一个未完成的迁移:", ··· 1078 "desc": "将您现有的AT Protocol账户移至此服务器。", 1079 "understand": "我了解风险并希望继续" 1080 }, 1081 - "sourceLogin": { 1082 - "title": "登录到您当前的PDS", 1083 - "desc": "输入您要迁移的账户凭据。", 1084 "handle": "用户名", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "密码", 1087 - "twoFactorCode": "双因素验证码", 1088 - "twoFactorRequired": "需要双因素认证", 1089 - "signIn": "登录并继续" 1090 }, 1091 "chooseHandle": { 1092 "title": "选择新用户名", 1093 "desc": "为您在此PDS上的账户选择用户名。", 1094 - "handleHint": "您的完整用户名将是:@{handle}" 1095 }, 1096 "review": { 1097 "title": "检查迁移", 1098 - "desc": "请检查并确认您的迁移详情。", 1099 "currentHandle": "当前用户名", 1100 "newHandle": "新用户名", 1101 "sourcePds": "源PDS", 1102 - "targetPds": "此PDS", 1103 "email": "邮箱", 1104 "inviteCode": "邀请码", 1105 - "confirm": "我确认要迁移我的账户", 1106 - "startMigration": "开始迁移" 1107 }, 1108 "migrating": { 1109 - "title": "正在迁移您的账户", 1110 - "desc": "请稍候,正在转移您的数据...", 1111 - "gettingServiceAuth": "正在获取服务授权...", 1112 - "creatingAccount": "正在新PDS上创建账户...", 1113 - "exportingRepo": "正在导出存储库...", 1114 - "importingRepo": "正在导入存储库...", 1115 - "countingBlobs": "正在统计blob...", 1116 - "migratingBlobs": "正在迁移blob ({current}/{total})...", 1117 - "migratingPrefs": "正在迁移偏好设置...", 1118 - "requestingPlc": "正在请求PLC操作..." 1119 }, 1120 "emailVerify": { 1121 "title": "验证您的邮箱", ··· 1128 "verifying": "验证中..." 1129 }, 1130 "plcToken": { 1131 - "title": "验证您的身份", 1132 - "desc": "验证码已发送到您在当前PDS注册的邮箱。", 1133 - "tokenLabel": "验证令牌", 1134 - "tokenPlaceholder": "输入邮件中的令牌", 1135 "resend": "重新发送", 1136 - "resending": "发送中..." 1137 }, 1138 "didWebUpdate": { 1139 "title": "更新您的DID文档", ··· 1156 "success": { 1157 "title": "迁移完成!", 1158 "desc": "您的账户已成功迁移到此PDS。", 1159 - "newHandle": "新用户名", 1160 "did": "DID", 1161 - "goToDashboard": "前往仪表板" 1162 } 1163 }, 1164 "outbound": {
··· 189 "title": "DID 文档编辑器", 190 "preview": "当前 DID 文档", 191 "verificationMethods": "验证方法(签名密钥)", 192 + "verificationMethodsDesc": "可以代表您的 DID 进行操作的签名密钥。迁移到新 PDS 时,请在此添加其签名密钥。", 193 "addKey": "添加密钥", 194 "removeKey": "删除", 195 "keyId": "密钥 ID", 196 "keyIdPlaceholder": "#atproto", 197 "publicKey": "公钥(Multibase)", 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "未配置验证方法。正在使用本地 PDS 密钥。", 200 "alsoKnownAs": "别名(用户名)", 201 + "alsoKnownAsDesc": "指向您的 DID 的用户名。当您在新 PDS 上更改用户名时请更新此项。", 202 "addHandle": "添加用户名", 203 + "removeHandle": "删除", 204 + "handle": "用户名", 205 "handlePlaceholder": "at://handle.pds.com", 206 + "noHandles": "未配置用户名。正在使用本地用户名。", 207 + "serviceEndpoint": "服务端点", 208 + "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 209 + "currentPds": "当前 PDS URL", 210 "save": "保存更改", 211 "saving": "保存中...", 212 "success": "DID 文档已更新", 213 + "saveFailed": "保存 DID 文档失败", 214 + "loadFailed": "加载 DID 文档失败", 215 + "invalidMultibase": "公钥必须是以 'z' 开头的有效 multibase 字符串", 216 + "invalidHandle": "用户名必须是 at:// URI(例如:at://handle.example.com)", 217 "helpTitle": "这是什么?", 218 "helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。" 219 }, ··· 902 "reauth": { 903 "title": "需要重新验证", 904 "subtitle": "请验证您的身份以继续。", 905 + "password": "密码", 906 + "totp": "TOTP", 907 + "passkey": "通行密钥", 908 + "authenticatorCode": "验证码", 909 "usePassword": "使用密码", 910 "usePasskey": "使用通行密钥", 911 "useTotp": "使用身份验证器", ··· 913 "totpPlaceholder": "输入6位验证码", 914 "verify": "验证", 915 "verifying": "验证中...", 916 + "authenticating": "正在验证...", 917 + "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 918 "cancel": "取消" 919 }, 920 "verifyChannel": { ··· 1077 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1078 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1079 "learnMore": "了解更多迁移风险", 1080 + "comingSoon": "即将推出", 1081 + "oauthCompleting": "正在完成身份验证...", 1082 + "oauthFailed": "身份验证失败", 1083 + "tryAgain": "重试", 1084 "resume": { 1085 "title": "恢复迁移?", 1086 "incomplete": "您有一个未完成的迁移:", ··· 1100 "desc": "将您现有的AT Protocol账户移至此服务器。", 1101 "understand": "我了解风险并希望继续" 1102 }, 1103 + "sourceAuth": { 1104 + "title": "输入您当前的用户名", 1105 + "titleResume": "恢复迁移", 1106 + "desc": "输入您要迁移的账户用户名。", 1107 + "descResume": "重新验证您的源PDS以继续迁移。", 1108 "handle": "用户名", 1109 + "handlePlaceholder": "alice.bsky.social", 1110 + "handleHint": "您在现有PDS上的当前用户名", 1111 + "continue": "继续", 1112 + "connecting": "连接中...", 1113 + "reauthenticate": "重新验证", 1114 + "resumeTitle": "迁移进行中", 1115 + "resumeFrom": "来自", 1116 + "resumeTo": "迁移至", 1117 + "resumeProgress": "进度", 1118 + "resumeOAuthNote": "您需要通过OAuth重新验证才能继续。" 1119 }, 1120 "chooseHandle": { 1121 "title": "选择新用户名", 1122 "desc": "为您在此PDS上的账户选择用户名。", 1123 + "migratingFrom": "迁移自", 1124 + "newHandle": "新用户名", 1125 + "checkingAvailability": "检查可用性...", 1126 + "handleAvailable": "用户名可用!", 1127 + "handleTaken": "用户名已被占用", 1128 + "handleHint": "您也可以输入完整的用户名(如alice.mydomain.com)来使用您自己的域名", 1129 + "email": "邮箱地址", 1130 + "authMethod": "身份验证方式", 1131 + "authPassword": "密码", 1132 + "authPasswordDesc": "传统的密码登录", 1133 + "authPasskey": "通行密钥", 1134 + "authPasskeyDesc": "使用生物识别或安全密钥的无密码登录", 1135 + "password": "密码", 1136 + "passwordHint": "至少8个字符", 1137 + "passkeyInfo": "您将在账户创建后设置通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。", 1138 + "inviteCode": "邀请码" 1139 }, 1140 "review": { 1141 "title": "检查迁移", 1142 + "desc": "确认您的迁移详情。", 1143 "currentHandle": "当前用户名", 1144 "newHandle": "新用户名", 1145 + "did": "DID", 1146 "sourcePds": "源PDS", 1147 + "targetPds": "目标PDS", 1148 "email": "邮箱", 1149 + "authentication": "身份验证", 1150 + "authPasskey": "通行密钥(无密码)", 1151 + "authPassword": "密码", 1152 "inviteCode": "邀请码", 1153 + "warning": "点击「开始迁移」后,您的存储库和数据将开始转移。此过程无法轻易撤销。", 1154 + "startMigration": "开始迁移", 1155 + "starting": "启动中..." 1156 }, 1157 "migrating": { 1158 + "title": "迁移进行中", 1159 + "desc": "正在转移您的账户...", 1160 + "exportRepo": "导出存储库", 1161 + "importRepo": "导入存储库", 1162 + "migrateBlobs": "迁移blob", 1163 + "migratePrefs": "迁移偏好设置" 1164 + }, 1165 + "passkeySetup": { 1166 + "title": "设置您的通行密钥", 1167 + "desc": "您的邮箱已验证。现在设置通行密钥以实现安全的无密码登录。", 1168 + "nameLabel": "通行密钥名称(可选)", 1169 + "namePlaceholder": "例如:MacBook Pro、iPhone", 1170 + "nameHint": "用于识别此通行密钥的友好名称", 1171 + "instructions": "点击下方按钮注册您的通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。", 1172 + "register": "注册通行密钥", 1173 + "registering": "注册中..." 1174 + }, 1175 + "appPassword": { 1176 + "title": "保存您的应用密码", 1177 + "desc": "您的通行密钥已创建。已为您生成应用密码,用于尚不支持通行密钥的应用。", 1178 + "warning": "此应用密码用于登录尚不支持通行密钥的应用(如 bsky.app)。此密码仅显示一次。", 1179 + "label": "应用密码:", 1180 + "saved": "我已将应用密码保存在安全的地方", 1181 + "continue": "继续" 1182 }, 1183 "emailVerify": { 1184 "title": "验证您的邮箱", ··· 1191 "verifying": "验证中..." 1192 }, 1193 "plcToken": { 1194 + "title": "验证迁移", 1195 + "desc": "验证码已发送到您旧账户注册的邮箱。", 1196 + "info": "此代码确认您有权访问该账户,并授权将您的身份更新为指向此PDS。", 1197 + "tokenLabel": "验证码", 1198 + "tokenPlaceholder": "输入邮件中的验证码", 1199 "resend": "重新发送", 1200 + "complete": "完成迁移", 1201 + "completing": "验证中..." 1202 }, 1203 "didWebUpdate": { 1204 "title": "更新您的DID文档", ··· 1221 "success": { 1222 "title": "迁移完成!", 1223 "desc": "您的账户已成功迁移到此PDS。", 1224 + "yourNewHandle": "您的新用户名", 1225 "did": "DID", 1226 + "blobsWarning": "{count}个blob无法迁移。这些可能是不再可用的图片或其他媒体。", 1227 + "redirecting": "正在跳转到仪表板..." 1228 + }, 1229 + "error": { 1230 + "title": "迁移错误", 1231 + "desc": "迁移过程中发生错误。", 1232 + "startOver": "重新开始" 1233 + }, 1234 + "common": { 1235 + "back": "返回", 1236 + "cancel": "取消", 1237 + "continue": "继续", 1238 + "whatWillHappen": "将会发生什么:", 1239 + "step1": "登录到您当前的PDS", 1240 + "step2": "在此服务器上选择新用户名", 1241 + "step3": "您的存储库和blob将被转移", 1242 + "step4": "通过邮件验证迁移", 1243 + "step5": "您的身份将更新为指向此处", 1244 + "beforeProceed": "继续之前:", 1245 + "warning1": "您需要访问当前账户注册的邮箱", 1246 + "warning2": "大型账户可能需要几分钟才能转移", 1247 + "warning3": "迁移后您的旧账户将被停用" 1248 } 1249 }, 1250 "outbound": {
+150 -42
frontend/src/routes/Migration.svelte
··· 1 <script lang="ts"> 2 import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { 5 createInboundMigrationFlow, 6 createOutboundMigrationFlow, ··· 18 let direction = $state<Direction>('select') 19 let showResumeModal = $state(false) 20 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) 21 22 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 23 let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 24 25 - if (hasPendingMigration()) { 26 resumeInfo = getResumeInfo() 27 if (resumeInfo) { 28 - showResumeModal = true 29 } 30 } 31 ··· 106 {#if showResumeModal && resumeInfo} 107 <div class="modal-overlay"> 108 <div class="modal"> 109 - <h2>Resume Migration?</h2> 110 - <p>You have an incomplete migration in progress:</p> 111 <div class="resume-details"> 112 <div class="detail-row"> 113 - <span class="label">Direction:</span> 114 - <span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span> 115 </div> 116 {#if resumeInfo.sourceHandle} 117 <div class="detail-row"> 118 - <span class="label">From:</span> 119 <span class="value">{resumeInfo.sourceHandle}</span> 120 </div> 121 {/if} 122 {#if resumeInfo.targetHandle} 123 <div class="detail-row"> 124 - <span class="label">To:</span> 125 <span class="value">{resumeInfo.targetHandle}</span> 126 </div> 127 {/if} 128 <div class="detail-row"> 129 - <span class="label">Progress:</span> 130 <span class="value">{resumeInfo.progressSummary}</span> 131 </div> 132 </div> 133 - <p class="note">You will need to re-enter your credentials to continue.</p> 134 <div class="modal-actions"> 135 - <button class="ghost" onclick={handleStartOver}>Start Over</button> 136 - <button onclick={handleResume}>Resume</button> 137 </div> 138 </div> 139 </div> 140 {/if} 141 142 - {#if direction === 'select'} 143 <header class="page-header"> 144 - <h1>Account Migration</h1> 145 - <p class="subtitle">Move your AT Protocol identity between servers</p> 146 </header> 147 148 <div class="direction-cards"> 149 <button class="direction-card ghost" onclick={selectInbound}> 150 <div class="card-icon">↓</div> 151 - <h2>Migrate Here</h2> 152 - <p>Move your existing AT Protocol account to this PDS from another server.</p> 153 <ul class="features"> 154 - <li>Bring your DID and identity</li> 155 - <li>Transfer all your data</li> 156 - <li>Keep your followers</li> 157 </ul> 158 </button> 159 160 - <button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}> 161 <div class="card-icon">↑</div> 162 - <h2>Migrate Away</h2> 163 - <p>Move your account from this PDS to another server.</p> 164 <ul class="features"> 165 - <li>Export your repository</li> 166 - <li>Transfer to new PDS</li> 167 - <li>Update your identity</li> 168 </ul> 169 - {#if !auth.session} 170 - <p class="login-required">Login required</p> 171 - {/if} 172 </button> 173 </div> 174 175 <div class="info-section"> 176 - <h3>What is account migration?</h3> 177 - <p> 178 - Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). 179 - Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved. 180 - </p> 181 182 - <h3>Before you migrate</h3> 183 <ul> 184 - <li>You will need your current account credentials</li> 185 - <li>Migration requires email verification for security</li> 186 - <li>Large accounts with many images may take several minutes</li> 187 - <li>Your old PDS will be notified to deactivate your account</li> 188 </ul> 189 190 <div class="warning-box"> 191 - <strong>Important:</strong> Account migration is a significant action. Make sure you trust the destination PDS 192 - and understand that your data will be moved. If something goes wrong, recovery may require manual intervention. 193 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener"> 194 - Learn more about migration risks 195 </a> 196 </div> 197 </div> ··· 199 {:else if direction === 'inbound' && inboundFlow} 200 <InboundWizard 201 flow={inboundFlow} 202 onBack={handleBack} 203 onComplete={handleInboundComplete} 204 /> ··· 409 display: flex; 410 gap: var(--space-3); 411 justify-content: flex-end; 412 } 413 </style>
··· 1 <script lang="ts"> 2 import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 5 import { 6 createInboundMigrationFlow, 7 createOutboundMigrationFlow, ··· 19 let direction = $state<Direction>('select') 20 let showResumeModal = $state(false) 21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) 22 + let oauthError = $state<string | null>(null) 23 + let oauthLoading = $state(false) 24 25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 26 let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 27 + let oauthCallbackProcessed = $state(false) 28 + 29 + $effect(() => { 30 + if (oauthCallbackProcessed) return 31 32 + const url = new URL(window.location.href) 33 + const code = url.searchParams.get('code') 34 + const state = url.searchParams.get('state') 35 + const errorParam = url.searchParams.get('error') 36 + const errorDescription = url.searchParams.get('error_description') 37 + 38 + if (errorParam) { 39 + oauthCallbackProcessed = true 40 + oauthError = errorDescription || errorParam 41 + window.history.replaceState({}, '', '/#/migrate') 42 + return 43 + } 44 + 45 + if (code && state) { 46 + oauthCallbackProcessed = true 47 + window.history.replaceState({}, '', '/#/migrate') 48 + direction = 'inbound' 49 + oauthLoading = true 50 + inboundFlow = createInboundMigrationFlow() 51 + 52 + inboundFlow.handleOAuthCallback(code, state) 53 + .then(() => { 54 + oauthLoading = false 55 + }) 56 + .catch((e) => { 57 + oauthLoading = false 58 + oauthError = e.message || 'OAuth authentication failed' 59 + inboundFlow = null 60 + direction = 'select' 61 + }) 62 + return 63 + } 64 + }) 65 + 66 + const urlParams = new URLSearchParams(window.location.search) 67 + const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 68 + 69 + if (!hasOAuthCallback && hasPendingMigration()) { 70 resumeInfo = getResumeInfo() 71 if (resumeInfo) { 72 + const stored = loadMigrationState() 73 + if (stored) { 74 + if (stored.direction === 'inbound') { 75 + direction = 'inbound' 76 + inboundFlow = createInboundMigrationFlow() 77 + inboundFlow.resumeFromState(stored) 78 + } else { 79 + direction = 'outbound' 80 + outboundFlow = createOutboundMigrationFlow() 81 + } 82 + } 83 } 84 } 85 ··· 160 {#if showResumeModal && resumeInfo} 161 <div class="modal-overlay"> 162 <div class="modal"> 163 + <h2>{$_('migration.resume.title')}</h2> 164 + <p>{$_('migration.resume.incomplete')}</p> 165 <div class="resume-details"> 166 <div class="detail-row"> 167 + <span class="label">{$_('migration.resume.direction')}:</span> 168 + <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 169 </div> 170 {#if resumeInfo.sourceHandle} 171 <div class="detail-row"> 172 + <span class="label">{$_('migration.resume.from')}:</span> 173 <span class="value">{resumeInfo.sourceHandle}</span> 174 </div> 175 {/if} 176 {#if resumeInfo.targetHandle} 177 <div class="detail-row"> 178 + <span class="label">{$_('migration.resume.to')}:</span> 179 <span class="value">{resumeInfo.targetHandle}</span> 180 </div> 181 {/if} 182 <div class="detail-row"> 183 + <span class="label">{$_('migration.resume.progress')}:</span> 184 <span class="value">{resumeInfo.progressSummary}</span> 185 </div> 186 </div> 187 + <p class="note">{$_('migration.resume.reenterCredentials')}</p> 188 <div class="modal-actions"> 189 + <button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button> 190 + <button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button> 191 </div> 192 </div> 193 </div> 194 {/if} 195 196 + {#if oauthLoading} 197 + <div class="oauth-loading"> 198 + <div class="loading-spinner"></div> 199 + <p>{$_('migration.oauthCompleting')}</p> 200 + </div> 201 + {:else if oauthError} 202 + <div class="oauth-error"> 203 + <h2>{$_('migration.oauthFailed')}</h2> 204 + <p>{oauthError}</p> 205 + <button onclick={() => { oauthError = null; direction = 'select' }}>{$_('migration.tryAgain')}</button> 206 + </div> 207 + {:else if direction === 'select'} 208 <header class="page-header"> 209 + <h1>{$_('migration.title')}</h1> 210 + <p class="subtitle">{$_('migration.subtitle')}</p> 211 </header> 212 213 <div class="direction-cards"> 214 <button class="direction-card ghost" onclick={selectInbound}> 215 <div class="card-icon">↓</div> 216 + <h2>{$_('migration.migrateHere')}</h2> 217 + <p>{$_('migration.migrateHereDesc')}</p> 218 <ul class="features"> 219 + <li>{$_('migration.bringDid')}</li> 220 + <li>{$_('migration.transferData')}</li> 221 + <li>{$_('migration.keepFollowers')}</li> 222 </ul> 223 </button> 224 225 + <button class="direction-card ghost" onclick={selectOutbound} disabled> 226 <div class="card-icon">↑</div> 227 + <h2>{$_('migration.migrateAway')}</h2> 228 + <p>{$_('migration.migrateAwayDesc')}</p> 229 <ul class="features"> 230 + <li>{$_('migration.exportRepo')}</li> 231 + <li>{$_('migration.transferToPds')}</li> 232 + <li>{$_('migration.updateIdentity')}</li> 233 </ul> 234 + <p class="login-required">{$_('migration.comingSoon')}</p> 235 </button> 236 </div> 237 238 <div class="info-section"> 239 + <h3>{$_('migration.whatIsMigration')}</h3> 240 + <p>{$_('migration.whatIsMigrationDesc')}</p> 241 242 + <h3>{$_('migration.beforeMigrate')}</h3> 243 <ul> 244 + <li>{$_('migration.beforeMigrate1')}</li> 245 + <li>{$_('migration.beforeMigrate2')}</li> 246 + <li>{$_('migration.beforeMigrate3')}</li> 247 + <li>{$_('migration.beforeMigrate4')}</li> 248 </ul> 249 250 <div class="warning-box"> 251 + <strong>Important:</strong> {$_('migration.importantWarning')} 252 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener"> 253 + {$_('migration.learnMore')} 254 </a> 255 </div> 256 </div> ··· 258 {:else if direction === 'inbound' && inboundFlow} 259 <InboundWizard 260 flow={inboundFlow} 261 + {resumeInfo} 262 onBack={handleBack} 263 onComplete={handleInboundComplete} 264 /> ··· 469 display: flex; 470 gap: var(--space-3); 471 justify-content: flex-end; 472 + } 473 + 474 + .oauth-loading { 475 + display: flex; 476 + flex-direction: column; 477 + align-items: center; 478 + justify-content: center; 479 + padding: var(--space-12); 480 + text-align: center; 481 + } 482 + 483 + .loading-spinner { 484 + width: 48px; 485 + height: 48px; 486 + border: 3px solid var(--border); 487 + border-top-color: var(--accent); 488 + border-radius: 50%; 489 + animation: spin 1s linear infinite; 490 + margin-bottom: var(--space-4); 491 + } 492 + 493 + @keyframes spin { 494 + to { transform: rotate(360deg); } 495 + } 496 + 497 + .oauth-loading p { 498 + color: var(--text-secondary); 499 + margin: 0; 500 + } 501 + 502 + .oauth-error { 503 + max-width: 500px; 504 + margin: 0 auto; 505 + text-align: center; 506 + padding: var(--space-8); 507 + background: var(--error-bg); 508 + border: 1px solid var(--error-border); 509 + border-radius: var(--radius-xl); 510 + } 511 + 512 + .oauth-error h2 { 513 + margin: 0 0 var(--space-4) 0; 514 + color: var(--error-text); 515 + } 516 + 517 + .oauth-error p { 518 + color: var(--text-secondary); 519 + margin: 0 0 var(--space-5) 0; 520 } 521 </style>
+3 -3
frontend/src/styles/base.css
··· 229 } 230 231 code { 232 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 233 font-size: 0.9em; 234 background: var(--bg-tertiary); 235 padding: var(--space-1) var(--space-2); ··· 237 } 238 239 pre { 240 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 241 font-size: var(--text-sm); 242 background: var(--bg-tertiary); 243 padding: var(--space-4); ··· 400 } 401 402 .mono { 403 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 404 } 405 406 .mt-4 {
··· 229 } 230 231 code { 232 + font-family: var(--font-mono); 233 font-size: 0.9em; 234 background: var(--bg-tertiary); 235 padding: var(--space-1) var(--space-2); ··· 237 } 238 239 pre { 240 + font-family: var(--font-mono); 241 font-size: var(--text-sm); 242 background: var(--bg-tertiary); 243 padding: var(--space-4); ··· 400 } 401 402 .mono { 403 + font-family: var(--font-mono); 404 } 405 406 .mt-4 {
+567
frontend/src/styles/migration.css
···
··· 1 + .migration-wizard { 2 + max-width: var(--width-sm); 3 + margin: 0 auto; 4 + } 5 + 6 + .step-indicator { 7 + display: flex; 8 + align-items: center; 9 + justify-content: center; 10 + margin-bottom: var(--space-6); 11 + gap: var(--space-1); 12 + } 13 + 14 + .step { 15 + display: flex; 16 + align-items: center; 17 + justify-content: center; 18 + } 19 + 20 + .step-dot { 21 + width: 24px; 22 + height: 24px; 23 + border-radius: 50%; 24 + background: var(--bg-secondary); 25 + border: 2px solid var(--border-color); 26 + display: flex; 27 + align-items: center; 28 + justify-content: center; 29 + font-size: var(--text-xs); 30 + font-weight: var(--font-medium); 31 + color: var(--text-secondary); 32 + flex-shrink: 0; 33 + } 34 + 35 + .step.active .step-dot { 36 + background: var(--accent); 37 + border-color: var(--accent); 38 + color: var(--text-inverse); 39 + width: 28px; 40 + height: 28px; 41 + } 42 + 43 + .step.completed .step-dot { 44 + background: var(--success-bg); 45 + border-color: var(--success-text); 46 + color: var(--success-text); 47 + } 48 + 49 + .step-label { 50 + display: none; 51 + } 52 + 53 + .step-line { 54 + width: 16px; 55 + height: 2px; 56 + background: var(--border-color); 57 + flex-shrink: 0; 58 + } 59 + 60 + .step-line.completed { 61 + background: var(--success-text); 62 + } 63 + 64 + .current-step-label { 65 + text-align: center; 66 + font-size: var(--text-sm); 67 + color: var(--text-secondary); 68 + margin-bottom: var(--space-5); 69 + } 70 + 71 + .current-step-label strong { 72 + color: var(--text-primary); 73 + } 74 + 75 + .step-content { 76 + background: var(--bg-secondary); 77 + border-radius: var(--radius-xl); 78 + padding: var(--space-6); 79 + } 80 + 81 + .step-content h2 { 82 + margin: 0 0 var(--space-3) 0; 83 + } 84 + 85 + .step-content > p { 86 + color: var(--text-secondary); 87 + margin: 0 0 var(--space-5) 0; 88 + } 89 + 90 + .info-box { 91 + background: var(--accent-muted); 92 + border: 1px solid var(--border-color); 93 + border-radius: var(--radius-lg); 94 + padding: var(--space-5); 95 + margin-bottom: var(--space-5); 96 + } 97 + 98 + .info-box h3 { 99 + margin: 0 0 var(--space-3) 0; 100 + font-size: var(--text-base); 101 + } 102 + 103 + .info-box ol, 104 + .info-box ul { 105 + margin: 0; 106 + padding-left: var(--space-5); 107 + } 108 + 109 + .info-box li { 110 + margin-bottom: var(--space-2); 111 + color: var(--text-secondary); 112 + } 113 + 114 + .info-box p { 115 + margin: 0; 116 + color: var(--text-secondary); 117 + } 118 + 119 + .warning-box { 120 + background: var(--warning-bg); 121 + border: 1px solid var(--warning-border); 122 + border-radius: var(--radius-lg); 123 + padding: var(--space-5); 124 + margin-bottom: var(--space-5); 125 + font-size: var(--text-sm); 126 + } 127 + 128 + .warning-box strong { 129 + color: var(--warning-text); 130 + } 131 + 132 + .warning-box p { 133 + margin: var(--space-3) 0 0 0; 134 + color: var(--text-secondary); 135 + } 136 + 137 + .warning-box ul { 138 + margin: var(--space-3) 0 0 0; 139 + padding-left: var(--space-5); 140 + } 141 + 142 + .checkbox-label { 143 + display: inline-flex; 144 + align-items: flex-start; 145 + gap: var(--space-3); 146 + cursor: pointer; 147 + margin-bottom: var(--space-5); 148 + text-align: left; 149 + } 150 + 151 + .checkbox-label input[type="checkbox"] { 152 + width: 18px; 153 + height: 18px; 154 + margin: 0; 155 + flex-shrink: 0; 156 + } 157 + 158 + .button-row { 159 + display: flex; 160 + gap: var(--space-3); 161 + justify-content: flex-end; 162 + margin-top: var(--space-5); 163 + } 164 + 165 + .handle-input-group { 166 + display: flex; 167 + gap: var(--space-2); 168 + } 169 + 170 + .handle-input-group input { 171 + flex: 1; 172 + } 173 + 174 + .handle-input-group select { 175 + width: auto; 176 + } 177 + 178 + .current-info { 179 + background: var(--bg-primary); 180 + border-radius: var(--radius-lg); 181 + padding: var(--space-4); 182 + margin-bottom: var(--space-5); 183 + display: flex; 184 + justify-content: space-between; 185 + } 186 + 187 + .current-info .label { 188 + color: var(--text-secondary); 189 + } 190 + 191 + .current-info .value { 192 + font-weight: var(--font-medium); 193 + } 194 + 195 + .review-card { 196 + background: var(--bg-primary); 197 + border-radius: var(--radius-lg); 198 + padding: var(--space-4); 199 + margin-bottom: var(--space-5); 200 + } 201 + 202 + .review-row { 203 + display: flex; 204 + justify-content: space-between; 205 + padding: var(--space-3) 0; 206 + border-bottom: 1px solid var(--border-color); 207 + } 208 + 209 + .review-row:last-child { 210 + border-bottom: none; 211 + } 212 + 213 + .review-row .label { 214 + color: var(--text-secondary); 215 + } 216 + 217 + .review-row .value { 218 + font-weight: var(--font-medium); 219 + text-align: right; 220 + word-break: break-all; 221 + } 222 + 223 + .review-row .value.mono { 224 + font-family: var(--font-mono); 225 + font-size: var(--text-sm); 226 + } 227 + 228 + .progress-section { 229 + margin-bottom: var(--space-5); 230 + } 231 + 232 + .progress-item { 233 + display: flex; 234 + align-items: center; 235 + gap: var(--space-3); 236 + padding: var(--space-3) 0; 237 + color: var(--text-secondary); 238 + } 239 + 240 + .progress-item.completed { 241 + color: var(--success-text); 242 + } 243 + 244 + .progress-item.active { 245 + color: var(--accent); 246 + } 247 + 248 + .progress-item .icon { 249 + width: 24px; 250 + text-align: center; 251 + } 252 + 253 + .progress-bar { 254 + height: 8px; 255 + background: var(--bg-primary); 256 + border-radius: var(--radius-md); 257 + overflow: hidden; 258 + margin-bottom: var(--space-4); 259 + } 260 + 261 + .progress-fill { 262 + height: 100%; 263 + background: var(--accent); 264 + transition: width var(--transition-slow); 265 + } 266 + 267 + .status-text { 268 + text-align: center; 269 + color: var(--text-secondary); 270 + font-size: var(--text-sm); 271 + } 272 + 273 + .success-content { 274 + text-align: center; 275 + } 276 + 277 + .success-icon { 278 + width: 64px; 279 + height: 64px; 280 + background: var(--success-bg); 281 + color: var(--success-text); 282 + border-radius: 50%; 283 + display: flex; 284 + align-items: center; 285 + justify-content: center; 286 + font-size: var(--text-2xl); 287 + margin: 0 auto var(--space-5) auto; 288 + } 289 + 290 + .success-details { 291 + background: var(--bg-primary); 292 + border-radius: var(--radius-lg); 293 + padding: var(--space-4); 294 + margin: var(--space-5) 0; 295 + text-align: left; 296 + } 297 + 298 + .success-details .detail-row { 299 + display: flex; 300 + justify-content: space-between; 301 + padding: var(--space-2) 0; 302 + } 303 + 304 + .success-details .label { 305 + color: var(--text-secondary); 306 + } 307 + 308 + .success-details .value { 309 + font-weight: var(--font-medium); 310 + } 311 + 312 + .success-details .value.mono { 313 + font-family: var(--font-mono); 314 + font-size: var(--text-sm); 315 + } 316 + 317 + .redirect-text { 318 + color: var(--text-secondary); 319 + font-style: italic; 320 + } 321 + 322 + .code-block { 323 + background: var(--bg-primary); 324 + border: 1px solid var(--border-color); 325 + border-radius: var(--radius-lg); 326 + padding: var(--space-4); 327 + margin-bottom: var(--space-5); 328 + overflow-x: auto; 329 + } 330 + 331 + .code-block pre { 332 + margin: 0; 333 + font-family: var(--font-mono); 334 + font-size: var(--text-sm); 335 + white-space: pre-wrap; 336 + word-break: break-all; 337 + } 338 + 339 + .auth-method-options { 340 + display: flex; 341 + flex-direction: column; 342 + gap: var(--space-3); 343 + } 344 + 345 + label.auth-option { 346 + display: flex; 347 + flex-direction: row; 348 + align-items: center; 349 + gap: var(--space-3); 350 + padding: var(--space-4); 351 + border: 2px solid var(--border-color); 352 + border-radius: var(--radius-lg); 353 + cursor: pointer; 354 + margin-bottom: 0; 355 + transition: border-color var(--transition-normal), background-color var(--transition-normal); 356 + } 357 + 358 + .auth-option:hover { 359 + border-color: var(--accent); 360 + background: var(--bg-hover); 361 + } 362 + 363 + .auth-option.selected { 364 + border-color: var(--accent); 365 + background: var(--accent-muted); 366 + } 367 + 368 + .auth-option input[type="radio"] { 369 + flex-shrink: 0; 370 + width: 18px; 371 + height: 18px; 372 + margin: 0; 373 + } 374 + 375 + .auth-option-content { 376 + display: flex; 377 + flex-direction: column; 378 + gap: var(--space-1); 379 + } 380 + 381 + .auth-option-content strong { 382 + color: var(--text-primary); 383 + } 384 + 385 + .auth-option-content span { 386 + font-size: var(--text-sm); 387 + color: var(--text-secondary); 388 + } 389 + 390 + .loading-indicator { 391 + display: flex; 392 + flex-direction: column; 393 + align-items: center; 394 + gap: var(--space-4); 395 + padding: var(--space-8); 396 + } 397 + 398 + .spinner { 399 + width: 40px; 400 + height: 40px; 401 + border: 3px solid var(--border-color); 402 + border-top-color: var(--accent); 403 + border-radius: 50%; 404 + animation: spin 1s linear infinite; 405 + } 406 + 407 + @keyframes spin { 408 + to { 409 + transform: rotate(360deg); 410 + } 411 + } 412 + 413 + .passkey-section { 414 + margin-top: var(--space-5); 415 + text-align: center; 416 + } 417 + 418 + .passkey-section p { 419 + margin-bottom: var(--space-4); 420 + color: var(--text-secondary); 421 + } 422 + 423 + .app-password-display { 424 + background: var(--bg-primary); 425 + border-radius: var(--radius-lg); 426 + padding: var(--space-5); 427 + margin-bottom: var(--space-5); 428 + text-align: center; 429 + } 430 + 431 + .app-password-label { 432 + font-size: var(--text-sm); 433 + color: var(--text-secondary); 434 + margin-bottom: var(--space-3); 435 + } 436 + 437 + .app-password-code { 438 + display: block; 439 + font-family: var(--font-mono); 440 + font-size: var(--text-lg); 441 + letter-spacing: 0.1em; 442 + padding: var(--space-4); 443 + background: var(--bg-tertiary); 444 + border-radius: var(--radius-md); 445 + margin-bottom: var(--space-4); 446 + user-select: all; 447 + } 448 + 449 + .copy-btn { 450 + font-size: var(--text-sm); 451 + } 452 + 453 + .current-account { 454 + background: var(--bg-primary); 455 + border-radius: var(--radius-lg); 456 + padding: var(--space-4); 457 + margin-bottom: var(--space-5); 458 + display: flex; 459 + justify-content: space-between; 460 + align-items: center; 461 + } 462 + 463 + .current-account .label { 464 + color: var(--text-secondary); 465 + } 466 + 467 + .current-account .value { 468 + font-weight: var(--font-medium); 469 + font-size: var(--text-lg); 470 + } 471 + 472 + .server-info { 473 + background: var(--bg-primary); 474 + border-radius: var(--radius-lg); 475 + padding: var(--space-4); 476 + margin-top: var(--space-5); 477 + } 478 + 479 + .server-info h3 { 480 + margin: 0 0 var(--space-3) 0; 481 + font-size: var(--text-base); 482 + color: var(--success-text); 483 + } 484 + 485 + .server-info .info-row { 486 + display: flex; 487 + justify-content: space-between; 488 + padding: var(--space-2) 0; 489 + font-size: var(--text-sm); 490 + } 491 + 492 + .server-info .label { 493 + color: var(--text-secondary); 494 + } 495 + 496 + .server-info a { 497 + display: inline-block; 498 + margin-top: var(--space-2); 499 + margin-right: var(--space-3); 500 + color: var(--accent); 501 + font-size: var(--text-sm); 502 + } 503 + 504 + .final-warning { 505 + background: var(--error-bg); 506 + border-color: var(--error-border); 507 + } 508 + 509 + .final-warning strong { 510 + color: var(--error-text); 511 + } 512 + 513 + .next-steps { 514 + background: var(--accent-muted); 515 + border-radius: var(--radius-lg); 516 + padding: var(--space-5); 517 + margin: var(--space-5) 0; 518 + text-align: left; 519 + } 520 + 521 + .next-steps h3 { 522 + margin: 0 0 var(--space-3) 0; 523 + } 524 + 525 + .next-steps ol { 526 + margin: 0; 527 + padding-left: var(--space-5); 528 + } 529 + 530 + .next-steps li { 531 + margin-bottom: var(--space-2); 532 + } 533 + 534 + .next-steps a { 535 + color: var(--accent); 536 + } 537 + 538 + .resume-info { 539 + margin-bottom: var(--space-5); 540 + } 541 + 542 + .resume-details { 543 + display: flex; 544 + flex-direction: column; 545 + gap: var(--space-2); 546 + margin-top: var(--space-3); 547 + } 548 + 549 + .resume-row { 550 + display: flex; 551 + gap: var(--space-3); 552 + } 553 + 554 + .resume-row .label { 555 + color: var(--text-secondary); 556 + min-width: 80px; 557 + } 558 + 559 + .resume-row .value { 560 + font-weight: var(--font-medium); 561 + } 562 + 563 + .resume-note { 564 + margin-top: var(--space-4); 565 + font-size: var(--text-sm); 566 + font-style: italic; 567 + }
+4
frontend/src/styles/tokens.css
··· 48 --transition-normal: 0.15s ease; 49 --transition-slow: 0.25s ease; 50 51 --bg-primary: #f9fafa; 52 --bg-secondary: #f1f3f3; 53 --bg-tertiary: #e8ebeb; 54 --bg-card: #ffffff; 55 --bg-input: #ffffff; 56 --bg-input-disabled: #f1f3f3; ··· 93 --bg-primary: #0a0c0c; 94 --bg-secondary: #131616; 95 --bg-tertiary: #1a1d1d; 96 --bg-card: #131616; 97 --bg-input: #1a1d1d; 98 --bg-input-disabled: #131616;
··· 48 --transition-normal: 0.15s ease; 49 --transition-slow: 0.25s ease; 50 51 + --font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 52 + 53 --bg-primary: #f9fafa; 54 --bg-secondary: #f1f3f3; 55 --bg-tertiary: #e8ebeb; 56 + --bg-hover: #e8ebeb; 57 --bg-card: #ffffff; 58 --bg-input: #ffffff; 59 --bg-input-disabled: #f1f3f3; ··· 96 --bg-primary: #0a0c0c; 97 --bg-secondary: #131616; 98 --bg-tertiary: #1a1d1d; 99 + --bg-hover: #1a1d1d; 100 --bg-card: #131616; 101 --bg-input: #1a1d1d; 102 --bg-input-disabled: #131616;
+17 -14
frontend/src/tests/AppPasswords.test.ts
··· 15 beforeEach(() => { 16 clearMocks(); 17 setupFetchMock(); 18 - window.confirm = vi.fn(() => true); 19 }); 20 describe("authentication guard", () => { 21 it("redirects to login when not authenticated", async () => { 22 setupUnauthenticatedUser(); 23 render(AppPasswords); 24 await waitFor(() => { 25 - expect(window.location.hash).toBe("#/login"); 26 }); 27 }); 28 }); ··· 97 await waitFor(() => { 98 expect(screen.getByText("Graysky")).toBeInTheDocument(); 99 expect(screen.getByText("Skeets")).toBeInTheDocument(); 100 - expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument(); 101 - expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument(); 102 expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength( 103 2, 104 ); ··· 199 await fireEvent.input(input, { target: { value: "MyApp" } }); 200 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 201 await waitFor(() => { 202 - expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 203 expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument(); 204 - expect(screen.getByText(/name: myapp/i)).toBeInTheDocument(); 205 expect(input.value).toBe(""); 206 }); 207 }); ··· 221 }); 222 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 223 await waitFor(() => { 224 - expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 225 }); 226 await fireEvent.click(screen.getByRole("button", { name: /done/i })); 227 await waitFor(() => { 228 - expect(screen.queryByText(/app password created/i)).not 229 .toBeInTheDocument(); 230 }); 231 }); ··· 255 }); 256 it("shows confirmation dialog before revoking", async () => { 257 const confirmSpy = vi.fn(() => false); 258 - window.confirm = confirmSpy; 259 mockEndpoint( 260 "com.atproto.server.listAppPasswords", 261 () => jsonResponse({ passwords: [testPassword] }), ··· 270 ); 271 }); 272 it("does not revoke when confirmation is cancelled", async () => { 273 - window.confirm = vi.fn(() => false); 274 let revokeCalled = false; 275 mockEndpoint( 276 "com.atproto.server.listAppPasswords", ··· 288 expect(revokeCalled).toBe(false); 289 }); 290 it("calls revokeAppPassword with correct name", async () => { 291 - window.confirm = vi.fn(() => true); 292 let capturedName: string | null = null; 293 mockEndpoint( 294 "com.atproto.server.listAppPasswords", ··· 309 }); 310 }); 311 it("shows loading state while revoking", async () => { 312 - window.confirm = vi.fn(() => true); 313 mockEndpoint( 314 "com.atproto.server.listAppPasswords", 315 () => jsonResponse({ passwords: [testPassword] }), ··· 328 expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled(); 329 }); 330 it("reloads password list after successful revocation", async () => { 331 - window.confirm = vi.fn(() => true); 332 let listCallCount = 0; 333 mockEndpoint("com.atproto.server.listAppPasswords", () => { 334 listCallCount++; ··· 352 }); 353 }); 354 it("shows error when revocation fails", async () => { 355 - window.confirm = vi.fn(() => true); 356 mockEndpoint( 357 "com.atproto.server.listAppPasswords", 358 () => jsonResponse({ passwords: [testPassword] }),
··· 15 beforeEach(() => { 16 clearMocks(); 17 setupFetchMock(); 18 + globalThis.confirm = vi.fn(() => true); 19 }); 20 describe("authentication guard", () => { 21 it("redirects to login when not authenticated", async () => { 22 setupUnauthenticatedUser(); 23 render(AppPasswords); 24 await waitFor(() => { 25 + expect(globalThis.location.hash).toBe("#/login"); 26 }); 27 }); 28 }); ··· 97 await waitFor(() => { 98 expect(screen.getByText("Graysky")).toBeInTheDocument(); 99 expect(screen.getByText("Skeets")).toBeInTheDocument(); 100 + expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument(); 101 + expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument(); 102 expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength( 103 2, 104 ); ··· 199 await fireEvent.input(input, { target: { value: "MyApp" } }); 200 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 201 await waitFor(() => { 202 + expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 203 expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument(); 204 + expect(screen.getByText("MyApp")).toBeInTheDocument(); 205 expect(input.value).toBe(""); 206 }); 207 }); ··· 221 }); 222 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 223 await waitFor(() => { 224 + expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 225 }); 226 + await fireEvent.click( 227 + screen.getByLabelText(/i have saved my app password/i), 228 + ); 229 await fireEvent.click(screen.getByRole("button", { name: /done/i })); 230 await waitFor(() => { 231 + expect(screen.queryByText(/save this app password/i)).not 232 .toBeInTheDocument(); 233 }); 234 }); ··· 258 }); 259 it("shows confirmation dialog before revoking", async () => { 260 const confirmSpy = vi.fn(() => false); 261 + globalThis.confirm = confirmSpy; 262 mockEndpoint( 263 "com.atproto.server.listAppPasswords", 264 () => jsonResponse({ passwords: [testPassword] }), ··· 273 ); 274 }); 275 it("does not revoke when confirmation is cancelled", async () => { 276 + globalThis.confirm = vi.fn(() => false); 277 let revokeCalled = false; 278 mockEndpoint( 279 "com.atproto.server.listAppPasswords", ··· 291 expect(revokeCalled).toBe(false); 292 }); 293 it("calls revokeAppPassword with correct name", async () => { 294 + globalThis.confirm = vi.fn(() => true); 295 let capturedName: string | null = null; 296 mockEndpoint( 297 "com.atproto.server.listAppPasswords", ··· 312 }); 313 }); 314 it("shows loading state while revoking", async () => { 315 + globalThis.confirm = vi.fn(() => true); 316 mockEndpoint( 317 "com.atproto.server.listAppPasswords", 318 () => jsonResponse({ passwords: [testPassword] }), ··· 331 expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled(); 332 }); 333 it("reloads password list after successful revocation", async () => { 334 + globalThis.confirm = vi.fn(() => true); 335 let listCallCount = 0; 336 mockEndpoint("com.atproto.server.listAppPasswords", () => { 337 listCallCount++; ··· 355 }); 356 }); 357 it("shows error when revocation fails", async () => { 358 + globalThis.confirm = vi.fn(() => true); 359 mockEndpoint( 360 "com.atproto.server.listAppPasswords", 361 () => jsonResponse({ passwords: [testPassword] }),
+76 -13
frontend/src/tests/Comms.test.ts
··· 8 mockData, 9 mockEndpoint, 10 setupAuthenticatedUser, 11 - setupFetchMock, 12 setupUnauthenticatedUser, 13 } from "./mocks"; 14 describe("Comms", () => { 15 beforeEach(() => { 16 clearMocks(); 17 - setupFetchMock(); 18 }); 19 describe("authentication guard", () => { 20 it("redirects to login when not authenticated", async () => { 21 setupUnauthenticatedUser(); 22 render(Comms); 23 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 25 }); 26 }); 27 }); ··· 32 "com.tranquil.account.getNotificationPrefs", 33 () => jsonResponse(mockData.notificationPrefs()), 34 ); 35 }); 36 it("displays all page elements and sections", async () => { 37 render(Comms); 38 await waitFor(() => { 39 expect( 40 screen.getByRole("heading", { 41 - name: /notification preferences/i, 42 level: 1, 43 }), 44 ).toBeInTheDocument(); 45 expect(screen.getByRole("link", { name: /dashboard/i })) 46 .toHaveAttribute("href", "#/dashboard"); 47 - expect(screen.getByText(/password resets/i)).toBeInTheDocument(); 48 expect(screen.getByRole("heading", { name: /preferred channel/i })) 49 .toBeInTheDocument(); 50 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 55 describe("loading state", () => { 56 beforeEach(() => { 57 setupAuthenticatedUser(); 58 }); 59 it("shows loading text while fetching preferences", async () => { 60 mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { ··· 68 describe("channel options", () => { 69 beforeEach(() => { 70 setupAuthenticatedUser(); 71 }); 72 it("displays all four channel options", async () => { 73 mockEndpoint( ··· 127 ); 128 render(Comms); 129 await waitFor(() => { 130 - expect(screen.getAllByText(/configure below to enable/i).length) 131 .toBeGreaterThan(0); 132 }); 133 }); ··· 151 describe("channel configuration", () => { 152 beforeEach(() => { 153 setupAuthenticatedUser(); 154 }); 155 it("displays email as readonly with current value", async () => { 156 mockEndpoint( ··· 179 render(Comms); 180 await waitFor(() => { 181 expect( 182 - (screen.getByLabelText(/discord user id/i) as HTMLInputElement).value, 183 ).toBe("123456789"); 184 expect( 185 - (screen.getByLabelText(/telegram username/i) as HTMLInputElement) 186 .value, 187 ).toBe("testuser"); 188 expect( 189 - (screen.getByLabelText(/signal phone number/i) as HTMLInputElement) 190 .value, 191 ).toBe("+1234567890"); 192 }); ··· 195 describe("verification status badges", () => { 196 beforeEach(() => { 197 setupAuthenticatedUser(); 198 }); 199 it("shows Primary badge for email", async () => { 200 mockEndpoint( ··· 250 describe("save preferences", () => { 251 beforeEach(() => { 252 setupAuthenticatedUser(); 253 }); 254 it("calls updateNotificationPrefs with correct data", async () => { 255 let capturedBody: Record<string, unknown> | null = null; ··· 266 ); 267 render(Comms); 268 await waitFor(() => { 269 - expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument(); 270 }); 271 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { 272 target: { value: "999888777" }, 273 }); 274 await fireEvent.click( ··· 319 screen.getByRole("button", { name: /save preferences/i }), 320 ); 321 await waitFor(() => { 322 - expect(screen.getByText(/notification preferences saved/i)) 323 .toBeInTheDocument(); 324 }); 325 }); ··· 378 describe("channel selection interaction", () => { 379 beforeEach(() => { 380 setupAuthenticatedUser(); 381 }); 382 it("enables discord channel after entering discord ID", async () => { 383 mockEndpoint( ··· 388 await waitFor(() => { 389 expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled(); 390 }); 391 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { 392 target: { value: "123456789" }, 393 }); 394 await waitFor(() => { ··· 420 describe("error handling", () => { 421 beforeEach(() => { 422 setupAuthenticatedUser(); 423 }); 424 it("shows error when loading preferences fails", async () => { 425 mockEndpoint(
··· 8 mockData, 9 mockEndpoint, 10 setupAuthenticatedUser, 11 + setupDefaultMocks, 12 setupUnauthenticatedUser, 13 } from "./mocks"; 14 describe("Comms", () => { 15 beforeEach(() => { 16 clearMocks(); 17 + setupDefaultMocks(); 18 }); 19 describe("authentication guard", () => { 20 it("redirects to login when not authenticated", async () => { 21 setupUnauthenticatedUser(); 22 render(Comms); 23 await waitFor(() => { 24 + expect(globalThis.location.hash).toBe("#/login"); 25 }); 26 }); 27 }); ··· 32 "com.tranquil.account.getNotificationPrefs", 33 () => jsonResponse(mockData.notificationPrefs()), 34 ); 35 + mockEndpoint( 36 + "com.atproto.server.describeServer", 37 + () => jsonResponse(mockData.describeServer()), 38 + ); 39 + mockEndpoint( 40 + "com.tranquil.account.getNotificationHistory", 41 + () => jsonResponse({ notifications: [] }), 42 + ); 43 }); 44 it("displays all page elements and sections", async () => { 45 render(Comms); 46 await waitFor(() => { 47 expect( 48 screen.getByRole("heading", { 49 + name: /communication preferences|notification preferences/i, 50 level: 1, 51 }), 52 ).toBeInTheDocument(); 53 expect(screen.getByRole("link", { name: /dashboard/i })) 54 .toHaveAttribute("href", "#/dashboard"); 55 expect(screen.getByRole("heading", { name: /preferred channel/i })) 56 .toBeInTheDocument(); 57 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 62 describe("loading state", () => { 63 beforeEach(() => { 64 setupAuthenticatedUser(); 65 + mockEndpoint( 66 + "com.atproto.server.describeServer", 67 + () => jsonResponse(mockData.describeServer()), 68 + ); 69 + mockEndpoint( 70 + "com.tranquil.account.getNotificationHistory", 71 + () => jsonResponse({ notifications: [] }), 72 + ); 73 }); 74 it("shows loading text while fetching preferences", async () => { 75 mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { ··· 83 describe("channel options", () => { 84 beforeEach(() => { 85 setupAuthenticatedUser(); 86 + mockEndpoint( 87 + "com.atproto.server.describeServer", 88 + () => jsonResponse(mockData.describeServer()), 89 + ); 90 + mockEndpoint( 91 + "com.tranquil.account.getNotificationHistory", 92 + () => jsonResponse({ notifications: [] }), 93 + ); 94 }); 95 it("displays all four channel options", async () => { 96 mockEndpoint( ··· 150 ); 151 render(Comms); 152 await waitFor(() => { 153 + expect(screen.getAllByText(/configure.*to enable/i).length) 154 .toBeGreaterThan(0); 155 }); 156 }); ··· 174 describe("channel configuration", () => { 175 beforeEach(() => { 176 setupAuthenticatedUser(); 177 + mockEndpoint( 178 + "com.atproto.server.describeServer", 179 + () => jsonResponse(mockData.describeServer()), 180 + ); 181 + mockEndpoint( 182 + "com.tranquil.account.getNotificationHistory", 183 + () => jsonResponse({ notifications: [] }), 184 + ); 185 }); 186 it("displays email as readonly with current value", async () => { 187 mockEndpoint( ··· 210 render(Comms); 211 await waitFor(() => { 212 expect( 213 + (screen.getByLabelText(/discord.*id/i) as HTMLInputElement).value, 214 ).toBe("123456789"); 215 expect( 216 + (screen.getByLabelText(/telegram.*username/i) as HTMLInputElement) 217 .value, 218 ).toBe("testuser"); 219 expect( 220 + (screen.getByLabelText(/signal.*number/i) as HTMLInputElement) 221 .value, 222 ).toBe("+1234567890"); 223 }); ··· 226 describe("verification status badges", () => { 227 beforeEach(() => { 228 setupAuthenticatedUser(); 229 + mockEndpoint( 230 + "com.atproto.server.describeServer", 231 + () => jsonResponse(mockData.describeServer()), 232 + ); 233 + mockEndpoint( 234 + "com.tranquil.account.getNotificationHistory", 235 + () => jsonResponse({ notifications: [] }), 236 + ); 237 }); 238 it("shows Primary badge for email", async () => { 239 mockEndpoint( ··· 289 describe("save preferences", () => { 290 beforeEach(() => { 291 setupAuthenticatedUser(); 292 + mockEndpoint( 293 + "com.atproto.server.describeServer", 294 + () => jsonResponse(mockData.describeServer()), 295 + ); 296 + mockEndpoint( 297 + "com.tranquil.account.getNotificationHistory", 298 + () => jsonResponse({ notifications: [] }), 299 + ); 300 }); 301 it("calls updateNotificationPrefs with correct data", async () => { 302 let capturedBody: Record<string, unknown> | null = null; ··· 313 ); 314 render(Comms); 315 await waitFor(() => { 316 + expect(screen.getByLabelText(/discord.*id/i)).toBeInTheDocument(); 317 }); 318 + await fireEvent.input(screen.getByLabelText(/discord.*id/i), { 319 target: { value: "999888777" }, 320 }); 321 await fireEvent.click( ··· 366 screen.getByRole("button", { name: /save preferences/i }), 367 ); 368 await waitFor(() => { 369 + expect(screen.getByText(/preferences saved/i)) 370 .toBeInTheDocument(); 371 }); 372 }); ··· 425 describe("channel selection interaction", () => { 426 beforeEach(() => { 427 setupAuthenticatedUser(); 428 + mockEndpoint( 429 + "com.atproto.server.describeServer", 430 + () => jsonResponse(mockData.describeServer()), 431 + ); 432 + mockEndpoint( 433 + "com.tranquil.account.getNotificationHistory", 434 + () => jsonResponse({ notifications: [] }), 435 + ); 436 }); 437 it("enables discord channel after entering discord ID", async () => { 438 mockEndpoint( ··· 443 await waitFor(() => { 444 expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled(); 445 }); 446 + await fireEvent.input(screen.getByLabelText(/discord.*id/i), { 447 target: { value: "123456789" }, 448 }); 449 await waitFor(() => { ··· 475 describe("error handling", () => { 476 beforeEach(() => { 477 setupAuthenticatedUser(); 478 + mockEndpoint( 479 + "com.atproto.server.describeServer", 480 + () => jsonResponse(mockData.describeServer()), 481 + ); 482 + mockEndpoint( 483 + "com.tranquil.account.getNotificationHistory", 484 + () => jsonResponse({ notifications: [] }), 485 + ); 486 }); 487 it("shows error when loading preferences fails", async () => { 488 mockEndpoint(
+27 -5
frontend/src/tests/Dashboard.test.ts
··· 21 setupUnauthenticatedUser(); 22 render(Dashboard); 23 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 25 }); 26 }); 27 it("shows loading state while checking auth", () => { ··· 40 .toBeInTheDocument(); 41 expect(screen.getByRole("heading", { name: /account overview/i })) 42 .toBeInTheDocument(); 43 - expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)) 44 - .toBeInTheDocument(); 45 expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)) 46 .toBeInTheDocument(); 47 expect(screen.getByText("test@example.com")).toBeInTheDocument(); ··· 62 await waitFor(() => { 63 const navCards = [ 64 { name: /app passwords/i, href: "#/app-passwords" }, 65 - { name: /invite codes/i, href: "#/invite-codes" }, 66 { name: /account settings/i, href: "#/settings" }, 67 { name: /communication preferences/i, href: "#/comms" }, 68 { name: /repository explorer/i, href: "#/repo" }, ··· 74 } 75 }); 76 }); 77 }); 78 describe("logout functionality", () => { 79 beforeEach(() => { ··· 89 }); 90 render(Dashboard); 91 await waitFor(() => { 92 expect(screen.getByRole("button", { name: /sign out/i })) 93 .toBeInTheDocument(); 94 }); 95 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 96 await waitFor(() => { 97 expect(deleteSessionCalled).toBe(true); 98 - expect(window.location.hash).toBe("#/login"); 99 }); 100 }); 101 it("clears session from localStorage after logout", async () => { 102 const storedSession = localStorage.getItem(STORAGE_KEY); 103 expect(storedSession).not.toBeNull(); 104 render(Dashboard); 105 await waitFor(() => { 106 expect(screen.getByRole("button", { name: /sign out/i })) 107 .toBeInTheDocument();
··· 21 setupUnauthenticatedUser(); 22 render(Dashboard); 23 await waitFor(() => { 24 + expect(globalThis.location.hash).toBe("#/login"); 25 }); 26 }); 27 it("shows loading state while checking auth", () => { ··· 40 .toBeInTheDocument(); 41 expect(screen.getByRole("heading", { name: /account overview/i })) 42 .toBeInTheDocument(); 43 + expect(screen.getAllByText(/@testuser\.test\.tranquil\.dev/).length) 44 + .toBeGreaterThan(0); 45 expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)) 46 .toBeInTheDocument(); 47 expect(screen.getByText("test@example.com")).toBeInTheDocument(); ··· 62 await waitFor(() => { 63 const navCards = [ 64 { name: /app passwords/i, href: "#/app-passwords" }, 65 { name: /account settings/i, href: "#/settings" }, 66 { name: /communication preferences/i, href: "#/comms" }, 67 { name: /repository explorer/i, href: "#/repo" }, ··· 73 } 74 }); 75 }); 76 + it("displays invite codes card when invites are required and user is admin", async () => { 77 + setupAuthenticatedUser({ isAdmin: true }); 78 + mockEndpoint( 79 + "com.atproto.server.describeServer", 80 + () => jsonResponse(mockData.describeServer({ inviteCodeRequired: true })), 81 + ); 82 + render(Dashboard); 83 + await waitFor(() => { 84 + const inviteCard = screen.getByRole("link", { name: /invite codes/i }); 85 + expect(inviteCard).toBeInTheDocument(); 86 + expect(inviteCard).toHaveAttribute("href", "#/invite-codes"); 87 + }); 88 + }); 89 }); 90 describe("logout functionality", () => { 91 beforeEach(() => { ··· 101 }); 102 render(Dashboard); 103 await waitFor(() => { 104 + expect(screen.getByRole("button", { name: /@testuser/i })) 105 + .toBeInTheDocument(); 106 + }); 107 + await fireEvent.click(screen.getByRole("button", { name: /@testuser/i })); 108 + await waitFor(() => { 109 expect(screen.getByRole("button", { name: /sign out/i })) 110 .toBeInTheDocument(); 111 }); 112 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 113 await waitFor(() => { 114 expect(deleteSessionCalled).toBe(true); 115 + expect(globalThis.location.hash).toBe("#/login"); 116 }); 117 }); 118 it("clears session from localStorage after logout", async () => { 119 const storedSession = localStorage.getItem(STORAGE_KEY); 120 expect(storedSession).not.toBeNull(); 121 render(Dashboard); 122 + await waitFor(() => { 123 + expect(screen.getByRole("button", { name: /@testuser/i })) 124 + .toBeInTheDocument(); 125 + }); 126 + await fireEvent.click(screen.getByRole("button", { name: /@testuser/i })); 127 await waitFor(() => { 128 expect(screen.getByRole("button", { name: /sign out/i })) 129 .toBeInTheDocument();
+132 -132
frontend/src/tests/Login.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 2 import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 import Login from "../routes/Login.svelte"; 4 import { 5 clearMocks, 6 - errorResponse, 7 jsonResponse, 8 mockData, 9 mockEndpoint, 10 setupFetchMock, 11 } from "./mocks"; 12 describe("Login", () => { 13 beforeEach(() => { 14 clearMocks(); 15 setupFetchMock(); 16 - window.location.hash = ""; 17 }); 18 - describe("initial render", () => { 19 - it("renders login form with all elements and correct initial state", () => { 20 - render(Login); 21 - expect(screen.getByRole("heading", { name: /sign in/i })) 22 - .toBeInTheDocument(); 23 - expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument(); 24 - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); 25 - expect(screen.getByRole("button", { name: /sign in/i })) 26 - .toBeInTheDocument(); 27 - expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled(); 28 - expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 29 - expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute( 30 - "href", 31 - "#/register", 32 - ); 33 - }); 34 - }); 35 - describe("form validation", () => { 36 - it("enables submit button only when both fields are filled", async () => { 37 - render(Login); 38 - const identifierInput = screen.getByLabelText(/handle or email/i); 39 - const passwordInput = screen.getByLabelText(/password/i); 40 - const submitButton = screen.getByRole("button", { name: /sign in/i }); 41 - await fireEvent.input(identifierInput, { target: { value: "testuser" } }); 42 - expect(submitButton).toBeDisabled(); 43 - await fireEvent.input(identifierInput, { target: { value: "" } }); 44 - await fireEvent.input(passwordInput, { 45 - target: { value: "password123" }, 46 }); 47 - expect(submitButton).toBeDisabled(); 48 - await fireEvent.input(identifierInput, { target: { value: "testuser" } }); 49 - expect(submitButton).not.toBeDisabled(); 50 }); 51 - }); 52 - describe("login submission", () => { 53 - it("calls createSession with correct credentials", async () => { 54 - let capturedBody: Record<string, string> | null = null; 55 - mockEndpoint("com.atproto.server.createSession", (_url, options) => { 56 - capturedBody = JSON.parse((options?.body as string) || "{}"); 57 - return jsonResponse(mockData.session()); 58 - }); 59 render(Login); 60 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { 61 - target: { value: "testuser@example.com" }, 62 - }); 63 - await fireEvent.input(screen.getByLabelText(/password/i), { 64 - target: { value: "mypassword" }, 65 - }); 66 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 67 await waitFor(() => { 68 - expect(capturedBody).toEqual({ 69 - identifier: "testuser@example.com", 70 - password: "mypassword", 71 - }); 72 }); 73 }); 74 - it("shows styled error message on invalid credentials", async () => { 75 - mockEndpoint( 76 - "com.atproto.server.createSession", 77 - () => 78 - errorResponse( 79 - "AuthenticationRequired", 80 - "Invalid identifier or password", 81 - 401, 82 - ), 83 - ); 84 render(Login); 85 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { 86 - target: { value: "wronguser" }, 87 - }); 88 - await fireEvent.input(screen.getByLabelText(/password/i), { 89 - target: { value: "wrongpassword" }, 90 - }); 91 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 92 await waitFor(() => { 93 - const errorDiv = screen.getByText(/invalid identifier or password/i); 94 - expect(errorDiv).toBeInTheDocument(); 95 - expect(errorDiv).toHaveClass("error"); 96 }); 97 }); 98 - it("navigates to dashboard on successful login", async () => { 99 - mockEndpoint( 100 - "com.atproto.server.createSession", 101 - () => jsonResponse(mockData.session()), 102 - ); 103 render(Login); 104 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { 105 - target: { value: "test" }, 106 - }); 107 - await fireEvent.input(screen.getByLabelText(/password/i), { 108 - target: { value: "password" }, 109 - }); 110 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 111 await waitFor(() => { 112 - expect(window.location.hash).toBe("#/dashboard"); 113 }); 114 }); 115 }); 116 - describe("account verification flow", () => { 117 - it("shows verification form with all controls when account is not verified", async () => { 118 - mockEndpoint("com.atproto.server.createSession", () => ({ 119 - ok: false, 120 - status: 401, 121 - json: async () => ({ 122 - error: "AccountNotVerified", 123 - message: "Account not verified", 124 - did: "did:web:test.tranquil.dev:u:testuser", 125 - }), 126 - })); 127 - render(Login); 128 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { 129 - target: { value: "unverified@test.com" }, 130 - }); 131 - await fireEvent.input(screen.getByLabelText(/password/i), { 132 - target: { value: "password" }, 133 }); 134 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 135 await waitFor(() => { 136 - expect(screen.getByRole("heading", { name: /verify your account/i })) 137 .toBeInTheDocument(); 138 - expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 139 - expect(screen.getByRole("button", { name: /resend code/i })) 140 - .toBeInTheDocument(); 141 - expect(screen.getByRole("button", { name: /back to login/i })) 142 .toBeInTheDocument(); 143 }); 144 }); 145 - it("returns to login form when clicking back", async () => { 146 - mockEndpoint("com.atproto.server.createSession", () => ({ 147 - ok: false, 148 - status: 401, 149 - json: async () => ({ 150 - error: "AccountNotVerified", 151 - message: "Account not verified", 152 - did: "did:web:test.tranquil.dev:u:testuser", 153 - }), 154 - })); 155 render(Login); 156 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { 157 - target: { value: "test" }, 158 - }); 159 - await fireEvent.input(screen.getByLabelText(/password/i), { 160 - target: { value: "password" }, 161 }); 162 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 163 await waitFor(() => { 164 - expect(screen.getByRole("button", { name: /back to login/i })) 165 .toBeInTheDocument(); 166 }); 167 - await fireEvent.click( 168 - screen.getByRole("button", { name: /back to login/i }), 169 - ); 170 await waitFor(() => { 171 - expect(screen.getByRole("heading", { name: /sign in/i })) 172 - .toBeInTheDocument(); 173 - expect(screen.queryByLabelText(/verification code/i)).not 174 .toBeInTheDocument(); 175 }); 176 }); 177 }); 178 });
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 import Login from "../routes/Login.svelte"; 4 import { 5 clearMocks, 6 jsonResponse, 7 mockData, 8 mockEndpoint, 9 setupFetchMock, 10 } from "./mocks"; 11 + import { _testSetState, type SavedAccount } from "../lib/auth.svelte"; 12 + 13 describe("Login", () => { 14 beforeEach(() => { 15 clearMocks(); 16 setupFetchMock(); 17 + globalThis.location.hash = ""; 18 + mockEndpoint("/oauth/par", () => 19 + jsonResponse({ request_uri: "urn:mock:request" }) 20 + ); 21 }); 22 + 23 + describe("initial render with no saved accounts", () => { 24 + beforeEach(() => { 25 + _testSetState({ 26 + session: null, 27 + loading: false, 28 + error: null, 29 + savedAccounts: [], 30 }); 31 }); 32 + 33 + it("renders login page with title and OAuth button", async () => { 34 render(Login); 35 await waitFor(() => { 36 + expect(screen.getByRole("heading", { name: /sign in/i })) 37 + .toBeInTheDocument(); 38 + expect(screen.getByRole("button", { name: /sign in/i })) 39 + .toBeInTheDocument(); 40 }); 41 }); 42 + 43 + it("shows create account link", async () => { 44 render(Login); 45 await waitFor(() => { 46 + expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 47 + expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute( 48 + "href", 49 + "#/register", 50 + ); 51 }); 52 }); 53 + 54 + it("shows forgot password and lost passkey links", async () => { 55 render(Login); 56 await waitFor(() => { 57 + expect(screen.getByRole("link", { name: /forgot password/i })) 58 + .toHaveAttribute("href", "#/reset-password"); 59 + expect(screen.getByRole("link", { name: /lost passkey/i })) 60 + .toHaveAttribute("href", "#/request-passkey-recovery"); 61 }); 62 }); 63 }); 64 + 65 + describe("with saved accounts", () => { 66 + const savedAccounts: SavedAccount[] = [ 67 + { 68 + did: "did:web:test.tranquil.dev:u:alice", 69 + handle: "alice.test.tranquil.dev", 70 + accessJwt: "mock-jwt-alice", 71 + refreshJwt: "mock-refresh-alice", 72 + }, 73 + { 74 + did: "did:web:test.tranquil.dev:u:bob", 75 + handle: "bob.test.tranquil.dev", 76 + accessJwt: "mock-jwt-bob", 77 + refreshJwt: "mock-refresh-bob", 78 + }, 79 + ]; 80 + 81 + beforeEach(() => { 82 + _testSetState({ 83 + session: null, 84 + loading: false, 85 + error: null, 86 + savedAccounts, 87 }); 88 + mockEndpoint("com.atproto.server.getSession", () => 89 + jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" }))); 90 + }); 91 + 92 + it("displays saved accounts list", async () => { 93 + render(Login); 94 await waitFor(() => { 95 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 96 .toBeInTheDocument(); 97 + expect(screen.getByText(/@bob\.test\.tranquil\.dev/)) 98 .toBeInTheDocument(); 99 }); 100 }); 101 + 102 + it("shows sign in to another account option", async () => { 103 render(Login); 104 + await waitFor(() => { 105 + expect(screen.getByText(/sign in to another/i)).toBeInTheDocument(); 106 }); 107 + }); 108 + 109 + it("can click on saved account to switch", async () => { 110 + render(Login); 111 await waitFor(() => { 112 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 113 .toBeInTheDocument(); 114 }); 115 + const aliceAccount = screen.getByText(/@alice\.test\.tranquil\.dev/) 116 + .closest("[role='button']"); 117 + if (aliceAccount) { 118 + await fireEvent.click(aliceAccount); 119 + } 120 + await waitFor(() => { 121 + expect(globalThis.location.hash).toBe("#/dashboard"); 122 + }); 123 + }); 124 + 125 + it("can remove saved account with forget button", async () => { 126 + render(Login); 127 await waitFor(() => { 128 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 129 .toBeInTheDocument(); 130 + const forgetButtons = screen.getAllByTitle(/remove/i); 131 + expect(forgetButtons.length).toBe(2); 132 }); 133 + }); 134 + }); 135 + 136 + describe("error handling", () => { 137 + it("displays error message when auth state has error", async () => { 138 + _testSetState({ 139 + session: null, 140 + loading: false, 141 + error: "OAuth login failed", 142 + savedAccounts: [], 143 + }); 144 + render(Login); 145 + await waitFor(() => { 146 + expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument(); 147 + expect(screen.getByText(/oauth login failed/i)).toHaveClass("error"); 148 + }); 149 + }); 150 + }); 151 + 152 + describe("verification flow", () => { 153 + beforeEach(() => { 154 + _testSetState({ 155 + session: null, 156 + loading: false, 157 + error: null, 158 + savedAccounts: [], 159 + }); 160 + }); 161 + 162 + it("shows verification form when pending verification exists", async () => { 163 + render(Login); 164 + }); 165 + }); 166 + 167 + describe("loading state", () => { 168 + it("shows loading state while auth is initializing", async () => { 169 + _testSetState({ 170 + session: null, 171 + loading: true, 172 + error: null, 173 + savedAccounts: [], 174 + }); 175 + render(Login); 176 }); 177 }); 178 });
+82 -71
frontend/src/tests/Settings.test.ts
··· 5 clearMocks, 6 errorResponse, 7 jsonResponse, 8 mockEndpoint, 9 setupAuthenticatedUser, 10 setupFetchMock, ··· 14 beforeEach(() => { 15 clearMocks(); 16 setupFetchMock(); 17 - window.confirm = vi.fn(() => true); 18 }); 19 describe("authentication guard", () => { 20 it("redirects to login when not authenticated", async () => { 21 setupUnauthenticatedUser(); 22 render(Settings); 23 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 25 }); 26 }); 27 }); ··· 50 beforeEach(() => { 51 setupAuthenticatedUser(); 52 }); 53 - it("displays current email and input field", async () => { 54 render(Settings); 55 await waitFor(() => { 56 - expect(screen.getByText(/current: test@example.com/i)) 57 .toBeInTheDocument(); 58 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 59 }); 60 }); 61 - it("calls requestEmailUpdate when submitting", async () => { 62 let requestCalled = false; 63 mockEndpoint("com.atproto.server.requestEmailUpdate", () => { 64 requestCalled = true; ··· 66 }); 67 render(Settings); 68 await waitFor(() => { 69 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 70 - }); 71 - await fireEvent.input(screen.getByLabelText(/new email/i), { 72 - target: { value: "newemail@example.com" }, 73 }); 74 await fireEvent.click( 75 screen.getByRole("button", { name: /change email/i }), ··· 78 expect(requestCalled).toBe(true); 79 }); 80 }); 81 - it("shows verification code input when token is required", async () => { 82 mockEndpoint( 83 "com.atproto.server.requestEmailUpdate", 84 () => jsonResponse({ tokenRequired: true }), 85 ); 86 render(Settings); 87 await waitFor(() => { 88 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 89 - }); 90 - await fireEvent.input(screen.getByLabelText(/new email/i), { 91 - target: { value: "newemail@example.com" }, 92 }); 93 await fireEvent.click( 94 screen.getByRole("button", { name: /change email/i }), 95 ); 96 await waitFor(() => { 97 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 98 expect(screen.getByRole("button", { name: /confirm email change/i })) 99 .toBeInTheDocument(); 100 }); ··· 111 capturedBody = JSON.parse((options?.body as string) || "{}"); 112 return jsonResponse({}); 113 }); 114 render(Settings); 115 await waitFor(() => { 116 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 117 - }); 118 - await fireEvent.input(screen.getByLabelText(/new email/i), { 119 - target: { value: "newemail@example.com" }, 120 }); 121 await fireEvent.click( 122 screen.getByRole("button", { name: /change email/i }), ··· 127 await fireEvent.input(screen.getByLabelText(/verification code/i), { 128 target: { value: "123456" }, 129 }); 130 await fireEvent.click( 131 screen.getByRole("button", { name: /confirm email change/i }), 132 ); ··· 142 () => jsonResponse({ tokenRequired: true }), 143 ); 144 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 145 render(Settings); 146 await waitFor(() => { 147 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 148 - }); 149 - await fireEvent.input(screen.getByLabelText(/new email/i), { 150 - target: { value: "new@test.com" }, 151 }); 152 await fireEvent.click( 153 screen.getByRole("button", { name: /change email/i }), ··· 158 await fireEvent.input(screen.getByLabelText(/verification code/i), { 159 target: { value: "123456" }, 160 }); 161 await fireEvent.click( 162 screen.getByRole("button", { name: /confirm email change/i }), 163 ); 164 await waitFor(() => { 165 - expect(screen.getByText(/email updated successfully/i)) 166 .toBeInTheDocument(); 167 }); 168 }); 169 - it("shows cancel button to return to email form", async () => { 170 mockEndpoint( 171 "com.atproto.server.requestEmailUpdate", 172 () => jsonResponse({ tokenRequired: true }), 173 ); 174 render(Settings); 175 await waitFor(() => { 176 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 177 - }); 178 - await fireEvent.input(screen.getByLabelText(/new email/i), { 179 - target: { value: "new@test.com" }, 180 }); 181 await fireEvent.click( 182 screen.getByRole("button", { name: /change email/i }), ··· 185 expect(screen.getByRole("button", { name: /cancel/i })) 186 .toBeInTheDocument(); 187 }); 188 - await fireEvent.click(screen.getByRole("button", { name: /cancel/i })); 189 await waitFor(() => { 190 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 191 expect(screen.queryByLabelText(/verification code/i)).not 192 .toBeInTheDocument(); 193 }); 194 }); 195 - it("shows error when email update fails", async () => { 196 mockEndpoint( 197 "com.atproto.server.requestEmailUpdate", 198 () => errorResponse("InvalidEmail", "Invalid email format", 400), 199 ); 200 render(Settings); 201 await waitFor(() => { 202 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 203 - }); 204 - await fireEvent.input(screen.getByLabelText(/new email/i), { 205 - target: { value: "invalid@test.com" }, 206 - }); 207 - await waitFor(() => { 208 - expect(screen.getByRole("button", { name: /change email/i })).not 209 - .toBeDisabled(); 210 }); 211 await fireEvent.click( 212 screen.getByRole("button", { name: /change email/i }), ··· 219 describe("handle change", () => { 220 beforeEach(() => { 221 setupAuthenticatedUser(); 222 }); 223 it("displays current handle", async () => { 224 render(Settings); 225 await waitFor(() => { 226 - expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)) 227 .toBeInTheDocument(); 228 }); 229 }); 230 - it("calls updateHandle with new handle", async () => { 231 - let capturedHandle: string | null = null; 232 - mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => { 233 - const body = JSON.parse((options?.body as string) || "{}"); 234 - capturedHandle = body.handle; 235 - return jsonResponse({}); 236 }); 237 render(Settings); 238 await waitFor(() => { 239 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 240 }); 241 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 242 - target: { value: "newhandle.bsky.social" }, 243 }); 244 - await fireEvent.click( 245 - screen.getByRole("button", { name: /change handle/i }), 246 - ); 247 - await waitFor(() => { 248 - expect(capturedHandle).toBe("newhandle.bsky.social"); 249 - }); 250 }); 251 it("shows success message after handle change", async () => { 252 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 253 render(Settings); 254 await waitFor(() => { 255 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 256 }); 257 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 258 target: { value: "newhandle" }, 259 }); 260 - await fireEvent.click( 261 - screen.getByRole("button", { name: /change handle/i }), 262 - ); 263 await waitFor(() => { 264 - expect(screen.getByText(/handle updated successfully/i)) 265 .toBeInTheDocument(); 266 }); 267 }); ··· 274 render(Settings); 275 await waitFor(() => { 276 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 277 }); 278 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 279 target: { value: "taken" }, 280 }); 281 - await fireEvent.click( 282 - screen.getByRole("button", { name: /change handle/i }), 283 - ); 284 await waitFor(() => { 285 - expect(screen.getByText(/handle is already taken/i)) 286 - .toBeInTheDocument(); 287 }); 288 }); 289 }); ··· 345 }); 346 it("shows confirmation dialog before final deletion", async () => { 347 const confirmSpy = vi.fn(() => false); 348 - window.confirm = confirmSpy; 349 mockEndpoint( 350 "com.atproto.server.requestAccountDelete", 351 () => jsonResponse({}), ··· 376 ); 377 }); 378 it("calls deleteAccount with correct parameters", async () => { 379 - window.confirm = vi.fn(() => true); 380 let capturedBody: Record<string, string> | null = null; 381 mockEndpoint( 382 "com.atproto.server.requestAccountDelete", ··· 414 }); 415 }); 416 it("navigates to login after successful deletion", async () => { 417 - window.confirm = vi.fn(() => true); 418 mockEndpoint( 419 "com.atproto.server.requestAccountDelete", 420 () => jsonResponse({}), ··· 442 screen.getByRole("button", { name: /permanently delete account/i }), 443 ); 444 await waitFor(() => { 445 - expect(window.location.hash).toBe("#/login"); 446 }); 447 }); 448 it("shows cancel button to return to request state", async () => { ··· 480 }); 481 }); 482 it("shows error when deletion fails", async () => { 483 - window.confirm = vi.fn(() => true); 484 mockEndpoint( 485 "com.atproto.server.requestAccountDelete", 486 () => jsonResponse({}),
··· 5 clearMocks, 6 errorResponse, 7 jsonResponse, 8 + mockData, 9 mockEndpoint, 10 setupAuthenticatedUser, 11 setupFetchMock, ··· 15 beforeEach(() => { 16 clearMocks(); 17 setupFetchMock(); 18 + globalThis.confirm = vi.fn(() => true); 19 }); 20 describe("authentication guard", () => { 21 it("redirects to login when not authenticated", async () => { 22 setupUnauthenticatedUser(); 23 render(Settings); 24 await waitFor(() => { 25 + expect(globalThis.location.hash).toBe("#/login"); 26 }); 27 }); 28 }); ··· 51 beforeEach(() => { 52 setupAuthenticatedUser(); 53 }); 54 + it("displays current email and change button", async () => { 55 render(Settings); 56 await waitFor(() => { 57 + expect(screen.getByText(/current.*test@example.com/i)) 58 .toBeInTheDocument(); 59 + expect(screen.getByRole("button", { name: /change email/i })) 60 + .toBeInTheDocument(); 61 }); 62 }); 63 + it("calls requestEmailUpdate when clicking change email button", async () => { 64 let requestCalled = false; 65 mockEndpoint("com.atproto.server.requestEmailUpdate", () => { 66 requestCalled = true; ··· 68 }); 69 render(Settings); 70 await waitFor(() => { 71 + expect(screen.getByRole("button", { name: /change email/i })) 72 + .toBeInTheDocument(); 73 }); 74 await fireEvent.click( 75 screen.getByRole("button", { name: /change email/i }), ··· 78 expect(requestCalled).toBe(true); 79 }); 80 }); 81 + it("shows verification code and new email inputs when token is required", async () => { 82 mockEndpoint( 83 "com.atproto.server.requestEmailUpdate", 84 () => jsonResponse({ tokenRequired: true }), 85 ); 86 render(Settings); 87 await waitFor(() => { 88 + expect(screen.getByRole("button", { name: /change email/i })) 89 + .toBeInTheDocument(); 90 }); 91 await fireEvent.click( 92 screen.getByRole("button", { name: /change email/i }), 93 ); 94 await waitFor(() => { 95 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 96 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 97 expect(screen.getByRole("button", { name: /confirm email change/i })) 98 .toBeInTheDocument(); 99 }); ··· 110 capturedBody = JSON.parse((options?.body as string) || "{}"); 111 return jsonResponse({}); 112 }); 113 + mockEndpoint("com.atproto.server.getSession", () => 114 + jsonResponse(mockData.session())); 115 render(Settings); 116 await waitFor(() => { 117 + expect(screen.getByRole("button", { name: /change email/i })) 118 + .toBeInTheDocument(); 119 }); 120 await fireEvent.click( 121 screen.getByRole("button", { name: /change email/i }), ··· 126 await fireEvent.input(screen.getByLabelText(/verification code/i), { 127 target: { value: "123456" }, 128 }); 129 + await fireEvent.input(screen.getByLabelText(/new email/i), { 130 + target: { value: "newemail@example.com" }, 131 + }); 132 await fireEvent.click( 133 screen.getByRole("button", { name: /confirm email change/i }), 134 ); ··· 144 () => jsonResponse({ tokenRequired: true }), 145 ); 146 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 147 + mockEndpoint("com.atproto.server.getSession", () => 148 + jsonResponse(mockData.session())); 149 render(Settings); 150 await waitFor(() => { 151 + expect(screen.getByRole("button", { name: /change email/i })) 152 + .toBeInTheDocument(); 153 }); 154 await fireEvent.click( 155 screen.getByRole("button", { name: /change email/i }), ··· 160 await fireEvent.input(screen.getByLabelText(/verification code/i), { 161 target: { value: "123456" }, 162 }); 163 + await fireEvent.input(screen.getByLabelText(/new email/i), { 164 + target: { value: "new@test.com" }, 165 + }); 166 await fireEvent.click( 167 screen.getByRole("button", { name: /confirm email change/i }), 168 ); 169 await waitFor(() => { 170 + expect(screen.getByText(/email updated/i)) 171 .toBeInTheDocument(); 172 }); 173 }); 174 + it("shows cancel button to return to initial state", async () => { 175 mockEndpoint( 176 "com.atproto.server.requestEmailUpdate", 177 () => jsonResponse({ tokenRequired: true }), 178 ); 179 render(Settings); 180 await waitFor(() => { 181 + expect(screen.getByRole("button", { name: /change email/i })) 182 + .toBeInTheDocument(); 183 }); 184 await fireEvent.click( 185 screen.getByRole("button", { name: /change email/i }), ··· 188 expect(screen.getByRole("button", { name: /cancel/i })) 189 .toBeInTheDocument(); 190 }); 191 + const emailSection = screen.getByRole("heading", { name: /change email/i }) 192 + .closest("section"); 193 + const cancelButton = emailSection?.querySelector("button.secondary"); 194 + if (cancelButton) { 195 + await fireEvent.click(cancelButton); 196 + } 197 await waitFor(() => { 198 expect(screen.queryByLabelText(/verification code/i)).not 199 .toBeInTheDocument(); 200 }); 201 }); 202 + it("shows error when request fails", async () => { 203 mockEndpoint( 204 "com.atproto.server.requestEmailUpdate", 205 () => errorResponse("InvalidEmail", "Invalid email format", 400), 206 ); 207 render(Settings); 208 await waitFor(() => { 209 + expect(screen.getByRole("button", { name: /change email/i })) 210 + .toBeInTheDocument(); 211 }); 212 await fireEvent.click( 213 screen.getByRole("button", { name: /change email/i }), ··· 220 describe("handle change", () => { 221 beforeEach(() => { 222 setupAuthenticatedUser(); 223 + mockEndpoint("com.atproto.server.describeServer", () => 224 + jsonResponse(mockData.describeServer())); 225 }); 226 it("displays current handle", async () => { 227 render(Settings); 228 await waitFor(() => { 229 + expect(screen.getByText(/current.*@testuser\.test\.tranquil\.dev/i)) 230 .toBeInTheDocument(); 231 }); 232 }); 233 + it("shows PDS handle and custom domain tabs", async () => { 234 + render(Settings); 235 + await waitFor(() => { 236 + expect(screen.getByRole("button", { name: /pds handle/i })) 237 + .toBeInTheDocument(); 238 + expect(screen.getByRole("button", { name: /custom domain/i })) 239 + .toBeInTheDocument(); 240 }); 241 + }); 242 + it("allows entering handle and shows domain suffix", async () => { 243 render(Settings); 244 await waitFor(() => { 245 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 246 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 247 }); 248 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 249 + await fireEvent.input(input, { 250 + target: { value: "newhandle" }, 251 }); 252 + expect(input.value).toBe("newhandle"); 253 + expect(screen.getByRole("button", { name: /change handle/i })) 254 + .toBeInTheDocument(); 255 }); 256 it("shows success message after handle change", async () => { 257 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 258 + mockEndpoint("com.atproto.server.getSession", () => 259 + jsonResponse(mockData.session())); 260 render(Settings); 261 await waitFor(() => { 262 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 263 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 264 }); 265 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 266 + await fireEvent.input(input, { 267 target: { value: "newhandle" }, 268 }); 269 + const button = screen.getByRole("button", { name: /change handle/i }); 270 + await fireEvent.submit(button.closest("form")!); 271 await waitFor(() => { 272 + expect(screen.getByText(/handle updated/i)) 273 .toBeInTheDocument(); 274 }); 275 }); ··· 282 render(Settings); 283 await waitFor(() => { 284 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 285 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 286 }); 287 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 288 + await fireEvent.input(input, { 289 target: { value: "taken" }, 290 }); 291 + expect(input.value).toBe("taken"); 292 + const button = screen.getByRole("button", { name: /change handle/i }); 293 + await fireEvent.submit(button.closest("form")!); 294 await waitFor(() => { 295 + const errorMessage = screen.queryByText(/handle is already taken/i) || 296 + screen.queryByText(/handle update failed/i); 297 + expect(errorMessage).toBeInTheDocument(); 298 }); 299 }); 300 }); ··· 356 }); 357 it("shows confirmation dialog before final deletion", async () => { 358 const confirmSpy = vi.fn(() => false); 359 + globalThis.confirm = confirmSpy; 360 mockEndpoint( 361 "com.atproto.server.requestAccountDelete", 362 () => jsonResponse({}), ··· 387 ); 388 }); 389 it("calls deleteAccount with correct parameters", async () => { 390 + globalThis.confirm = vi.fn(() => true); 391 let capturedBody: Record<string, string> | null = null; 392 mockEndpoint( 393 "com.atproto.server.requestAccountDelete", ··· 425 }); 426 }); 427 it("navigates to login after successful deletion", async () => { 428 + globalThis.confirm = vi.fn(() => true); 429 mockEndpoint( 430 "com.atproto.server.requestAccountDelete", 431 () => jsonResponse({}), ··· 453 screen.getByRole("button", { name: /permanently delete account/i }), 454 ); 455 await waitFor(() => { 456 + expect(globalThis.location.hash).toBe("#/login"); 457 }); 458 }); 459 it("shows cancel button to return to request state", async () => { ··· 491 }); 492 }); 493 it("shows error when deletion fails", async () => { 494 + globalThis.confirm = vi.fn(() => true); 495 mockEndpoint( 496 "com.atproto.server.requestAccountDelete", 497 () => jsonResponse({}),
+514
frontend/src/tests/migration/atproto-client.test.ts
···
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + base64UrlDecode, 4 + base64UrlEncode, 5 + buildOAuthAuthorizationUrl, 6 + clearDPoPKey, 7 + generateDPoPKeyPair, 8 + generateOAuthState, 9 + generatePKCE, 10 + getMigrationOAuthClientId, 11 + getMigrationOAuthRedirectUri, 12 + loadDPoPKey, 13 + prepareWebAuthnCreationOptions, 14 + saveDPoPKey, 15 + } from "../../lib/migration/atproto-client"; 16 + import type { OAuthServerMetadata } from "../../lib/migration/types"; 17 + 18 + const DPOP_KEY_STORAGE = "migration_dpop_key"; 19 + 20 + describe("migration/atproto-client", () => { 21 + beforeEach(() => { 22 + localStorage.removeItem(DPOP_KEY_STORAGE); 23 + }); 24 + 25 + describe("base64UrlEncode", () => { 26 + it("encodes empty buffer", () => { 27 + const result = base64UrlEncode(new Uint8Array([])); 28 + expect(result).toBe(""); 29 + }); 30 + 31 + it("encodes simple data", () => { 32 + const data = new TextEncoder().encode("hello"); 33 + const result = base64UrlEncode(data); 34 + expect(result).toBe("aGVsbG8"); 35 + }); 36 + 37 + it("uses URL-safe characters (no +, /, or =)", () => { 38 + const data = new Uint8Array([251, 255, 254]); 39 + const result = base64UrlEncode(data); 40 + expect(result).not.toContain("+"); 41 + expect(result).not.toContain("/"); 42 + expect(result).not.toContain("="); 43 + }); 44 + 45 + it("replaces + with -", () => { 46 + const data = new Uint8Array([251]); 47 + const result = base64UrlEncode(data); 48 + expect(result).toContain("-"); 49 + }); 50 + 51 + it("replaces / with _", () => { 52 + const data = new Uint8Array([255]); 53 + const result = base64UrlEncode(data); 54 + expect(result).toContain("_"); 55 + }); 56 + 57 + it("accepts ArrayBuffer", () => { 58 + const arrayBuffer = new ArrayBuffer(4); 59 + const view = new Uint8Array(arrayBuffer); 60 + view[0] = 116; // t 61 + view[1] = 101; // e 62 + view[2] = 115; // s 63 + view[3] = 116; // t 64 + const result = base64UrlEncode(arrayBuffer); 65 + expect(result).toBe("dGVzdA"); 66 + }); 67 + }); 68 + 69 + describe("base64UrlDecode", () => { 70 + it("decodes empty string", () => { 71 + const result = base64UrlDecode(""); 72 + expect(result.length).toBe(0); 73 + }); 74 + 75 + it("decodes URL-safe base64", () => { 76 + const result = base64UrlDecode("aGVsbG8"); 77 + expect(new TextDecoder().decode(result)).toBe("hello"); 78 + }); 79 + 80 + it("handles - and _ characters", () => { 81 + const encoded = base64UrlEncode(new Uint8Array([251, 255, 254])); 82 + const decoded = base64UrlDecode(encoded); 83 + expect(decoded).toEqual(new Uint8Array([251, 255, 254])); 84 + }); 85 + 86 + it("is inverse of base64UrlEncode", () => { 87 + const original = new Uint8Array([0, 1, 2, 255, 254, 253]); 88 + const encoded = base64UrlEncode(original); 89 + const decoded = base64UrlDecode(encoded); 90 + expect(decoded).toEqual(original); 91 + }); 92 + 93 + it("handles missing padding", () => { 94 + const result = base64UrlDecode("YQ"); 95 + expect(new TextDecoder().decode(result)).toBe("a"); 96 + }); 97 + }); 98 + 99 + describe("generateOAuthState", () => { 100 + it("generates a non-empty string", () => { 101 + const state = generateOAuthState(); 102 + expect(state).toBeTruthy(); 103 + expect(typeof state).toBe("string"); 104 + }); 105 + 106 + it("generates URL-safe characters only", () => { 107 + const state = generateOAuthState(); 108 + expect(state).toMatch(/^[A-Za-z0-9_-]+$/); 109 + }); 110 + 111 + it("generates different values each time", () => { 112 + const state1 = generateOAuthState(); 113 + const state2 = generateOAuthState(); 114 + expect(state1).not.toBe(state2); 115 + }); 116 + }); 117 + 118 + describe("generatePKCE", () => { 119 + it("generates code_verifier and code_challenge", async () => { 120 + const pkce = await generatePKCE(); 121 + expect(pkce.codeVerifier).toBeTruthy(); 122 + expect(pkce.codeChallenge).toBeTruthy(); 123 + }); 124 + 125 + it("generates URL-safe code_verifier", async () => { 126 + const pkce = await generatePKCE(); 127 + expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); 128 + }); 129 + 130 + it("generates URL-safe code_challenge", async () => { 131 + const pkce = await generatePKCE(); 132 + expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); 133 + }); 134 + 135 + it("code_challenge is SHA-256 hash of code_verifier", async () => { 136 + const pkce = await generatePKCE(); 137 + 138 + const encoder = new TextEncoder(); 139 + const data = encoder.encode(pkce.codeVerifier); 140 + const digest = await crypto.subtle.digest("SHA-256", data); 141 + const expectedChallenge = base64UrlEncode(new Uint8Array(digest)); 142 + 143 + expect(pkce.codeChallenge).toBe(expectedChallenge); 144 + }); 145 + 146 + it("generates different values each time", async () => { 147 + const pkce1 = await generatePKCE(); 148 + const pkce2 = await generatePKCE(); 149 + expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier); 150 + expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge); 151 + }); 152 + }); 153 + 154 + describe("buildOAuthAuthorizationUrl", () => { 155 + const mockMetadata: OAuthServerMetadata = { 156 + issuer: "https://bsky.social", 157 + authorization_endpoint: "https://bsky.social/oauth/authorize", 158 + token_endpoint: "https://bsky.social/oauth/token", 159 + scopes_supported: ["atproto"], 160 + response_types_supported: ["code"], 161 + grant_types_supported: ["authorization_code"], 162 + code_challenge_methods_supported: ["S256"], 163 + dpop_signing_alg_values_supported: ["ES256"], 164 + }; 165 + 166 + it("builds authorization URL with required parameters", () => { 167 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 168 + clientId: "https://example.com/oauth/client-metadata.json", 169 + redirectUri: "https://example.com/migrate", 170 + codeChallenge: "abc123", 171 + state: "state123", 172 + }); 173 + 174 + const parsed = new URL(url); 175 + expect(parsed.origin).toBe("https://bsky.social"); 176 + expect(parsed.pathname).toBe("/oauth/authorize"); 177 + expect(parsed.searchParams.get("response_type")).toBe("code"); 178 + expect(parsed.searchParams.get("client_id")).toBe( 179 + "https://example.com/oauth/client-metadata.json", 180 + ); 181 + expect(parsed.searchParams.get("redirect_uri")).toBe( 182 + "https://example.com/migrate", 183 + ); 184 + expect(parsed.searchParams.get("code_challenge")).toBe("abc123"); 185 + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); 186 + expect(parsed.searchParams.get("state")).toBe("state123"); 187 + }); 188 + 189 + it("includes default scope when not specified", () => { 190 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 191 + clientId: "client", 192 + redirectUri: "redirect", 193 + codeChallenge: "challenge", 194 + state: "state", 195 + }); 196 + 197 + const parsed = new URL(url); 198 + expect(parsed.searchParams.get("scope")).toBe("atproto"); 199 + }); 200 + 201 + it("includes custom scope when specified", () => { 202 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 203 + clientId: "client", 204 + redirectUri: "redirect", 205 + codeChallenge: "challenge", 206 + state: "state", 207 + scope: "atproto identity:*", 208 + }); 209 + 210 + const parsed = new URL(url); 211 + expect(parsed.searchParams.get("scope")).toBe("atproto identity:*"); 212 + }); 213 + 214 + it("includes dpop_jkt when specified", () => { 215 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 216 + clientId: "client", 217 + redirectUri: "redirect", 218 + codeChallenge: "challenge", 219 + state: "state", 220 + dpopJkt: "dpop-thumbprint-123", 221 + }); 222 + 223 + const parsed = new URL(url); 224 + expect(parsed.searchParams.get("dpop_jkt")).toBe("dpop-thumbprint-123"); 225 + }); 226 + 227 + it("includes login_hint when specified", () => { 228 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 229 + clientId: "client", 230 + redirectUri: "redirect", 231 + codeChallenge: "challenge", 232 + state: "state", 233 + loginHint: "alice.bsky.social", 234 + }); 235 + 236 + const parsed = new URL(url); 237 + expect(parsed.searchParams.get("login_hint")).toBe("alice.bsky.social"); 238 + }); 239 + 240 + it("omits optional params when not specified", () => { 241 + const url = buildOAuthAuthorizationUrl(mockMetadata, { 242 + clientId: "client", 243 + redirectUri: "redirect", 244 + codeChallenge: "challenge", 245 + state: "state", 246 + }); 247 + 248 + const parsed = new URL(url); 249 + expect(parsed.searchParams.has("dpop_jkt")).toBe(false); 250 + expect(parsed.searchParams.has("login_hint")).toBe(false); 251 + }); 252 + }); 253 + 254 + describe("getMigrationOAuthClientId", () => { 255 + it("returns client metadata URL based on origin", () => { 256 + const clientId = getMigrationOAuthClientId(); 257 + expect(clientId).toBe( 258 + `${globalThis.location.origin}/oauth/client-metadata.json`, 259 + ); 260 + }); 261 + }); 262 + 263 + describe("getMigrationOAuthRedirectUri", () => { 264 + it("returns migrate path based on origin", () => { 265 + const redirectUri = getMigrationOAuthRedirectUri(); 266 + expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`); 267 + }); 268 + }); 269 + 270 + describe("DPoP key management", () => { 271 + describe("generateDPoPKeyPair", () => { 272 + it("generates a valid key pair", async () => { 273 + const keyPair = await generateDPoPKeyPair(); 274 + 275 + expect(keyPair.privateKey).toBeDefined(); 276 + expect(keyPair.publicKey).toBeDefined(); 277 + expect(keyPair.jwk).toBeDefined(); 278 + expect(keyPair.thumbprint).toBeDefined(); 279 + }); 280 + 281 + it("generates ES256 (P-256) keys", async () => { 282 + const keyPair = await generateDPoPKeyPair(); 283 + 284 + expect(keyPair.jwk.kty).toBe("EC"); 285 + expect(keyPair.jwk.crv).toBe("P-256"); 286 + expect(keyPair.jwk.x).toBeDefined(); 287 + expect(keyPair.jwk.y).toBeDefined(); 288 + }); 289 + 290 + it("generates URL-safe thumbprint", async () => { 291 + const keyPair = await generateDPoPKeyPair(); 292 + 293 + expect(keyPair.thumbprint).toMatch(/^[A-Za-z0-9_-]+$/); 294 + }); 295 + 296 + it("generates different keys each time", async () => { 297 + const keyPair1 = await generateDPoPKeyPair(); 298 + const keyPair2 = await generateDPoPKeyPair(); 299 + 300 + expect(keyPair1.thumbprint).not.toBe(keyPair2.thumbprint); 301 + }); 302 + }); 303 + 304 + describe("saveDPoPKey", () => { 305 + it("saves key pair to localStorage", async () => { 306 + const keyPair = await generateDPoPKeyPair(); 307 + 308 + await saveDPoPKey(keyPair); 309 + 310 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull(); 311 + }); 312 + 313 + it("stores private and public JWK", async () => { 314 + const keyPair = await generateDPoPKeyPair(); 315 + 316 + await saveDPoPKey(keyPair); 317 + 318 + const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!); 319 + expect(stored.privateJwk).toBeDefined(); 320 + expect(stored.publicJwk).toBeDefined(); 321 + expect(stored.thumbprint).toBe(keyPair.thumbprint); 322 + }); 323 + 324 + it("stores creation timestamp", async () => { 325 + const before = Date.now(); 326 + const keyPair = await generateDPoPKeyPair(); 327 + await saveDPoPKey(keyPair); 328 + const after = Date.now(); 329 + 330 + const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!); 331 + expect(stored.createdAt).toBeGreaterThanOrEqual(before); 332 + expect(stored.createdAt).toBeLessThanOrEqual(after); 333 + }); 334 + }); 335 + 336 + describe("loadDPoPKey", () => { 337 + it("returns null when no key stored", async () => { 338 + const keyPair = await loadDPoPKey(); 339 + expect(keyPair).toBeNull(); 340 + }); 341 + 342 + it("loads stored key pair", async () => { 343 + const original = await generateDPoPKeyPair(); 344 + await saveDPoPKey(original); 345 + 346 + const loaded = await loadDPoPKey(); 347 + 348 + expect(loaded).not.toBeNull(); 349 + expect(loaded!.thumbprint).toBe(original.thumbprint); 350 + }); 351 + 352 + it("returns null and clears storage for expired key (> 24 hours)", async () => { 353 + const stored = { 354 + privateJwk: { kty: "EC", crv: "P-256", x: "test", y: "test", d: "test" }, 355 + publicJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, 356 + thumbprint: "test-thumb", 357 + createdAt: Date.now() - 25 * 60 * 60 * 1000, 358 + }; 359 + localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored)); 360 + 361 + const loaded = await loadDPoPKey(); 362 + 363 + expect(loaded).toBeNull(); 364 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 365 + }); 366 + 367 + it("returns null and clears storage for invalid data", async () => { 368 + localStorage.setItem(DPOP_KEY_STORAGE, "not-valid-json"); 369 + 370 + const loaded = await loadDPoPKey(); 371 + 372 + expect(loaded).toBeNull(); 373 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 374 + }); 375 + }); 376 + 377 + describe("clearDPoPKey", () => { 378 + it("removes key from localStorage", async () => { 379 + const keyPair = await generateDPoPKeyPair(); 380 + await saveDPoPKey(keyPair); 381 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull(); 382 + 383 + clearDPoPKey(); 384 + 385 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 386 + }); 387 + 388 + it("does not throw when nothing to clear", () => { 389 + expect(() => clearDPoPKey()).not.toThrow(); 390 + }); 391 + }); 392 + }); 393 + 394 + describe("prepareWebAuthnCreationOptions", () => { 395 + it("decodes challenge from base64url", () => { 396 + const options = { 397 + publicKey: { 398 + challenge: "dGVzdC1jaGFsbGVuZ2U", 399 + user: { 400 + id: "dXNlci1pZA", 401 + name: "test@example.com", 402 + displayName: "Test User", 403 + }, 404 + excludeCredentials: [], 405 + rp: { name: "Test" }, 406 + pubKeyCredParams: [{ type: "public-key", alg: -7 }], 407 + }, 408 + }; 409 + 410 + const prepared = prepareWebAuthnCreationOptions(options); 411 + 412 + expect(prepared.challenge).toBeInstanceOf(Uint8Array); 413 + expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe( 414 + "test-challenge", 415 + ); 416 + }); 417 + 418 + it("decodes user.id from base64url", () => { 419 + const options = { 420 + publicKey: { 421 + challenge: "Y2hhbGxlbmdl", 422 + user: { 423 + id: "dXNlci1pZA", 424 + name: "test@example.com", 425 + displayName: "Test User", 426 + }, 427 + excludeCredentials: [], 428 + rp: { name: "Test" }, 429 + pubKeyCredParams: [{ type: "public-key", alg: -7 }], 430 + }, 431 + }; 432 + 433 + const prepared = prepareWebAuthnCreationOptions(options); 434 + 435 + expect(prepared.user?.id).toBeInstanceOf(Uint8Array); 436 + expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe( 437 + "user-id", 438 + ); 439 + }); 440 + 441 + it("decodes excludeCredentials ids from base64url", () => { 442 + const options = { 443 + publicKey: { 444 + challenge: "Y2hhbGxlbmdl", 445 + user: { 446 + id: "dXNlcg", 447 + name: "test@example.com", 448 + displayName: "Test User", 449 + }, 450 + excludeCredentials: [ 451 + { id: "Y3JlZDE", type: "public-key" }, 452 + { id: "Y3JlZDI", type: "public-key" }, 453 + ], 454 + rp: { name: "Test" }, 455 + pubKeyCredParams: [{ type: "public-key", alg: -7 }], 456 + }, 457 + }; 458 + 459 + const prepared = prepareWebAuthnCreationOptions(options); 460 + 461 + expect(prepared.excludeCredentials).toHaveLength(2); 462 + expect( 463 + new TextDecoder().decode( 464 + prepared.excludeCredentials![0].id as Uint8Array, 465 + ), 466 + ).toBe("cred1"); 467 + expect( 468 + new TextDecoder().decode( 469 + prepared.excludeCredentials![1].id as Uint8Array, 470 + ), 471 + ).toBe("cred2"); 472 + }); 473 + 474 + it("handles empty excludeCredentials", () => { 475 + const options = { 476 + publicKey: { 477 + challenge: "Y2hhbGxlbmdl", 478 + user: { 479 + id: "dXNlcg", 480 + name: "test@example.com", 481 + displayName: "Test User", 482 + }, 483 + rp: { name: "Test" }, 484 + pubKeyCredParams: [{ type: "public-key", alg: -7 }], 485 + }, 486 + }; 487 + 488 + const prepared = prepareWebAuthnCreationOptions(options); 489 + 490 + expect(prepared.excludeCredentials).toEqual([]); 491 + }); 492 + 493 + it("preserves other user properties", () => { 494 + const options = { 495 + publicKey: { 496 + challenge: "Y2hhbGxlbmdl", 497 + user: { 498 + id: "dXNlcg", 499 + name: "test@example.com", 500 + displayName: "Test User", 501 + }, 502 + excludeCredentials: [], 503 + rp: { name: "Test" }, 504 + pubKeyCredParams: [{ type: "public-key", alg: -7 }], 505 + }, 506 + }; 507 + 508 + const prepared = prepareWebAuthnCreationOptions(options); 509 + 510 + expect(prepared.user?.name).toBe("test@example.com"); 511 + expect(prepared.user?.displayName).toBe("Test User"); 512 + }); 513 + }); 514 + });
+509
frontend/src/tests/migration/storage.test.ts
···
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + clearMigrationState, 4 + getResumeInfo, 5 + hasPendingMigration, 6 + loadMigrationState, 7 + saveMigrationState, 8 + setError, 9 + updateProgress, 10 + updateStep, 11 + } from "../../lib/migration/storage"; 12 + import type { 13 + InboundMigrationState, 14 + OutboundMigrationState, 15 + } from "../../lib/migration/types"; 16 + 17 + const STORAGE_KEY = "tranquil_migration_state"; 18 + const DPOP_KEY_STORAGE = "migration_dpop_key"; 19 + 20 + function createInboundState( 21 + overrides?: Partial<InboundMigrationState>, 22 + ): InboundMigrationState { 23 + return { 24 + direction: "inbound", 25 + step: "welcome", 26 + sourcePdsUrl: "https://bsky.social", 27 + sourceDid: "did:plc:abc123", 28 + sourceHandle: "alice.bsky.social", 29 + targetHandle: "alice.example.com", 30 + targetEmail: "alice@example.com", 31 + targetPassword: "password123", 32 + inviteCode: "", 33 + sourceAccessToken: null, 34 + sourceRefreshToken: null, 35 + serviceAuthToken: null, 36 + emailVerifyToken: "", 37 + plcToken: "", 38 + progress: { 39 + repoExported: false, 40 + repoImported: false, 41 + blobsTotal: 0, 42 + blobsMigrated: 0, 43 + blobsFailed: [], 44 + prefsMigrated: false, 45 + plcSigned: false, 46 + activated: false, 47 + deactivated: false, 48 + currentOperation: "", 49 + }, 50 + error: null, 51 + targetVerificationMethod: null, 52 + authMethod: "password", 53 + passkeySetupToken: null, 54 + oauthCodeVerifier: null, 55 + generatedAppPassword: null, 56 + generatedAppPasswordName: null, 57 + ...overrides, 58 + }; 59 + } 60 + 61 + function createOutboundState( 62 + overrides?: Partial<OutboundMigrationState>, 63 + ): OutboundMigrationState { 64 + return { 65 + direction: "outbound", 66 + step: "welcome", 67 + localDid: "did:plc:xyz789", 68 + localHandle: "bob.example.com", 69 + targetPdsUrl: "https://new-pds.com", 70 + targetPdsDid: "did:web:new-pds.com", 71 + targetHandle: "bob.new-pds.com", 72 + targetEmail: "bob@new-pds.com", 73 + targetPassword: "password456", 74 + inviteCode: "", 75 + targetAccessToken: null, 76 + targetRefreshToken: null, 77 + serviceAuthToken: null, 78 + plcToken: "", 79 + progress: { 80 + repoExported: false, 81 + repoImported: false, 82 + blobsTotal: 0, 83 + blobsMigrated: 0, 84 + blobsFailed: [], 85 + prefsMigrated: false, 86 + plcSigned: false, 87 + activated: false, 88 + deactivated: false, 89 + currentOperation: "", 90 + }, 91 + error: null, 92 + targetServerInfo: null, 93 + ...overrides, 94 + }; 95 + } 96 + 97 + describe("migration/storage", () => { 98 + beforeEach(() => { 99 + localStorage.removeItem(STORAGE_KEY); 100 + localStorage.removeItem(DPOP_KEY_STORAGE); 101 + }); 102 + 103 + describe("saveMigrationState", () => { 104 + it("saves inbound migration state to localStorage", () => { 105 + const state = createInboundState({ 106 + step: "migrating", 107 + progress: { 108 + repoExported: true, 109 + repoImported: false, 110 + blobsTotal: 10, 111 + blobsMigrated: 5, 112 + blobsFailed: [], 113 + prefsMigrated: false, 114 + plcSigned: false, 115 + activated: false, 116 + deactivated: false, 117 + currentOperation: "Migrating blobs...", 118 + }, 119 + }); 120 + 121 + saveMigrationState(state); 122 + 123 + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 124 + expect(stored.version).toBe(1); 125 + expect(stored.direction).toBe("inbound"); 126 + expect(stored.step).toBe("migrating"); 127 + expect(stored.sourcePdsUrl).toBe("https://bsky.social"); 128 + expect(stored.sourceDid).toBe("did:plc:abc123"); 129 + expect(stored.sourceHandle).toBe("alice.bsky.social"); 130 + expect(stored.targetHandle).toBe("alice.example.com"); 131 + expect(stored.targetEmail).toBe("alice@example.com"); 132 + expect(stored.progress.repoExported).toBe(true); 133 + expect(stored.progress.blobsMigrated).toBe(5); 134 + expect(stored.startedAt).toBeDefined(); 135 + expect(new Date(stored.startedAt).getTime()).not.toBeNaN(); 136 + }); 137 + 138 + it("saves outbound migration state to localStorage", () => { 139 + const state = createOutboundState({ 140 + step: "review", 141 + }); 142 + 143 + saveMigrationState(state); 144 + 145 + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 146 + expect(stored.version).toBe(1); 147 + expect(stored.direction).toBe("outbound"); 148 + expect(stored.step).toBe("review"); 149 + expect(stored.targetHandle).toBe("bob.new-pds.com"); 150 + expect(stored.targetEmail).toBe("bob@new-pds.com"); 151 + }); 152 + 153 + it("saves authMethod for inbound migrations", () => { 154 + const state = createInboundState({ authMethod: "passkey" }); 155 + 156 + saveMigrationState(state); 157 + 158 + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 159 + expect(stored.authMethod).toBe("passkey"); 160 + }); 161 + 162 + it("saves passkeySetupToken when present", () => { 163 + const state = createInboundState({ 164 + authMethod: "passkey", 165 + passkeySetupToken: "setup-token-123", 166 + }); 167 + 168 + saveMigrationState(state); 169 + 170 + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 171 + expect(stored.passkeySetupToken).toBe("setup-token-123"); 172 + }); 173 + 174 + it("saves error information", () => { 175 + const state = createInboundState({ 176 + step: "error", 177 + error: "Connection failed", 178 + }); 179 + 180 + saveMigrationState(state); 181 + 182 + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); 183 + expect(stored.lastError).toBe("Connection failed"); 184 + expect(stored.lastErrorStep).toBe("error"); 185 + }); 186 + }); 187 + 188 + describe("loadMigrationState", () => { 189 + it("returns null when no state is stored", () => { 190 + expect(loadMigrationState()).toBeNull(); 191 + }); 192 + 193 + it("loads valid migration state", () => { 194 + const state = createInboundState({ step: "migrating" }); 195 + saveMigrationState(state); 196 + 197 + const loaded = loadMigrationState(); 198 + 199 + expect(loaded).not.toBeNull(); 200 + expect(loaded!.direction).toBe("inbound"); 201 + expect(loaded!.step).toBe("migrating"); 202 + expect(loaded!.sourceHandle).toBe("alice.bsky.social"); 203 + }); 204 + 205 + it("clears and returns null for incompatible version", () => { 206 + localStorage.setItem( 207 + STORAGE_KEY, 208 + JSON.stringify({ 209 + version: 999, 210 + direction: "inbound", 211 + step: "welcome", 212 + }), 213 + ); 214 + 215 + const loaded = loadMigrationState(); 216 + 217 + expect(loaded).toBeNull(); 218 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 219 + }); 220 + 221 + it("clears and returns null for expired state (> 24 hours)", () => { 222 + const expiredState = { 223 + version: 1, 224 + direction: "inbound", 225 + step: "welcome", 226 + startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 227 + sourcePdsUrl: "https://bsky.social", 228 + targetPdsUrl: "http://localhost:3000", 229 + sourceDid: "did:plc:abc123", 230 + sourceHandle: "alice.bsky.social", 231 + targetHandle: "alice.example.com", 232 + targetEmail: "alice@example.com", 233 + progress: { 234 + repoExported: false, 235 + repoImported: false, 236 + blobsTotal: 0, 237 + blobsMigrated: 0, 238 + prefsMigrated: false, 239 + plcSigned: false, 240 + }, 241 + }; 242 + localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState)); 243 + 244 + const loaded = loadMigrationState(); 245 + 246 + expect(loaded).toBeNull(); 247 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 248 + }); 249 + 250 + it("returns state that is not yet expired (< 24 hours)", () => { 251 + const recentState = { 252 + version: 1, 253 + direction: "inbound", 254 + step: "review", 255 + startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), 256 + sourcePdsUrl: "https://bsky.social", 257 + targetPdsUrl: "http://localhost:3000", 258 + sourceDid: "did:plc:abc123", 259 + sourceHandle: "alice.bsky.social", 260 + targetHandle: "alice.example.com", 261 + targetEmail: "alice@example.com", 262 + progress: { 263 + repoExported: false, 264 + repoImported: false, 265 + blobsTotal: 0, 266 + blobsMigrated: 0, 267 + prefsMigrated: false, 268 + plcSigned: false, 269 + }, 270 + }; 271 + localStorage.setItem(STORAGE_KEY, JSON.stringify(recentState)); 272 + 273 + const loaded = loadMigrationState(); 274 + 275 + expect(loaded).not.toBeNull(); 276 + expect(loaded!.step).toBe("review"); 277 + }); 278 + 279 + it("clears and returns null for invalid JSON", () => { 280 + localStorage.setItem(STORAGE_KEY, "not-valid-json"); 281 + 282 + const loaded = loadMigrationState(); 283 + 284 + expect(loaded).toBeNull(); 285 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 286 + }); 287 + }); 288 + 289 + describe("clearMigrationState", () => { 290 + it("removes migration state from localStorage", () => { 291 + const state = createInboundState(); 292 + saveMigrationState(state); 293 + expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); 294 + 295 + clearMigrationState(); 296 + 297 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 298 + }); 299 + 300 + it("also removes DPoP key", () => { 301 + localStorage.setItem(DPOP_KEY_STORAGE, "some-dpop-key"); 302 + const state = createInboundState(); 303 + saveMigrationState(state); 304 + 305 + clearMigrationState(); 306 + 307 + expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull(); 308 + }); 309 + 310 + it("does not throw when nothing to clear", () => { 311 + expect(() => clearMigrationState()).not.toThrow(); 312 + }); 313 + }); 314 + 315 + describe("hasPendingMigration", () => { 316 + it("returns false when no migration state exists", () => { 317 + expect(hasPendingMigration()).toBe(false); 318 + }); 319 + 320 + it("returns true when valid migration state exists", () => { 321 + const state = createInboundState(); 322 + saveMigrationState(state); 323 + 324 + expect(hasPendingMigration()).toBe(true); 325 + }); 326 + 327 + it("returns false when state is expired", () => { 328 + const expiredState = { 329 + version: 1, 330 + direction: "inbound", 331 + step: "welcome", 332 + startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 333 + sourcePdsUrl: "https://bsky.social", 334 + targetPdsUrl: "http://localhost:3000", 335 + sourceDid: "did:plc:abc123", 336 + sourceHandle: "alice.bsky.social", 337 + targetHandle: "alice.example.com", 338 + targetEmail: "alice@example.com", 339 + progress: { 340 + repoExported: false, 341 + repoImported: false, 342 + blobsTotal: 0, 343 + blobsMigrated: 0, 344 + prefsMigrated: false, 345 + plcSigned: false, 346 + }, 347 + }; 348 + localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState)); 349 + 350 + expect(hasPendingMigration()).toBe(false); 351 + }); 352 + }); 353 + 354 + describe("getResumeInfo", () => { 355 + it("returns null when no migration state exists", () => { 356 + expect(getResumeInfo()).toBeNull(); 357 + }); 358 + 359 + it("returns resume info for inbound migration", () => { 360 + const state = createInboundState({ 361 + step: "migrating", 362 + progress: { 363 + repoExported: true, 364 + repoImported: true, 365 + blobsTotal: 10, 366 + blobsMigrated: 5, 367 + blobsFailed: [], 368 + prefsMigrated: false, 369 + plcSigned: false, 370 + activated: false, 371 + deactivated: false, 372 + currentOperation: "", 373 + }, 374 + }); 375 + saveMigrationState(state); 376 + 377 + const info = getResumeInfo(); 378 + 379 + expect(info).not.toBeNull(); 380 + expect(info!.direction).toBe("inbound"); 381 + expect(info!.sourceHandle).toBe("alice.bsky.social"); 382 + expect(info!.targetHandle).toBe("alice.example.com"); 383 + expect(info!.progressSummary).toContain("repo exported"); 384 + expect(info!.progressSummary).toContain("repo imported"); 385 + expect(info!.progressSummary).toContain("5/10 blobs"); 386 + }); 387 + 388 + it("returns 'just started' when no progress made", () => { 389 + const state = createInboundState({ step: "welcome" }); 390 + saveMigrationState(state); 391 + 392 + const info = getResumeInfo(); 393 + 394 + expect(info!.progressSummary).toBe("just started"); 395 + }); 396 + 397 + it("includes authMethod for inbound migrations", () => { 398 + const state = createInboundState({ authMethod: "passkey" }); 399 + saveMigrationState(state); 400 + 401 + const info = getResumeInfo(); 402 + 403 + expect(info!.authMethod).toBe("passkey"); 404 + }); 405 + 406 + it("includes all completed progress items", () => { 407 + const state = createInboundState({ 408 + step: "finalizing", 409 + progress: { 410 + repoExported: true, 411 + repoImported: true, 412 + blobsTotal: 10, 413 + blobsMigrated: 10, 414 + blobsFailed: [], 415 + prefsMigrated: true, 416 + plcSigned: true, 417 + activated: false, 418 + deactivated: false, 419 + currentOperation: "", 420 + }, 421 + }); 422 + saveMigrationState(state); 423 + 424 + const info = getResumeInfo(); 425 + 426 + expect(info!.progressSummary).toContain("repo exported"); 427 + expect(info!.progressSummary).toContain("repo imported"); 428 + expect(info!.progressSummary).toContain("preferences migrated"); 429 + expect(info!.progressSummary).toContain("PLC signed"); 430 + }); 431 + }); 432 + 433 + describe("updateProgress", () => { 434 + it("updates progress fields in stored state", () => { 435 + const state = createInboundState(); 436 + saveMigrationState(state); 437 + 438 + updateProgress({ repoExported: true, blobsTotal: 50 }); 439 + 440 + const loaded = loadMigrationState(); 441 + expect(loaded!.progress.repoExported).toBe(true); 442 + expect(loaded!.progress.blobsTotal).toBe(50); 443 + }); 444 + 445 + it("preserves other progress fields", () => { 446 + const state = createInboundState({ 447 + progress: { 448 + repoExported: true, 449 + repoImported: false, 450 + blobsTotal: 10, 451 + blobsMigrated: 0, 452 + blobsFailed: [], 453 + prefsMigrated: false, 454 + plcSigned: false, 455 + activated: false, 456 + deactivated: false, 457 + currentOperation: "", 458 + }, 459 + }); 460 + saveMigrationState(state); 461 + 462 + updateProgress({ repoImported: true }); 463 + 464 + const loaded = loadMigrationState(); 465 + expect(loaded!.progress.repoExported).toBe(true); 466 + expect(loaded!.progress.repoImported).toBe(true); 467 + }); 468 + 469 + it("does nothing when no state exists", () => { 470 + expect(() => updateProgress({ repoExported: true })).not.toThrow(); 471 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 472 + }); 473 + }); 474 + 475 + describe("updateStep", () => { 476 + it("updates step in stored state", () => { 477 + const state = createInboundState({ step: "welcome" }); 478 + saveMigrationState(state); 479 + 480 + updateStep("migrating"); 481 + 482 + const loaded = loadMigrationState(); 483 + expect(loaded!.step).toBe("migrating"); 484 + }); 485 + 486 + it("does nothing when no state exists", () => { 487 + expect(() => updateStep("migrating")).not.toThrow(); 488 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 489 + }); 490 + }); 491 + 492 + describe("setError", () => { 493 + it("sets error and errorStep in stored state", () => { 494 + const state = createInboundState({ step: "migrating" }); 495 + saveMigrationState(state); 496 + 497 + setError("Connection timeout", "migrating"); 498 + 499 + const loaded = loadMigrationState(); 500 + expect(loaded!.lastError).toBe("Connection timeout"); 501 + expect(loaded!.lastErrorStep).toBe("migrating"); 502 + }); 503 + 504 + it("does nothing when no state exists", () => { 505 + expect(() => setError("Error message", "welcome")).not.toThrow(); 506 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 507 + }); 508 + }); 509 + });
+75
frontend/src/tests/migration/types.test.ts
···
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { MigrationError } from "../../lib/migration/types"; 3 + 4 + describe("migration/types", () => { 5 + describe("MigrationError", () => { 6 + it("creates error with message and code", () => { 7 + const error = new MigrationError("Something went wrong", "ERR_NETWORK"); 8 + 9 + expect(error.message).toBe("Something went wrong"); 10 + expect(error.code).toBe("ERR_NETWORK"); 11 + expect(error.name).toBe("MigrationError"); 12 + }); 13 + 14 + it("defaults recoverable to false", () => { 15 + const error = new MigrationError("Error", "ERR_CODE"); 16 + 17 + expect(error.recoverable).toBe(false); 18 + }); 19 + 20 + it("accepts recoverable flag", () => { 21 + const error = new MigrationError("Temporary error", "ERR_TIMEOUT", true); 22 + 23 + expect(error.recoverable).toBe(true); 24 + }); 25 + 26 + it("accepts details object", () => { 27 + const details = { status: 500, endpoint: "/api/test" }; 28 + const error = new MigrationError( 29 + "Server error", 30 + "ERR_SERVER", 31 + false, 32 + details, 33 + ); 34 + 35 + expect(error.details).toEqual(details); 36 + }); 37 + 38 + it("is instanceof Error", () => { 39 + const error = new MigrationError("Test", "ERR_TEST"); 40 + 41 + expect(error).toBeInstanceOf(Error); 42 + expect(error).toBeInstanceOf(MigrationError); 43 + }); 44 + 45 + it("has proper stack trace", () => { 46 + const error = new MigrationError("Test", "ERR_TEST"); 47 + 48 + expect(error.stack).toBeDefined(); 49 + expect(error.stack).toContain("MigrationError"); 50 + }); 51 + 52 + it("can be caught as Error", () => { 53 + let caught: Error | null = null; 54 + 55 + try { 56 + throw new MigrationError("Test error", "ERR_TEST"); 57 + } catch (e) { 58 + caught = e as Error; 59 + } 60 + 61 + expect(caught).not.toBeNull(); 62 + expect(caught!.message).toBe("Test error"); 63 + }); 64 + 65 + it("can check if error is MigrationError", () => { 66 + const error = new MigrationError("Test", "ERR_TEST", true, { foo: "bar" }); 67 + 68 + if (error instanceof MigrationError) { 69 + expect(error.code).toBe("ERR_TEST"); 70 + expect(error.recoverable).toBe(true); 71 + expect(error.details).toEqual({ foo: "bar" }); 72 + } 73 + }); 74 + }); 75 + });
+8 -2
frontend/src/tests/mocks.ts
··· 29 return match ? match[1] : url; 30 } 31 export function setupFetchMock(): void { 32 - global.fetch = vi.fn( 33 async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { 34 const url = typeof input === "string" ? input : input.toString(); 35 const endpoint = extractEndpoint(url); ··· 137 signalVerified: false, 138 ...overrides, 139 }), 140 - describeServer: () => ({ 141 availableUserDomains: ["test.tranquil.dev"], 142 inviteCodeRequired: false, 143 links: { ··· 145 termsOfService: "https://example.com/tos", 146 }, 147 selfHostedDidWebEnabled: true, 148 }), 149 describeRepo: (did: string) => ({ 150 handle: "testuser.test.tranquil.dev", ··· 210 mockEndpoint( 211 "com.tranquil.account.updateNotificationPrefs", 212 () => jsonResponse({ success: true }), 213 ); 214 mockEndpoint( 215 "com.atproto.server.requestEmailUpdate",
··· 29 return match ? match[1] : url; 30 } 31 export function setupFetchMock(): void { 32 + globalThis.fetch = vi.fn( 33 async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { 34 const url = typeof input === "string" ? input : input.toString(); 35 const endpoint = extractEndpoint(url); ··· 137 signalVerified: false, 138 ...overrides, 139 }), 140 + describeServer: (overrides?: Record<string, unknown>) => ({ 141 availableUserDomains: ["test.tranquil.dev"], 142 inviteCodeRequired: false, 143 links: { ··· 145 termsOfService: "https://example.com/tos", 146 }, 147 selfHostedDidWebEnabled: true, 148 + availableCommsChannels: ["email", "discord", "telegram", "signal"], 149 + ...overrides, 150 }), 151 describeRepo: (did: string) => ({ 152 handle: "testuser.test.tranquil.dev", ··· 212 mockEndpoint( 213 "com.tranquil.account.updateNotificationPrefs", 214 () => jsonResponse({ success: true }), 215 + ); 216 + mockEndpoint( 217 + "com.tranquil.account.getNotificationHistory", 218 + () => jsonResponse({ notifications: [] }), 219 ); 220 mockEndpoint( 221 "com.atproto.server.requestEmailUpdate",
+12 -5
frontend/src/tests/setup.ts
··· 1 import "@testing-library/jest-dom/vitest"; 2 import { afterEach, beforeEach, vi } from "vitest"; 3 - import { _testReset } from "../lib/auth.svelte"; 4 5 let locationHash = ""; 6 ··· 24 configurable: true, 25 }); 26 27 - beforeEach(() => { 28 vi.clearAllMocks(); 29 - localStorage.clear(); 30 - sessionStorage.clear(); 31 locationHash = ""; 32 - _testReset(); 33 }); 34 35 afterEach(() => {
··· 1 import "@testing-library/jest-dom/vitest"; 2 import { afterEach, beforeEach, vi } from "vitest"; 3 + import { init, register, waitLocale } from "svelte-i18n"; 4 + import { _testResetState } from "../lib/auth.svelte"; 5 + 6 + register("en", () => import("../locales/en.json")); 7 + 8 + init({ 9 + fallbackLocale: "en", 10 + initialLocale: "en", 11 + }); 12 13 let locationHash = ""; 14 ··· 32 configurable: true, 33 }); 34 35 + beforeEach(async () => { 36 vi.clearAllMocks(); 37 locationHash = ""; 38 + _testResetState(); 39 + await waitLocale(); 40 }); 41 42 afterEach(() => {
+1
frontend/svelte.config.js
··· 1 import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 const isTest = process.env.VITEST === "true" || process.env.VITEST === true; 3 export default {
··· 1 + import process from "node:process"; 2 import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 const isTest = process.env.VITEST === "true" || process.env.VITEST === true; 4 export default {
+1
frontend/vite.config.ts
··· 1 import { defineConfig, loadEnv } from "vite"; 2 import { svelte } from "@sveltejs/vite-plugin-svelte"; 3
··· 1 + import process from "node:process"; 2 import { defineConfig, loadEnv } from "vite"; 3 import { svelte } from "@sveltejs/vite-plugin-svelte"; 4
+27 -28
src/api/actor/preferences.rs
··· 108 serde_json::from_value(row.value_json).ok() 109 }) 110 .collect(); 111 - if let Some(ref pref) = personal_details_pref { 112 - if let Some(birth_date) = pref.get("birthDate").and_then(|v| v.as_str()) { 113 - if let Some(age) = get_age_from_datestring(birth_date) { 114 - let declared_age_pref = json!({ 115 - "$type": DECLARED_AGE_PREF, 116 - "isOverAge13": age >= 13, 117 - "isOverAge16": age >= 16, 118 - "isOverAge18": age >= 18, 119 - }); 120 - preferences.push(declared_age_pref); 121 - } 122 - } 123 } 124 (StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response() 125 } ··· 157 } 158 }; 159 let has_full_access = auth_user.permissions().has_full_access(); 160 - let user_id: uuid::Uuid = match sqlx::query_scalar!( 161 - "SELECT id FROM users WHERE did = $1", 162 - auth_user.did 163 - ) 164 - .fetch_optional(&state.db) 165 - .await 166 - { 167 - Ok(Some(id)) => id, 168 - _ => { 169 - return ( 170 - StatusCode::INTERNAL_SERVER_ERROR, 171 - Json(json!({"error": "InternalError", "message": "User not found"})), 172 - ) 173 - .into_response(); 174 - } 175 - }; 176 if input.preferences.len() > MAX_PREFERENCES_COUNT { 177 return ( 178 StatusCode::BAD_REQUEST,
··· 108 serde_json::from_value(row.value_json).ok() 109 }) 110 .collect(); 111 + if let Some(age) = personal_details_pref 112 + .as_ref() 113 + .and_then(|pref| pref.get("birthDate")) 114 + .and_then(|v| v.as_str()) 115 + .and_then(get_age_from_datestring) 116 + { 117 + let declared_age_pref = json!({ 118 + "$type": DECLARED_AGE_PREF, 119 + "isOverAge13": age >= 13, 120 + "isOverAge16": age >= 16, 121 + "isOverAge18": age >= 18, 122 + }); 123 + preferences.push(declared_age_pref); 124 } 125 (StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response() 126 } ··· 158 } 159 }; 160 let has_full_access = auth_user.permissions().has_full_access(); 161 + let user_id: uuid::Uuid = 162 + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 163 + .fetch_optional(&state.db) 164 + .await 165 + { 166 + Ok(Some(id)) => id, 167 + _ => { 168 + return ( 169 + StatusCode::INTERNAL_SERVER_ERROR, 170 + Json(json!({"error": "InternalError", "message": "User not found"})), 171 + ) 172 + .into_response(); 173 + } 174 + }; 175 if input.preferences.len() > MAX_PREFERENCES_COUNT { 176 return ( 177 StatusCode::BAD_REQUEST,
+1 -4
src/api/admin/account/info.rs
··· 125 } 126 } 127 128 - async fn get_invited_by( 129 - db: &sqlx::PgPool, 130 - user_id: uuid::Uuid, 131 - ) -> Option<InviteCodeInfo> { 132 let use_row = sqlx::query!( 133 r#" 134 SELECT icu.code
··· 125 } 126 } 127 128 + async fn get_invited_by(db: &sqlx::PgPool, user_id: uuid::Uuid) -> Option<InviteCodeInfo> { 129 let use_row = sqlx::query!( 130 r#" 131 SELECT icu.code
+9 -1
src/api/admin/account/search.rs
··· 91 .into_iter() 92 .take(limit as usize) 93 .map( 94 - |(did, handle, email, created_at, email_verified, deactivated_at, invites_disabled)| { 95 AccountView { 96 did: did.clone(), 97 handle,
··· 91 .into_iter() 92 .take(limit as usize) 93 .map( 94 + |( 95 + did, 96 + handle, 97 + email, 98 + created_at, 99 + email_verified, 100 + deactivated_at, 101 + invites_disabled, 102 + )| { 103 AccountView { 104 did: did.clone(), 105 handle,
+4 -1
src/api/admin/account/update.rs
··· 131 if let Err(e) = 132 crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await 133 { 134 - warn!("Failed to sequence identity event for admin handle update: {}", e); 135 } 136 if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await 137 {
··· 131 if let Err(e) = 132 crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await 133 { 134 + warn!( 135 + "Failed to sequence identity event for admin handle update: {}", 136 + e 137 + ); 138 } 139 if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await 140 {
+8 -3
src/api/identity/account.rs
··· 1005 { 1006 warn!("Failed to sequence account event for {}: {}", did, e); 1007 } 1008 - if let Err(e) = 1009 - crate::api::repo::record::sequence_genesis_commit(&state, &did, &commit_cid, &mst_root, &rev_str).await 1010 { 1011 warn!("Failed to sequence commit event for {}: {}", did, e); 1012 } ··· 1144 ) 1145 .into_response() 1146 } 1147 -
··· 1005 { 1006 warn!("Failed to sequence account event for {}: {}", did, e); 1007 } 1008 + if let Err(e) = crate::api::repo::record::sequence_genesis_commit( 1009 + &state, 1010 + &did, 1011 + &commit_cid, 1012 + &mst_root, 1013 + &rev_str, 1014 + ) 1015 + .await 1016 { 1017 warn!("Failed to sequence commit event for {}: {}", did, e); 1018 } ··· 1150 ) 1151 .into_response() 1152 }
+74 -74
src/api/identity/did.rs
··· 191 192 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 193 194 - if let Some(ref ovr) = overrides { 195 - if let Ok(parsed) = 196 - serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone()) 197 - { 198 - if !parsed.is_empty() { 199 - let also_known_as = if !ovr.also_known_as.is_empty() { 200 - ovr.also_known_as.clone() 201 - } else { 202 - vec![format!("at://{}", full_handle)] 203 - }; 204 205 - return Json(json!({ 206 - "@context": [ 207 - "https://www.w3.org/ns/did/v1", 208 - "https://w3id.org/security/multikey/v1", 209 - "https://w3id.org/security/suites/secp256k1-2019/v1" 210 - ], 211 - "id": did, 212 - "alsoKnownAs": also_known_as, 213 - "verificationMethod": parsed.iter().map(|m| json!({ 214 - "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 215 - "type": m.method_type, 216 - "controller": did, 217 - "publicKeyMultibase": m.public_key_multibase 218 - })).collect::<Vec<_>>(), 219 - "service": [{ 220 - "id": "#atproto_pds", 221 - "type": "AtprotoPersonalDataServer", 222 - "serviceEndpoint": service_endpoint 223 - }] 224 - })) 225 - .into_response(); 226 - } 227 - } 228 } 229 230 let key_row = sqlx::query!( ··· 351 352 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 353 354 - if let Some(ref ovr) = overrides { 355 - if let Ok(parsed) = 356 - serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone()) 357 - { 358 - if !parsed.is_empty() { 359 - let also_known_as = if !ovr.also_known_as.is_empty() { 360 - ovr.also_known_as.clone() 361 - } else { 362 - vec![format!("at://{}", full_handle)] 363 - }; 364 365 - return Json(json!({ 366 - "@context": [ 367 - "https://www.w3.org/ns/did/v1", 368 - "https://w3id.org/security/multikey/v1", 369 - "https://w3id.org/security/suites/secp256k1-2019/v1" 370 - ], 371 - "id": did, 372 - "alsoKnownAs": also_known_as, 373 - "verificationMethod": parsed.iter().map(|m| json!({ 374 - "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 375 - "type": m.method_type, 376 - "controller": did, 377 - "publicKeyMultibase": m.public_key_multibase 378 - })).collect::<Vec<_>>(), 379 - "service": [{ 380 - "id": "#atproto_pds", 381 - "type": "AtprotoPersonalDataServer", 382 - "serviceEndpoint": service_endpoint 383 - }] 384 - })) 385 - .into_response(); 386 - } 387 - } 388 } 389 390 let key_row = sqlx::query!( ··· 637 let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { 638 Ok(key) => key, 639 Err(_) => { 640 - warn!("PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"); 641 did_key.clone() 642 } 643 }; ··· 709 ) 710 .into_response(); 711 } 712 - let user_row = match sqlx::query!( 713 - "SELECT id, handle FROM users WHERE did = $1", 714 - did 715 - ) 716 - .fetch_optional(&state.db) 717 - .await 718 { 719 Ok(Some(row)) => row, 720 _ => return ApiError::InternalError.into_response(), ··· 879 match result { 880 Ok(_) => { 881 if !current_handle.is_empty() { 882 - let _ = state.cache.delete(&format!("handle:{}", current_handle)).await; 883 } 884 let _ = state.cache.delete(&format!("handle:{}", handle)).await; 885 if let Err(e) =
··· 191 192 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 193 194 + if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| { 195 + serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone()) 196 + .ok() 197 + .filter(|p| !p.is_empty()) 198 + .map(|p| (ovr, p)) 199 + }) { 200 + let also_known_as = if !ovr.also_known_as.is_empty() { 201 + ovr.also_known_as.clone() 202 + } else { 203 + vec![format!("at://{}", full_handle)] 204 + }; 205 206 + return Json(json!({ 207 + "@context": [ 208 + "https://www.w3.org/ns/did/v1", 209 + "https://w3id.org/security/multikey/v1", 210 + "https://w3id.org/security/suites/secp256k1-2019/v1" 211 + ], 212 + "id": did, 213 + "alsoKnownAs": also_known_as, 214 + "verificationMethod": parsed.iter().map(|m| json!({ 215 + "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 216 + "type": m.method_type, 217 + "controller": did, 218 + "publicKeyMultibase": m.public_key_multibase 219 + })).collect::<Vec<_>>(), 220 + "service": [{ 221 + "id": "#atproto_pds", 222 + "type": "AtprotoPersonalDataServer", 223 + "serviceEndpoint": service_endpoint 224 + }] 225 + })) 226 + .into_response(); 227 } 228 229 let key_row = sqlx::query!( ··· 350 351 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 352 353 + if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| { 354 + serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone()) 355 + .ok() 356 + .filter(|p| !p.is_empty()) 357 + .map(|p| (ovr, p)) 358 + }) { 359 + let also_known_as = if !ovr.also_known_as.is_empty() { 360 + ovr.also_known_as.clone() 361 + } else { 362 + vec![format!("at://{}", full_handle)] 363 + }; 364 365 + return Json(json!({ 366 + "@context": [ 367 + "https://www.w3.org/ns/did/v1", 368 + "https://w3id.org/security/multikey/v1", 369 + "https://w3id.org/security/suites/secp256k1-2019/v1" 370 + ], 371 + "id": did, 372 + "alsoKnownAs": also_known_as, 373 + "verificationMethod": parsed.iter().map(|m| json!({ 374 + "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 375 + "type": m.method_type, 376 + "controller": did, 377 + "publicKeyMultibase": m.public_key_multibase 378 + })).collect::<Vec<_>>(), 379 + "service": [{ 380 + "id": "#atproto_pds", 381 + "type": "AtprotoPersonalDataServer", 382 + "serviceEndpoint": service_endpoint 383 + }] 384 + })) 385 + .into_response(); 386 } 387 388 let key_row = sqlx::query!( ··· 635 let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { 636 Ok(key) => key, 637 Err(_) => { 638 + warn!( 639 + "PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation" 640 + ); 641 did_key.clone() 642 } 643 }; ··· 709 ) 710 .into_response(); 711 } 712 + let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 713 + .fetch_optional(&state.db) 714 + .await 715 { 716 Ok(Some(row)) => row, 717 _ => return ApiError::InternalError.into_response(), ··· 876 match result { 877 Ok(_) => { 878 if !current_handle.is_empty() { 879 + let _ = state 880 + .cache 881 + .delete(&format!("handle:{}", current_handle)) 882 + .await; 883 } 884 let _ = state.cache.delete(&format!("handle:{}", handle)).await; 885 if let Err(e) =
+18 -20
src/api/identity/plc/submit.rs
··· 58 let op = &input.operation; 59 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 60 let public_url = format!("https://{}", hostname); 61 - let user = match sqlx::query!( 62 - "SELECT id, handle FROM users WHERE did = $1", 63 - did 64 - ) 65 - .fetch_optional(&state.db) 66 - .await 67 { 68 Ok(Some(row)) => row, 69 _ => { ··· 170 ) 171 .into_response(); 172 } 173 - if !user.handle.is_empty() { 174 - if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { 175 - let expected_handle = format!("at://{}", user.handle); 176 - let first_aka = also_known_as.first().and_then(|v| v.as_str()); 177 - if first_aka != Some(&expected_handle) { 178 - return ( 179 - StatusCode::BAD_REQUEST, 180 - Json(json!({ 181 - "error": "InvalidRequest", 182 - "message": "Incorrect handle in alsoKnownAs" 183 - })), 184 - ) 185 - .into_response(); 186 - } 187 } 188 } 189 let plc_client = PlcClient::new(None);
··· 58 let op = &input.operation; 59 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 60 let public_url = format!("https://{}", hostname); 61 + let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 62 + .fetch_optional(&state.db) 63 + .await 64 { 65 Ok(Some(row)) => row, 66 _ => { ··· 167 ) 168 .into_response(); 169 } 170 + if let Some(also_known_as) = (!user.handle.is_empty()) 171 + .then(|| op.get("alsoKnownAs").and_then(|v| v.as_array())) 172 + .flatten() 173 + { 174 + let expected_handle = format!("at://{}", user.handle); 175 + let first_aka = also_known_as.first().and_then(|v| v.as_str()); 176 + if first_aka != Some(&expected_handle) { 177 + return ( 178 + StatusCode::BAD_REQUEST, 179 + Json(json!({ 180 + "error": "InvalidRequest", 181 + "message": "Incorrect handle in alsoKnownAs" 182 + })), 183 + ) 184 + .into_response(); 185 } 186 } 187 let plc_client = PlcClient::new(None);
+7 -13
src/api/moderation/mod.rs
··· 51 None => return ApiError::AuthenticationRequired.into_response(), 52 }; 53 54 - let auth_user = match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await 55 - { 56 - Ok(user) => user, 57 - Err(e) => return ApiError::from(e).into_response(), 58 - }; 59 60 let did = &auth_user.did; 61 62 if let Some((service_url, service_did)) = get_report_service_config() { 63 - return proxy_to_report_service( 64 - &state, 65 - &auth_user, 66 - &service_url, 67 - &service_did, 68 - &input, 69 - ) 70 - .await; 71 } 72 73 create_report_locally(&state, did, auth_user.is_takendown, input).await
··· 51 None => return ApiError::AuthenticationRequired.into_response(), 52 }; 53 54 + let auth_user = 55 + match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await { 56 + Ok(user) => user, 57 + Err(e) => return ApiError::from(e).into_response(), 58 + }; 59 60 let did = &auth_user.did; 61 62 if let Some((service_url, service_did)) = get_report_service_config() { 63 + return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input) 64 + .await; 65 } 66 67 create_report_locally(&state, did, auth_user.is_takendown, input).await
+4 -1
src/api/repo/import.rs
··· 346 } 347 } 348 if blob_ref_count > 0 { 349 - info!("Recorded {} blob references for imported repo", blob_ref_count); 350 } 351 let key_row = match sqlx::query!( 352 r#"SELECT uk.key_bytes, uk.encryption_version
··· 346 } 347 } 348 if blob_ref_count > 0 { 349 + info!( 350 + "Recorded {} blob references for imported repo", 351 + blob_ref_count 352 + ); 353 } 354 let key_row = match sqlx::query!( 355 r#"SELECT uk.key_bytes, uk.encryption_version
+1 -1
src/api/repo/record/batch.rs
··· 1 use super::validation::validate_record_with_status; 2 use super::write::has_verified_comms_channel; 3 - use crate::validation::ValidationStatus; 4 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 5 use crate::delegation::{self, DelegationActionType}; 6 use crate::repo::tracking::TrackingBlockStore; 7 use crate::state::AppState; 8 use axum::{ 9 Json, 10 extract::State,
··· 1 use super::validation::validate_record_with_status; 2 use super::write::has_verified_comms_channel; 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::repo::tracking::TrackingBlockStore; 6 use crate::state::AppState; 7 + use crate::validation::ValidationStatus; 8 use axum::{ 9 Json, 10 extract::State,
+1 -5
src/api/repo/record/delete.rs
··· 127 } 128 let prev_record_cid = mst.get(&key).await.ok().flatten(); 129 if prev_record_cid.is_none() { 130 - return ( 131 - StatusCode::OK, 132 - Json(DeleteRecordOutput { commit: None }), 133 - ) 134 - .into_response(); 135 } 136 let new_mst = match mst.delete(&key).await { 137 Ok(m) => m,
··· 127 } 128 let prev_record_cid = mst.get(&key).await.ok().flatten(); 129 if prev_record_cid.is_none() { 130 + return (StatusCode::OK, Json(DeleteRecordOutput { commit: None })).into_response(); 131 } 132 let new_mst = match mst.delete(&key).await { 133 Ok(m) => m,
+1 -1
src/api/repo/record/write.rs
··· 1 use super::validation::validate_record_with_status; 2 - use crate::validation::ValidationStatus; 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::repo::tracking::TrackingBlockStore; 6 use crate::state::AppState; 7 use axum::{ 8 Json, 9 extract::State,
··· 1 use super::validation::validate_record_with_status; 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 3 use crate::delegation::{self, DelegationActionType}; 4 use crate::repo::tracking::TrackingBlockStore; 5 use crate::state::AppState; 6 + use crate::validation::ValidationStatus; 7 use axum::{ 8 Json, 9 extract::State,
+11 -12
src/api/server/account_status.rs
··· 92 Ok(Some(row)) => (row.repo_root_cid, row.repo_rev), 93 _ => (String::new(), None), 94 }; 95 - let block_count: i64 = 96 - sqlx::query_scalar!("SELECT COUNT(*) FROM user_blocks WHERE user_id = $1", user_id) 97 - .fetch_one(&state.db) 98 - .await 99 - .unwrap_or(Some(0)) 100 - .unwrap_or(0); 101 let repo_rev = if let Some(rev) = repo_rev_from_db { 102 rev 103 } else if !repo_commit.is_empty() { ··· 241 let rotation_keys = doc_data 242 .get("rotationKeys") 243 .and_then(|v| v.as_array()) 244 - .map(|arr| { 245 - arr.iter() 246 - .filter_map(|k| k.as_str()) 247 - .collect::<Vec<_>>() 248 - }) 249 .unwrap_or_default(); 250 if !rotation_keys.contains(&expected_rotation_key.as_str()) { 251 return Err(( ··· 440 did 441 ); 442 let did_validation_start = std::time::Instant::now(); 443 - if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await { 444 info!( 445 "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", 446 did,
··· 92 Ok(Some(row)) => (row.repo_root_cid, row.repo_rev), 93 _ => (String::new(), None), 94 }; 95 + let block_count: i64 = sqlx::query_scalar!( 96 + "SELECT COUNT(*) FROM user_blocks WHERE user_id = $1", 97 + user_id 98 + ) 99 + .fetch_one(&state.db) 100 + .await 101 + .unwrap_or(Some(0)) 102 + .unwrap_or(0); 103 let repo_rev = if let Some(rev) = repo_rev_from_db { 104 rev 105 } else if !repo_commit.is_empty() { ··· 243 let rotation_keys = doc_data 244 .get("rotationKeys") 245 .and_then(|v| v.as_array()) 246 + .map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>()) 247 .unwrap_or_default(); 248 if !rotation_keys.contains(&expected_rotation_key.as_str()) { 249 return Err(( ··· 438 did 439 ); 440 let did_validation_start = std::time::Instant::now(); 441 + if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await 442 + { 443 info!( 444 "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", 445 did,
+10 -12
src/api/server/email.rs
··· 86 "email_update", 87 &current_email.to_lowercase(), 88 ); 89 - let formatted_code = 90 - crate::auth::verification_token::format_token_for_display(&code); 91 92 - let hostname = 93 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 94 - if let Err(e) = crate::comms::enqueue_email_update_token( 95 - &state.db, 96 - user.id, 97 - &formatted_code, 98 - &hostname, 99 - ) 100 - .await 101 { 102 warn!("Failed to enqueue email update notification: {:?}", e); 103 } 104 } 105 106 info!("Email update requested for user {}", user.id); 107 - (StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response() 108 } 109 110 #[derive(Deserialize)]
··· 86 "email_update", 87 &current_email.to_lowercase(), 88 ); 89 + let formatted_code = crate::auth::verification_token::format_token_for_display(&code); 90 91 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 92 + if let Err(e) = 93 + crate::comms::enqueue_email_update_token(&state.db, user.id, &formatted_code, &hostname) 94 + .await 95 { 96 warn!("Failed to enqueue email update notification: {:?}", e); 97 } 98 } 99 100 info!("Email update requested for user {}", user.id); 101 + ( 102 + StatusCode::OK, 103 + Json(json!({ "tokenRequired": token_required })), 104 + ) 105 + .into_response() 106 } 107 108 #[derive(Deserialize)]
+16 -17
src/api/server/invite.rs
··· 1 use crate::api::ApiError; 2 - use crate::auth::extractor::BearerAuthAdmin; 3 use crate::auth::BearerAuth; 4 use crate::state::AppState; 5 use axum::{ 6 Json, ··· 114 .filter(|v| !v.is_empty()) 115 .unwrap_or_else(|| vec![auth_user.did.clone()]); 116 117 - let admin_user_id = match sqlx::query_scalar!( 118 - "SELECT id FROM users WHERE is_admin = true LIMIT 1" 119 - ) 120 - .fetch_optional(&state.db) 121 - .await 122 - { 123 - Ok(Some(id)) => id, 124 - Ok(None) => { 125 - error!("No admin user found to create invite codes"); 126 - return ApiError::InternalError.into_response(); 127 - } 128 - Err(e) => { 129 - error!("DB error looking up admin user: {:?}", e); 130 - return ApiError::InternalError.into_response(); 131 - } 132 - }; 133 134 let mut result_codes = Vec::new(); 135
··· 1 use crate::api::ApiError; 2 use crate::auth::BearerAuth; 3 + use crate::auth::extractor::BearerAuthAdmin; 4 use crate::state::AppState; 5 use axum::{ 6 Json, ··· 114 .filter(|v| !v.is_empty()) 115 .unwrap_or_else(|| vec![auth_user.did.clone()]); 116 117 + let admin_user_id = 118 + match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1") 119 + .fetch_optional(&state.db) 120 + .await 121 + { 122 + Ok(Some(id)) => id, 123 + Ok(None) => { 124 + error!("No admin user found to create invite codes"); 125 + return ApiError::InternalError.into_response(); 126 + } 127 + Err(e) => { 128 + error!("DB error looking up admin user: {:?}", e); 129 + return ApiError::InternalError.into_response(); 130 + } 131 + }; 132 133 let mut result_codes = Vec::new(); 134
+42 -49
src/api/server/migration.rs
··· 332 333 if let Some(ref methods) = input.verification_methods { 334 if methods.is_empty() { 335 - return ApiError::InvalidRequest( 336 - "verification_methods cannot be empty".into(), 337 - ) 338 - .into_response(); 339 } 340 for method in methods { 341 if method.id.is_empty() { ··· 366 if let Some(ref handles) = input.also_known_as { 367 for handle in handles { 368 if !handle.starts_with("at://") { 369 - return ApiError::InvalidRequest( 370 - "alsoKnownAs entries must be at:// URIs".into(), 371 - ) 372 - .into_response(); 373 } 374 } 375 } ··· 377 if let Some(ref endpoint) = input.service_endpoint { 378 let endpoint = endpoint.trim(); 379 if !endpoint.starts_with("https://") { 380 - return ApiError::InvalidRequest( 381 - "serviceEndpoint must start with https://".into(), 382 - ) 383 - .into_response(); 384 } 385 } 386 ··· 523 .migrated_to_pds 524 .unwrap_or_else(|| format!("https://{}", hostname)); 525 526 - if let Some(ref ovr) = overrides { 527 - if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) { 528 - if !parsed.is_empty() { 529 - let also_known_as = if !ovr.also_known_as.is_empty() { 530 - ovr.also_known_as.clone() 531 - } else { 532 - vec![format!("at://{}", user.handle)] 533 - }; 534 - return json!({ 535 - "@context": [ 536 - "https://www.w3.org/ns/did/v1", 537 - "https://w3id.org/security/multikey/v1", 538 - "https://w3id.org/security/suites/secp256k1-2019/v1" 539 - ], 540 - "id": did, 541 - "alsoKnownAs": also_known_as, 542 - "verificationMethod": parsed.iter().map(|m| json!({ 543 - "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 544 - "type": m.method_type, 545 - "controller": did, 546 - "publicKeyMultibase": m.public_key_multibase 547 - })).collect::<Vec<_>>(), 548 - "service": [{ 549 - "id": "#atproto_pds", 550 - "type": "AtprotoPersonalDataServer", 551 - "serviceEndpoint": service_endpoint 552 - }] 553 - }); 554 - } 555 - } 556 } 557 558 let key_row = sqlx::query!( ··· 563 .await; 564 565 let public_key_multibase = match key_row { 566 - Ok(Some(row)) => { 567 - match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 568 - Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes) 569 - .unwrap_or_else(|_| "error".to_string()), 570 - Err(_) => "error".to_string(), 571 - } 572 - } 573 _ => "error".to_string(), 574 }; 575
··· 332 333 if let Some(ref methods) = input.verification_methods { 334 if methods.is_empty() { 335 + return ApiError::InvalidRequest("verification_methods cannot be empty".into()) 336 + .into_response(); 337 } 338 for method in methods { 339 if method.id.is_empty() { ··· 364 if let Some(ref handles) = input.also_known_as { 365 for handle in handles { 366 if !handle.starts_with("at://") { 367 + return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) 368 + .into_response(); 369 } 370 } 371 } ··· 373 if let Some(ref endpoint) = input.service_endpoint { 374 let endpoint = endpoint.trim(); 375 if !endpoint.starts_with("https://") { 376 + return ApiError::InvalidRequest("serviceEndpoint must start with https://".into()) 377 + .into_response(); 378 } 379 } 380 ··· 517 .migrated_to_pds 518 .unwrap_or_else(|| format!("https://{}", hostname)); 519 520 + if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| { 521 + serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) 522 + .ok() 523 + .filter(|p| !p.is_empty()) 524 + .map(|p| (ovr, p)) 525 + }) { 526 + let also_known_as = if !ovr.also_known_as.is_empty() { 527 + ovr.also_known_as.clone() 528 + } else { 529 + vec![format!("at://{}", user.handle)] 530 + }; 531 + return json!({ 532 + "@context": [ 533 + "https://www.w3.org/ns/did/v1", 534 + "https://w3id.org/security/multikey/v1", 535 + "https://w3id.org/security/suites/secp256k1-2019/v1" 536 + ], 537 + "id": did, 538 + "alsoKnownAs": also_known_as, 539 + "verificationMethod": parsed.iter().map(|m| json!({ 540 + "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }), 541 + "type": m.method_type, 542 + "controller": did, 543 + "publicKeyMultibase": m.public_key_multibase 544 + })).collect::<Vec<_>>(), 545 + "service": [{ 546 + "id": "#atproto_pds", 547 + "type": "AtprotoPersonalDataServer", 548 + "serviceEndpoint": service_endpoint 549 + }] 550 + }); 551 } 552 553 let key_row = sqlx::query!( ··· 558 .await; 559 560 let public_key_multibase = match key_row { 561 + Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { 562 + Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes) 563 + .unwrap_or_else(|_| "error".to_string()), 564 + Err(_) => "error".to_string(), 565 + }, 566 _ => "error".to_string(), 567 }; 568
+104 -30
src/api/server/passkey_account.rs
··· 84 pub handle: String, 85 pub setup_token: String, 86 pub setup_expires_at: chrono::DateTime<Utc>, 87 } 88 89 pub async fn create_passkey_account( ··· 378 d.to_string() 379 } 380 _ => { 381 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 382 - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); 383 - 384 - let genesis_result = match crate::plc::create_genesis_operation( 385 - &secret_key, 386 - &rotation_key, 387 - &handle, 388 - &pds_endpoint, 389 - ) { 390 - Ok(r) => r, 391 - Err(e) => { 392 - error!("Error creating PLC genesis operation: {:?}", e); 393 return ( 394 - StatusCode::INTERNAL_SERVER_ERROR, 395 - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 396 ) 397 .into_response(); 398 } 399 - }; 400 401 - let plc_client = crate::plc::PlcClient::new(None); 402 - if let Err(e) = plc_client 403 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 404 - .await 405 - { 406 - error!("Failed to submit PLC genesis operation: {:?}", e); 407 - return ( 408 - StatusCode::BAD_GATEWAY, 409 - Json(json!({ 410 - "error": "UpstreamError", 411 - "message": format!("Failed to register DID with PLC directory: {}", e) 412 - })), 413 - ) 414 - .into_response(); 415 } 416 - genesis_result.did 417 } 418 }; 419 ··· 726 727 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 728 729 Json(CreatePasskeyAccountResponse { 730 did, 731 handle, 732 setup_token, 733 setup_expires_at, 734 }) 735 .into_response() 736 }
··· 84 pub handle: String, 85 pub setup_token: String, 86 pub setup_expires_at: chrono::DateTime<Utc>, 87 + #[serde(skip_serializing_if = "Option::is_none")] 88 + pub access_jwt: Option<String>, 89 } 90 91 pub async fn create_passkey_account( ··· 380 d.to_string() 381 } 382 _ => { 383 + if let Some(ref auth_did) = byod_auth { 384 + if let Some(ref provided_did) = input.did { 385 + if provided_did.starts_with("did:plc:") { 386 + if provided_did != auth_did { 387 + return ( 388 + StatusCode::FORBIDDEN, 389 + Json(json!({ 390 + "error": "AuthorizationError", 391 + "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) 392 + })), 393 + ) 394 + .into_response(); 395 + } 396 + info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)"); 397 + provided_did.clone() 398 + } else { 399 + return ( 400 + StatusCode::BAD_REQUEST, 401 + Json(json!({ 402 + "error": "InvalidRequest", 403 + "message": "BYOD migration requires a did:plc or did:web DID" 404 + })), 405 + ) 406 + .into_response(); 407 + } 408 + } else { 409 return ( 410 + StatusCode::BAD_REQUEST, 411 + Json(json!({ 412 + "error": "InvalidRequest", 413 + "message": "BYOD migration requires the 'did' field" 414 + })), 415 ) 416 .into_response(); 417 } 418 + } else { 419 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 420 + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); 421 422 + let genesis_result = match crate::plc::create_genesis_operation( 423 + &secret_key, 424 + &rotation_key, 425 + &handle, 426 + &pds_endpoint, 427 + ) { 428 + Ok(r) => r, 429 + Err(e) => { 430 + error!("Error creating PLC genesis operation: {:?}", e); 431 + return ( 432 + StatusCode::INTERNAL_SERVER_ERROR, 433 + Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 434 + ) 435 + .into_response(); 436 + } 437 + }; 438 + 439 + let plc_client = crate::plc::PlcClient::new(None); 440 + if let Err(e) = plc_client 441 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 442 + .await 443 + { 444 + error!("Failed to submit PLC genesis operation: {:?}", e); 445 + return ( 446 + StatusCode::BAD_GATEWAY, 447 + Json(json!({ 448 + "error": "UpstreamError", 449 + "message": format!("Failed to register DID with PLC directory: {}", e) 450 + })), 451 + ) 452 + .into_response(); 453 + } 454 + genesis_result.did 455 } 456 } 457 }; 458 ··· 765 766 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 767 768 + let access_jwt = if byod_auth.is_some() { 769 + match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) { 770 + Ok(token_meta) => { 771 + let refresh_jti = uuid::Uuid::new_v4().to_string(); 772 + let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24); 773 + let no_scope: Option<String> = None; 774 + if let Err(e) = sqlx::query!( 775 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 776 + did, 777 + token_meta.jti, 778 + refresh_jti, 779 + token_meta.expires_at, 780 + refresh_expires, 781 + false, 782 + false, 783 + no_scope 784 + ) 785 + .execute(&state.db) 786 + .await 787 + { 788 + warn!(did = %did, "Failed to insert migration session: {:?}", e); 789 + } 790 + info!(did = %did, "Generated migration access token for BYOD passkey account"); 791 + Some(token_meta.token) 792 + } 793 + Err(e) => { 794 + warn!(did = %did, "Failed to generate migration access token: {:?}", e); 795 + None 796 + } 797 + } 798 + } else { 799 + None 800 + }; 801 + 802 Json(CreatePasskeyAccountResponse { 803 did, 804 handle, 805 setup_token, 806 setup_expires_at, 807 + access_jwt, 808 }) 809 .into_response() 810 }
+3 -6
src/api/server/session.rs
··· 334 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 335 let handle = full_handle(&row.handle, &pds_hostname); 336 let is_takendown = row.takedown_ref.is_some(); 337 - let is_migrated = 338 - row.deactivated_at.is_some() && row.migrated_to_pds.is_some(); 339 let is_active = row.deactivated_at.is_none() && !is_takendown; 340 let email_value = if can_read_email { 341 row.email.clone() ··· 368 if let Some(doc) = did_doc { 369 response["didDoc"] = doc; 370 } 371 - Json(response) 372 - .into_response() 373 } 374 Ok(None) => ApiError::AuthenticationFailed.into_response(), 375 Err(e) => { ··· 613 } else if u.deactivated_at.is_some() { 614 response["status"] = json!("deactivated"); 615 } 616 - Json(response) 617 - .into_response() 618 } 619 Ok(None) => { 620 error!("User not found for existing session: {}", session_row.did);
··· 334 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 335 let handle = full_handle(&row.handle, &pds_hostname); 336 let is_takendown = row.takedown_ref.is_some(); 337 + let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some(); 338 let is_active = row.deactivated_at.is_none() && !is_takendown; 339 let email_value = if can_read_email { 340 row.email.clone() ··· 367 if let Some(doc) = did_doc { 368 response["didDoc"] = doc; 369 } 370 + Json(response).into_response() 371 } 372 Ok(None) => ApiError::AuthenticationFailed.into_response(), 373 Err(e) => { ··· 611 } else if u.deactivated_at.is_some() { 612 response["status"] = json!("deactivated"); 613 } 614 + Json(response).into_response() 615 } 616 Ok(None) => { 617 error!("User not found for existing session: {}", session_row.did);
+2 -14
src/handle/reserved.rs
··· 2 use std::sync::LazyLock; 3 4 const ATP_SPECIFIC: &[&str] = &[ 5 - "at", 6 - "atp", 7 - "plc", 8 - "pds", 9 - "did", 10 - "repo", 11 - "tid", 12 - "nsid", 13 - "xrpc", 14 - "lex", 15 - "lexicon", 16 - "bsky", 17 - "bluesky", 18 - "handle", 19 ]; 20 21 const COMMONLY_RESERVED: &[&str] = &[
··· 2 use std::sync::LazyLock; 3 4 const ATP_SPECIFIC: &[&str] = &[ 5 + "at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky", 6 + "bluesky", "handle", 7 ]; 8 9 const COMMONLY_RESERVED: &[&str] = &[
+4 -1
src/main.rs
··· 5 use tracing::{error, info, warn}; 6 use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 - use tranquil_pds::scheduled::{backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, start_scheduled_tasks}; 9 use tranquil_pds::state::AppState; 10 11 #[tokio::main]
··· 5 use tracing::{error, info, warn}; 6 use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 + use tranquil_pds::scheduled::{ 9 + backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 + start_scheduled_tasks, 11 + }; 12 use tranquil_pds::state::AppState; 13 14 #[tokio::main]
+5 -2
src/oauth/endpoints/metadata.rs
··· 167 client_id, 168 client_name: "PDS Account Manager".to_string(), 169 client_uri: base_url.clone(), 170 - redirect_uris: vec![format!("{}/", base_url)], 171 grant_types: vec![ 172 "authorization_code".to_string(), 173 "refresh_token".to_string(), 174 ], 175 response_types: vec!["code".to_string()], 176 - scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*" 177 .to_string(), 178 token_endpoint_auth_method: "none".to_string(), 179 application_type: "web".to_string(),
··· 167 client_id, 168 client_name: "PDS Account Manager".to_string(), 169 client_uri: base_url.clone(), 170 + redirect_uris: vec![ 171 + format!("{}/", base_url), 172 + format!("{}/migrate", base_url), 173 + ], 174 grant_types: vec![ 175 "authorization_code".to_string(), 176 "refresh_token".to_string(), 177 ], 178 response_types: vec!["code".to_string()], 179 + scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*" 180 .to_string(), 181 token_endpoint_auth_method: "none".to_string(), 182 application_type: "web".to_string(),
+33 -39
src/scheduled.rs
··· 1 use cid::Cid; 2 use jacquard_repo::commit::Commit; 3 use jacquard_repo::storage::BlockStore; 4 - use ipld_core::ipld::Ipld; 5 use sqlx::PgPool; 6 use std::str::FromStr; 7 use std::sync::Arc; ··· 107 } 108 } 109 110 - info!(success, failed, "Completed genesis commit blocks_cids backfill"); 111 } 112 113 pub async fn backfill_repo_rev(db: &PgPool, block_store: PostgresBlockStore) { 114 - let repos_missing_rev = match sqlx::query!( 115 - "SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL" 116 - ) 117 - .fetch_all(db) 118 - .await 119 - { 120 - Ok(rows) => rows, 121 - Err(e) => { 122 - error!("Failed to query repos for backfill: {}", e); 123 - return; 124 - } 125 - }; 126 127 if repos_missing_rev.is_empty() { 128 debug!("No repos need repo_rev backfill"); ··· 244 if let Some(prev) = commit.prev { 245 to_visit.push(prev); 246 } 247 - } else if let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) { 248 - if let Ipld::Map(ref obj) = ipld { 249 - if let Some(Ipld::Link(left_cid)) = obj.get("l") { 250 - to_visit.push(*left_cid); 251 - } 252 - if let Some(Ipld::List(entries)) = obj.get("e") { 253 - for entry in entries { 254 - if let Ipld::Map(entry_obj) = entry { 255 - if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 256 - to_visit.push(*tree_cid); 257 - } 258 - if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") { 259 - to_visit.push(*val_cid); 260 - } 261 } 262 } 263 } ··· 361 362 let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0); 363 for blob_ref in blob_refs { 364 - let record_uri = format!( 365 - "at://{}/{}/{}", 366 - user.did, record.collection, record.rkey 367 - ); 368 if let Err(e) = sqlx::query!( 369 r#" 370 INSERT INTO record_blobs (repo_id, record_uri, blob_cid) ··· 490 did: &str, 491 _handle: &str, 492 ) -> Result<(), String> { 493 - let user_id: uuid::Uuid = sqlx::query_scalar!( 494 - "SELECT id FROM users WHERE did = $1", 495 - did 496 - ) 497 - .fetch_one(db) 498 - .await 499 - .map_err(|e| format!("DB error fetching user: {}", e))?; 500 501 let blob_storage_keys: Vec<String> = sqlx::query_scalar!( 502 r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
··· 1 use cid::Cid; 2 + use ipld_core::ipld::Ipld; 3 use jacquard_repo::commit::Commit; 4 use jacquard_repo::storage::BlockStore; 5 use sqlx::PgPool; 6 use std::str::FromStr; 7 use std::sync::Arc; ··· 107 } 108 } 109 110 + info!( 111 + success, 112 + failed, "Completed genesis commit blocks_cids backfill" 113 + ); 114 } 115 116 pub async fn backfill_repo_rev(db: &PgPool, block_store: PostgresBlockStore) { 117 + let repos_missing_rev = 118 + match sqlx::query!("SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL") 119 + .fetch_all(db) 120 + .await 121 + { 122 + Ok(rows) => rows, 123 + Err(e) => { 124 + error!("Failed to query repos for backfill: {}", e); 125 + return; 126 + } 127 + }; 128 129 if repos_missing_rev.is_empty() { 130 debug!("No repos need repo_rev backfill"); ··· 246 if let Some(prev) = commit.prev { 247 to_visit.push(prev); 248 } 249 + } else if let Ok(Ipld::Map(ref obj)) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) { 250 + if let Some(Ipld::Link(left_cid)) = obj.get("l") { 251 + to_visit.push(*left_cid); 252 + } 253 + if let Some(Ipld::List(entries)) = obj.get("e") { 254 + for entry in entries { 255 + if let Ipld::Map(entry_obj) = entry { 256 + if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 257 + to_visit.push(*tree_cid); 258 + } 259 + if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") { 260 + to_visit.push(*val_cid); 261 } 262 } 263 } ··· 361 362 let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0); 363 for blob_ref in blob_refs { 364 + let record_uri = format!("at://{}/{}/{}", user.did, record.collection, record.rkey); 365 if let Err(e) = sqlx::query!( 366 r#" 367 INSERT INTO record_blobs (repo_id, record_uri, blob_cid) ··· 487 did: &str, 488 _handle: &str, 489 ) -> Result<(), String> { 490 + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 491 + .fetch_one(db) 492 + .await 493 + .map_err(|e| format!("DB error fetching user: {}", e))?; 494 495 let blob_storage_keys: Vec<String> = sqlx::query_scalar!( 496 r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
+101 -28
tests/account_lifecycle.rs
··· 11 let (access_jwt, did) = create_account_and_login(&client).await; 12 13 let status1 = client 14 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 15 .bearer_auth(&access_jwt) 16 .send() 17 .await ··· 19 assert_eq!(status1.status(), StatusCode::OK); 20 let body1: Value = status1.json().await.unwrap(); 21 let initial_blocks = body1["repoBlocks"].as_i64().unwrap(); 22 - assert!(initial_blocks >= 2, "New account should have at least 2 blocks (commit + empty MST)"); 23 24 let create_res = client 25 .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) ··· 38 .unwrap(); 39 assert_eq!(create_res.status(), StatusCode::OK); 40 let create_body: Value = create_res.json().await.unwrap(); 41 - let rkey = create_body["uri"].as_str().unwrap().split('/').last().unwrap().to_string(); 42 43 let status2 = client 44 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 45 .bearer_auth(&access_jwt) 46 .send() 47 .await 48 .unwrap(); 49 let body2: Value = status2.json().await.unwrap(); 50 let after_create_blocks = body2["repoBlocks"].as_i64().unwrap(); 51 - assert!(after_create_blocks > initial_blocks, "Block count should increase after creating a record"); 52 53 let delete_res = client 54 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) ··· 64 assert_eq!(delete_res.status(), StatusCode::OK); 65 66 let status3 = client 67 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 68 .bearer_auth(&access_jwt) 69 .send() 70 .await ··· 86 let (access_jwt, _) = create_account_and_login(&client).await; 87 88 let status = client 89 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 90 .bearer_auth(&access_jwt) 91 .send() 92 .await ··· 96 97 let repo_rev = body["repoRev"].as_str().unwrap(); 98 assert!(!repo_rev.is_empty(), "repoRev should not be empty"); 99 - assert!(repo_rev.chars().all(|c| c.is_alphanumeric()), "repoRev should be alphanumeric TID"); 100 } 101 102 #[tokio::test] ··· 106 let (access_jwt, _) = create_account_and_login(&client).await; 107 108 let status = client 109 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 110 .bearer_auth(&access_jwt) 111 .send() 112 .await ··· 114 assert_eq!(status.status(), StatusCode::OK); 115 let body: Value = status.json().await.unwrap(); 116 117 - assert_eq!(body["validDid"], true, "validDid should be true for active account with correct DID document"); 118 - assert_eq!(body["activated"], true, "activated should be true for active account"); 119 } 120 121 #[tokio::test] ··· 128 let delete_after = future_time.to_rfc3339(); 129 130 let deactivate = client 131 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 132 .bearer_auth(&access_jwt) 133 .json(&json!({ 134 "deleteAfter": delete_after ··· 139 assert_eq!(deactivate.status(), StatusCode::OK); 140 141 let status = client 142 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 143 .bearer_auth(&access_jwt) 144 .send() 145 .await ··· 170 assert_eq!(create_res.status(), StatusCode::OK); 171 let body: Value = create_res.json().await.unwrap(); 172 173 - assert!(body["accessJwt"].is_string(), "accessJwt should always be returned"); 174 - assert!(body["refreshJwt"].is_string(), "refreshJwt should always be returned"); 175 assert!(body["did"].is_string(), "did should be returned"); 176 177 if body["didDoc"].is_object() { ··· 201 assert_eq!(create_res.status(), StatusCode::OK); 202 let body: Value = create_res.json().await.unwrap(); 203 204 - let access_jwt = body["accessJwt"].as_str().expect("accessJwt should be present"); 205 - let refresh_jwt = body["refreshJwt"].as_str().expect("refreshJwt should be present"); 206 207 assert!(!access_jwt.is_empty(), "accessJwt should not be empty"); 208 assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty"); 209 210 let parts: Vec<&str> = access_jwt.split('.').collect(); 211 - assert_eq!(parts.len(), 3, "accessJwt should be a valid JWT with 3 parts"); 212 } 213 214 #[tokio::test] ··· 224 assert_eq!(describe.status(), StatusCode::OK); 225 let body: Value = describe.json().await.unwrap(); 226 227 - assert!(body.get("links").is_some(), "describeServer should include links object"); 228 - assert!(body.get("contact").is_some(), "describeServer should include contact object"); 229 230 let links = &body["links"]; 231 - assert!(links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(), 232 - "links should have privacyPolicy field (can be null)"); 233 - assert!(links.get("termsOfService").is_some() || links["termsOfService"].is_null(), 234 - "links should have termsOfService field (can be null)"); 235 236 let contact = &body["contact"]; 237 - assert!(contact.get("email").is_some() || contact["email"].is_null(), 238 - "contact should have email field (can be null)"); 239 } 240 241 #[tokio::test] ··· 274 275 assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 276 let error_body: Value = delete_res.json().await.unwrap(); 277 - assert!(error_body["message"].as_str().unwrap().contains("password length") 278 - || error_body["error"].as_str().unwrap() == "InvalidRequest"); 279 }
··· 11 let (access_jwt, did) = create_account_and_login(&client).await; 12 13 let status1 = client 14 + .get(format!( 15 + "{}/xrpc/com.atproto.server.checkAccountStatus", 16 + base 17 + )) 18 .bearer_auth(&access_jwt) 19 .send() 20 .await ··· 22 assert_eq!(status1.status(), StatusCode::OK); 23 let body1: Value = status1.json().await.unwrap(); 24 let initial_blocks = body1["repoBlocks"].as_i64().unwrap(); 25 + assert!( 26 + initial_blocks >= 2, 27 + "New account should have at least 2 blocks (commit + empty MST)" 28 + ); 29 30 let create_res = client 31 .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) ··· 44 .unwrap(); 45 assert_eq!(create_res.status(), StatusCode::OK); 46 let create_body: Value = create_res.json().await.unwrap(); 47 + let rkey = create_body["uri"] 48 + .as_str() 49 + .unwrap() 50 + .split('/') 51 + .last() 52 + .unwrap() 53 + .to_string(); 54 55 let status2 = client 56 + .get(format!( 57 + "{}/xrpc/com.atproto.server.checkAccountStatus", 58 + base 59 + )) 60 .bearer_auth(&access_jwt) 61 .send() 62 .await 63 .unwrap(); 64 let body2: Value = status2.json().await.unwrap(); 65 let after_create_blocks = body2["repoBlocks"].as_i64().unwrap(); 66 + assert!( 67 + after_create_blocks > initial_blocks, 68 + "Block count should increase after creating a record" 69 + ); 70 71 let delete_res = client 72 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) ··· 82 assert_eq!(delete_res.status(), StatusCode::OK); 83 84 let status3 = client 85 + .get(format!( 86 + "{}/xrpc/com.atproto.server.checkAccountStatus", 87 + base 88 + )) 89 .bearer_auth(&access_jwt) 90 .send() 91 .await ··· 107 let (access_jwt, _) = create_account_and_login(&client).await; 108 109 let status = client 110 + .get(format!( 111 + "{}/xrpc/com.atproto.server.checkAccountStatus", 112 + base 113 + )) 114 .bearer_auth(&access_jwt) 115 .send() 116 .await ··· 120 121 let repo_rev = body["repoRev"].as_str().unwrap(); 122 assert!(!repo_rev.is_empty(), "repoRev should not be empty"); 123 + assert!( 124 + repo_rev.chars().all(|c| c.is_alphanumeric()), 125 + "repoRev should be alphanumeric TID" 126 + ); 127 } 128 129 #[tokio::test] ··· 133 let (access_jwt, _) = create_account_and_login(&client).await; 134 135 let status = client 136 + .get(format!( 137 + "{}/xrpc/com.atproto.server.checkAccountStatus", 138 + base 139 + )) 140 .bearer_auth(&access_jwt) 141 .send() 142 .await ··· 144 assert_eq!(status.status(), StatusCode::OK); 145 let body: Value = status.json().await.unwrap(); 146 147 + assert_eq!( 148 + body["validDid"], true, 149 + "validDid should be true for active account with correct DID document" 150 + ); 151 + assert_eq!( 152 + body["activated"], true, 153 + "activated should be true for active account" 154 + ); 155 } 156 157 #[tokio::test] ··· 164 let delete_after = future_time.to_rfc3339(); 165 166 let deactivate = client 167 + .post(format!( 168 + "{}/xrpc/com.atproto.server.deactivateAccount", 169 + base 170 + )) 171 .bearer_auth(&access_jwt) 172 .json(&json!({ 173 "deleteAfter": delete_after ··· 178 assert_eq!(deactivate.status(), StatusCode::OK); 179 180 let status = client 181 + .get(format!( 182 + "{}/xrpc/com.atproto.server.checkAccountStatus", 183 + base 184 + )) 185 .bearer_auth(&access_jwt) 186 .send() 187 .await ··· 212 assert_eq!(create_res.status(), StatusCode::OK); 213 let body: Value = create_res.json().await.unwrap(); 214 215 + assert!( 216 + body["accessJwt"].is_string(), 217 + "accessJwt should always be returned" 218 + ); 219 + assert!( 220 + body["refreshJwt"].is_string(), 221 + "refreshJwt should always be returned" 222 + ); 223 assert!(body["did"].is_string(), "did should be returned"); 224 225 if body["didDoc"].is_object() { ··· 249 assert_eq!(create_res.status(), StatusCode::OK); 250 let body: Value = create_res.json().await.unwrap(); 251 252 + let access_jwt = body["accessJwt"] 253 + .as_str() 254 + .expect("accessJwt should be present"); 255 + let refresh_jwt = body["refreshJwt"] 256 + .as_str() 257 + .expect("refreshJwt should be present"); 258 259 assert!(!access_jwt.is_empty(), "accessJwt should not be empty"); 260 assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty"); 261 262 let parts: Vec<&str> = access_jwt.split('.').collect(); 263 + assert_eq!( 264 + parts.len(), 265 + 3, 266 + "accessJwt should be a valid JWT with 3 parts" 267 + ); 268 } 269 270 #[tokio::test] ··· 280 assert_eq!(describe.status(), StatusCode::OK); 281 let body: Value = describe.json().await.unwrap(); 282 283 + assert!( 284 + body.get("links").is_some(), 285 + "describeServer should include links object" 286 + ); 287 + assert!( 288 + body.get("contact").is_some(), 289 + "describeServer should include contact object" 290 + ); 291 292 let links = &body["links"]; 293 + assert!( 294 + links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(), 295 + "links should have privacyPolicy field (can be null)" 296 + ); 297 + assert!( 298 + links.get("termsOfService").is_some() || links["termsOfService"].is_null(), 299 + "links should have termsOfService field (can be null)" 300 + ); 301 302 let contact = &body["contact"]; 303 + assert!( 304 + contact.get("email").is_some() || contact["email"].is_null(), 305 + "contact should have email field (can be null)" 306 + ); 307 } 308 309 #[tokio::test] ··· 342 343 assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 344 let error_body: Value = delete_res.json().await.unwrap(); 345 + assert!( 346 + error_body["message"] 347 + .as_str() 348 + .unwrap() 349 + .contains("password length") 350 + || error_body["error"].as_str().unwrap() == "InvalidRequest" 351 + ); 352 }
+1 -3
tests/account_notifications.rs
··· 23 format!("Subject {}", i), 24 format!("Body {}", i), 25 ); 26 - enqueue_comms(pool, comms) 27 - .await 28 - .expect("Failed to enqueue"); 29 } 30 31 let resp = client
··· 23 format!("Subject {}", i), 24 format!("Body {}", i), 25 ); 26 + enqueue_comms(pool, comms).await.expect("Failed to enqueue"); 27 } 28 29 let resp = client
+9 -2
tests/actor.rs
··· 174 let body: Value = get_resp.json().await.unwrap(); 175 let prefs_arr = body["preferences"].as_array().unwrap(); 176 assert_eq!(prefs_arr.len(), 1); 177 - assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#adultContentPref"); 178 } 179 180 #[tokio::test] ··· 393 let client = client(); 394 let base = base_url().await; 395 let (token, _did) = create_account_and_login(&client).await; 396 - let current_year = chrono::Utc::now().format("%Y").to_string().parse::<i32>().unwrap(); 397 let birth_year = current_year - 15; 398 let prefs = json!({ 399 "preferences": [
··· 174 let body: Value = get_resp.json().await.unwrap(); 175 let prefs_arr = body["preferences"].as_array().unwrap(); 176 assert_eq!(prefs_arr.len(), 1); 177 + assert_eq!( 178 + prefs_arr[0]["$type"], 179 + "app.bsky.actor.defs#adultContentPref" 180 + ); 181 } 182 183 #[tokio::test] ··· 396 let client = client(); 397 let base = base_url().await; 398 let (token, _did) = create_account_and_login(&client).await; 399 + let current_year = chrono::Utc::now() 400 + .format("%Y") 401 + .to_string() 402 + .parse::<i32>() 403 + .unwrap(); 404 let birth_year = current_year - 15; 405 let prefs = json!({ 406 "preferences": [
+4 -1
tests/admin_invite.rs
··· 217 .expect("Failed to get invite codes"); 218 let list_body: Value = list_res.json().await.unwrap(); 219 let codes = list_body["codes"].as_array().unwrap(); 220 - let admin_codes: Vec<_> = codes.iter().filter(|c| c["forAccount"].as_str() == Some(&did)).collect(); 221 for code in admin_codes { 222 assert_eq!(code["disabled"], true); 223 }
··· 217 .expect("Failed to get invite codes"); 218 let list_body: Value = list_res.json().await.unwrap(); 219 let codes = list_body["codes"].as_array().unwrap(); 220 + let admin_codes: Vec<_> = codes 221 + .iter() 222 + .filter(|c| c["forAccount"].as_str() == Some(&did)) 223 + .collect(); 224 for code in admin_codes { 225 assert_eq!(code["disabled"], true); 226 }
+20 -5
tests/did_web.rs
··· 569 let jwt = verify_new_account(&client, &did).await; 570 let target_pds = "https://pds2.example.com"; 571 let res = client 572 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 573 .bearer_auth(&jwt) 574 .json(&json!({ "migratingTo": target_pds })) 575 .send() ··· 633 .expect("Failed to send request"); 634 assert_eq!(res.status(), StatusCode::OK); 635 let res = client 636 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 637 .bearer_auth(&jwt) 638 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 639 .send() ··· 770 ); 771 let target_pds = "https://pds3.example.com"; 772 let res = client 773 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 774 .bearer_auth(&jwt) 775 .json(&json!({ "migratingTo": target_pds })) 776 .send() ··· 785 .expect("Failed to send request"); 786 assert_eq!(res.status(), StatusCode::OK); 787 let body: Value = res.json().await.expect("Response was not JSON"); 788 - assert_eq!(body["active"], false, "Migrated account should not be active"); 789 assert_eq!( 790 body["status"], "migrated", 791 "Status should be 'migrated' after migration" ··· 819 assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 820 let jwt = verify_new_account(&client, &did).await; 821 let res = client 822 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 823 .bearer_auth(&jwt) 824 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 825 .send()
··· 569 let jwt = verify_new_account(&client, &did).await; 570 let target_pds = "https://pds2.example.com"; 571 let res = client 572 + .post(format!( 573 + "{}/xrpc/com.atproto.server.deactivateAccount", 574 + base 575 + )) 576 .bearer_auth(&jwt) 577 .json(&json!({ "migratingTo": target_pds })) 578 .send() ··· 636 .expect("Failed to send request"); 637 assert_eq!(res.status(), StatusCode::OK); 638 let res = client 639 + .post(format!( 640 + "{}/xrpc/com.atproto.server.deactivateAccount", 641 + base 642 + )) 643 .bearer_auth(&jwt) 644 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 645 .send() ··· 776 ); 777 let target_pds = "https://pds3.example.com"; 778 let res = client 779 + .post(format!( 780 + "{}/xrpc/com.atproto.server.deactivateAccount", 781 + base 782 + )) 783 .bearer_auth(&jwt) 784 .json(&json!({ "migratingTo": target_pds })) 785 .send() ··· 794 .expect("Failed to send request"); 795 assert_eq!(res.status(), StatusCode::OK); 796 let body: Value = res.json().await.expect("Response was not JSON"); 797 + assert_eq!( 798 + body["active"], false, 799 + "Migrated account should not be active" 800 + ); 801 assert_eq!( 802 body["status"], "migrated", 803 "Status should be 'migrated' after migration" ··· 831 assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 832 let jwt = verify_new_account(&client, &did).await; 833 let res = client 834 + .post(format!( 835 + "{}/xrpc/com.atproto.server.deactivateAccount", 836 + base 837 + )) 838 .bearer_auth(&jwt) 839 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 840 .send()
+32 -19
tests/email_update.rs
··· 112 .expect("Failed to update email"); 113 assert_eq!(res.status(), StatusCode::OK); 114 115 - let user_email: Option<String> = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) 116 - .fetch_one(pool) 117 - .await 118 - .expect("User not found"); 119 assert_eq!(user_email, Some(new_email)); 120 } 121 ··· 255 assert_eq!(res.status(), StatusCode::OK); 256 let body: Value = res.json().await.expect("Invalid JSON"); 257 let did = body["did"].as_str().expect("No did").to_string(); 258 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 259 260 let body_text: String = sqlx::query_scalar!( 261 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", ··· 283 .expect("Failed to confirm email"); 284 assert_eq!(res.status(), StatusCode::OK); 285 286 - let verified: bool = sqlx::query_scalar!( 287 - "SELECT email_verified FROM users WHERE did = $1", 288 - did 289 - ) 290 - .fetch_one(pool) 291 - .await 292 - .expect("User not found"); 293 assert!(verified); 294 } 295 ··· 317 assert_eq!(res.status(), StatusCode::OK); 318 let body: Value = res.json().await.expect("Invalid JSON"); 319 let did = body["did"].as_str().expect("No did").to_string(); 320 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 321 322 let body_text: String = sqlx::query_scalar!( 323 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", ··· 370 .expect("Failed to create account"); 371 assert_eq!(res.status(), StatusCode::OK); 372 let body: Value = res.json().await.expect("Invalid JSON"); 373 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 374 375 let res = client 376 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) ··· 411 assert_eq!(res.status(), StatusCode::OK); 412 let body: Value = res.json().await.expect("Invalid JSON"); 413 let did = body["did"].as_str().expect("No did").to_string(); 414 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 415 416 let res = client 417 .post(format!( ··· 491 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 492 let body: Value = res.json().await.expect("Invalid JSON"); 493 assert_eq!(body["error"], "InvalidRequest"); 494 - assert!(body["message"] 495 - .as_str() 496 - .unwrap_or("") 497 - .contains("already in use")); 498 }
··· 112 .expect("Failed to update email"); 113 assert_eq!(res.status(), StatusCode::OK); 114 115 + let user_email: Option<String> = 116 + sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did) 117 + .fetch_one(pool) 118 + .await 119 + .expect("User not found"); 120 assert_eq!(user_email, Some(new_email)); 121 } 122 ··· 256 assert_eq!(res.status(), StatusCode::OK); 257 let body: Value = res.json().await.expect("Invalid JSON"); 258 let did = body["did"].as_str().expect("No did").to_string(); 259 + let access_jwt = body["accessJwt"] 260 + .as_str() 261 + .expect("No accessJwt") 262 + .to_string(); 263 264 let body_text: String = sqlx::query_scalar!( 265 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", ··· 287 .expect("Failed to confirm email"); 288 assert_eq!(res.status(), StatusCode::OK); 289 290 + let verified: bool = 291 + sqlx::query_scalar!("SELECT email_verified FROM users WHERE did = $1", did) 292 + .fetch_one(pool) 293 + .await 294 + .expect("User not found"); 295 assert!(verified); 296 } 297 ··· 319 assert_eq!(res.status(), StatusCode::OK); 320 let body: Value = res.json().await.expect("Invalid JSON"); 321 let did = body["did"].as_str().expect("No did").to_string(); 322 + let access_jwt = body["accessJwt"] 323 + .as_str() 324 + .expect("No accessJwt") 325 + .to_string(); 326 327 let body_text: String = sqlx::query_scalar!( 328 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", ··· 375 .expect("Failed to create account"); 376 assert_eq!(res.status(), StatusCode::OK); 377 let body: Value = res.json().await.expect("Invalid JSON"); 378 + let access_jwt = body["accessJwt"] 379 + .as_str() 380 + .expect("No accessJwt") 381 + .to_string(); 382 383 let res = client 384 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) ··· 419 assert_eq!(res.status(), StatusCode::OK); 420 let body: Value = res.json().await.expect("Invalid JSON"); 421 let did = body["did"].as_str().expect("No did").to_string(); 422 + let access_jwt = body["accessJwt"] 423 + .as_str() 424 + .expect("No accessJwt") 425 + .to_string(); 426 427 let res = client 428 .post(format!( ··· 502 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 503 let body: Value = res.json().await.expect("Invalid JSON"); 504 assert_eq!(body["error"], "InvalidRequest"); 505 + assert!( 506 + body["message"] 507 + .as_str() 508 + .unwrap_or("") 509 + .contains("already in use") 510 + ); 511 }
+4 -1
tests/identity.rs
··· 393 .await 394 .expect("Failed to get session"); 395 let session_body: Value = session.json().await.expect("Invalid JSON"); 396 - let current_handle = session_body["handle"].as_str().expect("No handle").to_string(); 397 let short_handle = current_handle.split('.').next().unwrap_or(&current_handle); 398 let res = client 399 .post(format!(
··· 393 .await 394 .expect("Failed to get session"); 395 let session_body: Value = session.json().await.expect("Invalid JSON"); 396 + let current_handle = session_body["handle"] 397 + .as_str() 398 + .expect("No handle") 399 + .to_string(); 400 let short_handle = current_handle.split('.').next().unwrap_or(&current_handle); 401 let res = client 402 .post(format!(
+13 -3
tests/invite.rs
··· 25 assert!(body["code"].is_string()); 26 let code = body["code"].as_str().unwrap(); 27 assert!(!code.is_empty()); 28 - assert!(code.contains('-'), "Code should be in hostname-xxxxx-xxxxx format"); 29 let parts: Vec<&str> = code.split('-').collect(); 30 - assert!(parts.len() >= 3, "Code should have at least 3 parts (hostname + 2 random parts)"); 31 } 32 33 #[tokio::test] ··· 363 let body: Value = res.json().await.expect("Response was not valid JSON"); 364 let codes = body["codes"].as_array().unwrap(); 365 for c in codes { 366 - assert_ne!(c["code"].as_str().unwrap(), code, "Disabled code should be filtered out"); 367 } 368 }
··· 25 assert!(body["code"].is_string()); 26 let code = body["code"].as_str().unwrap(); 27 assert!(!code.is_empty()); 28 + assert!( 29 + code.contains('-'), 30 + "Code should be in hostname-xxxxx-xxxxx format" 31 + ); 32 let parts: Vec<&str> = code.split('-').collect(); 33 + assert!( 34 + parts.len() >= 3, 35 + "Code should have at least 3 parts (hostname + 2 random parts)" 36 + ); 37 } 38 39 #[tokio::test] ··· 369 let body: Value = res.json().await.expect("Response was not valid JSON"); 370 let codes = body["codes"].as_array().unwrap(); 371 for c in codes { 372 + assert_ne!( 373 + c["code"].as_str().unwrap(), 374 + code, 375 + "Disabled code should be filtered out" 376 + ); 377 } 378 }
+20 -5
tests/lifecycle_session.rs
··· 291 let base = base_url().await; 292 let (jwt, _did) = create_account_and_login(&client).await; 293 let create_res = client 294 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 295 .bearer_auth(&jwt) 296 .json(&json!({ "name": "My App" })) 297 .send() ··· 299 .expect("Failed to create app password"); 300 assert_eq!(create_res.status(), StatusCode::OK); 301 let duplicate_res = client 302 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 303 .bearer_auth(&jwt) 304 .json(&json!({ "name": "My App" })) 305 .send() ··· 320 let base = base_url().await; 321 let (jwt, _did) = create_account_and_login(&client).await; 322 let revoke_res = client 323 - .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 324 .bearer_auth(&jwt) 325 .json(&json!({ "name": "Does Not Exist" })) 326 .send() ··· 356 let did = account["did"].as_str().unwrap(); 357 let main_jwt = verify_new_account(&client, did).await; 358 let create_app_res = client 359 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 360 .bearer_auth(&main_jwt) 361 .json(&json!({ "name": "Session Test App" })) 362 .send() ··· 389 "App password session should be valid before revocation" 390 ); 391 let revoke_res = client 392 - .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 393 .bearer_auth(&main_jwt) 394 .json(&json!({ "name": "Session Test App" })) 395 .send()
··· 291 let base = base_url().await; 292 let (jwt, _did) = create_account_and_login(&client).await; 293 let create_res = client 294 + .post(format!( 295 + "{}/xrpc/com.atproto.server.createAppPassword", 296 + base 297 + )) 298 .bearer_auth(&jwt) 299 .json(&json!({ "name": "My App" })) 300 .send() ··· 302 .expect("Failed to create app password"); 303 assert_eq!(create_res.status(), StatusCode::OK); 304 let duplicate_res = client 305 + .post(format!( 306 + "{}/xrpc/com.atproto.server.createAppPassword", 307 + base 308 + )) 309 .bearer_auth(&jwt) 310 .json(&json!({ "name": "My App" })) 311 .send() ··· 326 let base = base_url().await; 327 let (jwt, _did) = create_account_and_login(&client).await; 328 let revoke_res = client 329 + .post(format!( 330 + "{}/xrpc/com.atproto.server.revokeAppPassword", 331 + base 332 + )) 333 .bearer_auth(&jwt) 334 .json(&json!({ "name": "Does Not Exist" })) 335 .send() ··· 365 let did = account["did"].as_str().unwrap(); 366 let main_jwt = verify_new_account(&client, did).await; 367 let create_app_res = client 368 + .post(format!( 369 + "{}/xrpc/com.atproto.server.createAppPassword", 370 + base 371 + )) 372 .bearer_auth(&main_jwt) 373 .json(&json!({ "name": "Session Test App" })) 374 .send() ··· 401 "App password session should be valid before revocation" 402 ); 403 let revoke_res = client 404 + .post(format!( 405 + "{}/xrpc/com.atproto.server.revokeAppPassword", 406 + base 407 + )) 408 .bearer_auth(&main_jwt) 409 .json(&json!({ "name": "Session Test App" })) 410 .send()
+2 -8
tests/moderation.rs
··· 85 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 86 let body: Value = res.json().await.unwrap(); 87 assert_eq!(body["error"], "InvalidRequest"); 88 - assert!(body["message"] 89 - .as_str() 90 - .unwrap() 91 - .contains("reasonType")); 92 } 93 94 #[tokio::test] ··· 266 ); 267 let body: Value = report_res.json().await.unwrap(); 268 assert_eq!(body["error"], "InvalidRequest"); 269 - assert!(body["message"] 270 - .as_str() 271 - .unwrap() 272 - .contains("takendown")); 273 }
··· 85 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 86 let body: Value = res.json().await.unwrap(); 87 assert_eq!(body["error"], "InvalidRequest"); 88 + assert!(body["message"].as_str().unwrap().contains("reasonType")); 89 } 90 91 #[tokio::test] ··· 263 ); 264 let body: Value = report_res.json().await.unwrap(); 265 assert_eq!(body["error"], "InvalidRequest"); 266 + assert!(body["message"].as_str().unwrap().contains("takendown")); 267 }
+1 -2
tests/oauth_lifecycle.rs
··· 949 let url = base_url().await; 950 let http_client = client(); 951 let (alice, _mock_alice) = 952 - create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback") 953 - .await; 954 let (bob, _mock_bob) = 955 create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await; 956 let collection = "app.bsky.feed.post";
··· 949 let url = base_url().await; 950 let http_client = client(); 951 let (alice, _mock_alice) = 952 + create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback").await; 953 let (bob, _mock_bob) = 954 create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await; 955 let collection = "app.bsky.feed.post";
+173 -42
tests/repo_conformance.rs
··· 23 }); 24 25 let res = client 26 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 27 .bearer_auth(&jwt) 28 .json(&payload) 29 .send() ··· 35 36 assert!(body["uri"].is_string(), "response must have uri"); 37 assert!(body["cid"].is_string(), "response must have cid"); 38 - assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid"); 39 40 - assert!(body["commit"].is_object(), "response must have commit object"); 41 let commit = &body["commit"]; 42 assert!(commit["cid"].is_string(), "commit must have cid"); 43 - assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid"); 44 assert!(commit["rev"].is_string(), "commit must have rev"); 45 46 - assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true"); 47 - assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); 48 } 49 50 #[tokio::test] ··· 65 }); 66 67 let res = client 68 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 69 .bearer_auth(&jwt) 70 .json(&payload) 71 .send() ··· 77 78 assert!(body["uri"].is_string()); 79 assert!(body["commit"].is_object()); 80 - assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false"); 81 } 82 83 #[tokio::test] ··· 98 }); 99 100 let res = client 101 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 102 .bearer_auth(&jwt) 103 .json(&payload) 104 .send() ··· 111 assert!(body["uri"].is_string(), "response must have uri"); 112 assert!(body["cid"].is_string(), "response must have cid"); 113 114 - assert!(body["commit"].is_object(), "response must have commit object"); 115 let commit = &body["commit"]; 116 assert!(commit["cid"].is_string(), "commit must have cid"); 117 assert!(commit["rev"].is_string(), "commit must have rev"); 118 119 - assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); 120 } 121 122 #[tokio::test] ··· 136 } 137 }); 138 let create_res = client 139 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 140 .bearer_auth(&jwt) 141 .json(&create_payload) 142 .send() ··· 150 "rkey": "to-delete" 151 }); 152 let delete_res = client 153 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 154 .bearer_auth(&jwt) 155 .json(&delete_payload) 156 .send() ··· 160 assert_eq!(delete_res.status(), StatusCode::OK); 161 let body: Value = delete_res.json().await.unwrap(); 162 163 - assert!(body["commit"].is_object(), "response must have commit object when record was deleted"); 164 let commit = &body["commit"]; 165 assert!(commit["cid"].is_string(), "commit must have cid"); 166 assert!(commit["rev"].is_string(), "commit must have rev"); ··· 177 "rkey": "nonexistent-record" 178 }); 179 let delete_res = client 180 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 181 .bearer_auth(&jwt) 182 .json(&delete_payload) 183 .send() ··· 187 assert_eq!(delete_res.status(), StatusCode::OK); 188 let body: Value = delete_res.json().await.unwrap(); 189 190 - assert!(body["commit"].is_null(), "commit should be omitted on no-op delete"); 191 } 192 193 #[tokio::test] ··· 223 }); 224 225 let res = client 226 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 227 .bearer_auth(&jwt) 228 .json(&payload) 229 .send() ··· 233 assert_eq!(res.status(), StatusCode::OK); 234 let body: Value = res.json().await.unwrap(); 235 236 - assert!(body["commit"].is_object(), "response must have commit object"); 237 let commit = &body["commit"]; 238 assert!(commit["cid"].is_string(), "commit must have cid"); 239 assert!(commit["rev"].is_string(), "commit must have rev"); 240 241 - assert!(body["results"].is_array(), "response must have results array"); 242 let results = body["results"].as_array().unwrap(); 243 assert_eq!(results.len(), 2, "should have 2 results"); 244 245 for result in results { 246 assert!(result["uri"].is_string(), "result must have uri"); 247 assert!(result["cid"].is_string(), "result must have cid"); 248 - assert_eq!(result["validationStatus"], "valid", "result must have validationStatus"); 249 assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult"); 250 } 251 } ··· 267 } 268 }); 269 client 270 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 271 .bearer_auth(&jwt) 272 .json(&create_payload) 273 .send() ··· 296 }); 297 298 let res = client 299 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 300 .bearer_auth(&jwt) 301 .json(&payload) 302 .send() ··· 310 assert_eq!(results.len(), 2); 311 312 let update_result = &results[0]; 313 - assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult"); 314 assert!(update_result["uri"].is_string()); 315 assert!(update_result["cid"].is_string()); 316 assert_eq!(update_result["validationStatus"], "valid"); 317 318 let delete_result = &results[1]; 319 - assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult"); 320 - assert!(delete_result["uri"].is_null(), "delete result should not have uri"); 321 - assert!(delete_result["cid"].is_null(), "delete result should not have cid"); 322 - assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus"); 323 } 324 325 #[tokio::test] ··· 328 let (did, _jwt) = setup_new_user("conform-get-err").await; 329 330 let res = client 331 - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) 332 .query(&[ 333 ("repo", did.as_str()), 334 ("collection", "app.bsky.feed.post"), ··· 340 341 assert_eq!(res.status(), StatusCode::NOT_FOUND); 342 let body: Value = res.json().await.unwrap(); 343 - assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec"); 344 } 345 346 #[tokio::test] ··· 358 }); 359 360 let res = client 361 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 362 .bearer_auth(&jwt) 363 .json(&payload) 364 .send() 365 .await 366 .expect("Failed to create record"); 367 368 - assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation"); 369 let body: Value = res.json().await.unwrap(); 370 371 assert!(body["uri"].is_string()); 372 assert!(body["cid"].is_string()); 373 assert!(body["commit"].is_object()); 374 - assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons"); 375 } 376 377 #[tokio::test] ··· 390 }); 391 392 let res = client 393 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 394 .bearer_auth(&jwt) 395 .json(&payload) 396 .send() 397 .await 398 .expect("Failed to send request"); 399 400 - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true"); 401 let body: Value = res.json().await.unwrap(); 402 assert_eq!(body["error"], "InvalidRecord"); 403 - assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found"); 404 } 405 406 #[tokio::test] ··· 423 }); 424 425 let first_res = client 426 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 427 .bearer_auth(&jwt) 428 .json(&payload) 429 .send() ··· 431 .expect("Failed to put record"); 432 assert_eq!(first_res.status(), StatusCode::OK); 433 let first_body: Value = first_res.json().await.unwrap(); 434 - assert!(first_body["commit"].is_object(), "first put should have commit"); 435 436 let second_res = client 437 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 438 .bearer_auth(&jwt) 439 .json(&payload) 440 .send() ··· 443 assert_eq!(second_res.status(), StatusCode::OK); 444 let second_body: Value = second_res.json().await.unwrap(); 445 446 - assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)"); 447 - assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content"); 448 } 449 450 #[tokio::test] ··· 468 }); 469 470 let res = client 471 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 472 .bearer_auth(&jwt) 473 .json(&payload) 474 .send() ··· 480 481 let results = body["results"].as_array().unwrap(); 482 assert_eq!(results.len(), 1); 483 - assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status"); 484 }
··· 23 }); 24 25 let res = client 26 + .post(format!( 27 + "{}/xrpc/com.atproto.repo.createRecord", 28 + base_url().await 29 + )) 30 .bearer_auth(&jwt) 31 .json(&payload) 32 .send() ··· 38 39 assert!(body["uri"].is_string(), "response must have uri"); 40 assert!(body["cid"].is_string(), "response must have cid"); 41 + assert!( 42 + body["cid"].as_str().unwrap().starts_with("bafy"), 43 + "cid must be valid" 44 + ); 45 46 + assert!( 47 + body["commit"].is_object(), 48 + "response must have commit object" 49 + ); 50 let commit = &body["commit"]; 51 assert!(commit["cid"].is_string(), "commit must have cid"); 52 + assert!( 53 + commit["cid"].as_str().unwrap().starts_with("bafy"), 54 + "commit.cid must be valid" 55 + ); 56 assert!(commit["rev"].is_string(), "commit must have rev"); 57 58 + assert!( 59 + body["validationStatus"].is_string(), 60 + "response must have validationStatus when validate defaults to true" 61 + ); 62 + assert_eq!( 63 + body["validationStatus"], "valid", 64 + "validationStatus should be 'valid'" 65 + ); 66 } 67 68 #[tokio::test] ··· 83 }); 84 85 let res = client 86 + .post(format!( 87 + "{}/xrpc/com.atproto.repo.createRecord", 88 + base_url().await 89 + )) 90 .bearer_auth(&jwt) 91 .json(&payload) 92 .send() ··· 98 99 assert!(body["uri"].is_string()); 100 assert!(body["commit"].is_object()); 101 + assert!( 102 + body["validationStatus"].is_null(), 103 + "validationStatus should be omitted when validate=false" 104 + ); 105 } 106 107 #[tokio::test] ··· 122 }); 123 124 let res = client 125 + .post(format!( 126 + "{}/xrpc/com.atproto.repo.putRecord", 127 + base_url().await 128 + )) 129 .bearer_auth(&jwt) 130 .json(&payload) 131 .send() ··· 138 assert!(body["uri"].is_string(), "response must have uri"); 139 assert!(body["cid"].is_string(), "response must have cid"); 140 141 + assert!( 142 + body["commit"].is_object(), 143 + "response must have commit object" 144 + ); 145 let commit = &body["commit"]; 146 assert!(commit["cid"].is_string(), "commit must have cid"); 147 assert!(commit["rev"].is_string(), "commit must have rev"); 148 149 + assert_eq!( 150 + body["validationStatus"], "valid", 151 + "validationStatus should be 'valid'" 152 + ); 153 } 154 155 #[tokio::test] ··· 169 } 170 }); 171 let create_res = client 172 + .post(format!( 173 + "{}/xrpc/com.atproto.repo.putRecord", 174 + base_url().await 175 + )) 176 .bearer_auth(&jwt) 177 .json(&create_payload) 178 .send() ··· 186 "rkey": "to-delete" 187 }); 188 let delete_res = client 189 + .post(format!( 190 + "{}/xrpc/com.atproto.repo.deleteRecord", 191 + base_url().await 192 + )) 193 .bearer_auth(&jwt) 194 .json(&delete_payload) 195 .send() ··· 199 assert_eq!(delete_res.status(), StatusCode::OK); 200 let body: Value = delete_res.json().await.unwrap(); 201 202 + assert!( 203 + body["commit"].is_object(), 204 + "response must have commit object when record was deleted" 205 + ); 206 let commit = &body["commit"]; 207 assert!(commit["cid"].is_string(), "commit must have cid"); 208 assert!(commit["rev"].is_string(), "commit must have rev"); ··· 219 "rkey": "nonexistent-record" 220 }); 221 let delete_res = client 222 + .post(format!( 223 + "{}/xrpc/com.atproto.repo.deleteRecord", 224 + base_url().await 225 + )) 226 .bearer_auth(&jwt) 227 .json(&delete_payload) 228 .send() ··· 232 assert_eq!(delete_res.status(), StatusCode::OK); 233 let body: Value = delete_res.json().await.unwrap(); 234 235 + assert!( 236 + body["commit"].is_null(), 237 + "commit should be omitted on no-op delete" 238 + ); 239 } 240 241 #[tokio::test] ··· 271 }); 272 273 let res = client 274 + .post(format!( 275 + "{}/xrpc/com.atproto.repo.applyWrites", 276 + base_url().await 277 + )) 278 .bearer_auth(&jwt) 279 .json(&payload) 280 .send() ··· 284 assert_eq!(res.status(), StatusCode::OK); 285 let body: Value = res.json().await.unwrap(); 286 287 + assert!( 288 + body["commit"].is_object(), 289 + "response must have commit object" 290 + ); 291 let commit = &body["commit"]; 292 assert!(commit["cid"].is_string(), "commit must have cid"); 293 assert!(commit["rev"].is_string(), "commit must have rev"); 294 295 + assert!( 296 + body["results"].is_array(), 297 + "response must have results array" 298 + ); 299 let results = body["results"].as_array().unwrap(); 300 assert_eq!(results.len(), 2, "should have 2 results"); 301 302 for result in results { 303 assert!(result["uri"].is_string(), "result must have uri"); 304 assert!(result["cid"].is_string(), "result must have cid"); 305 + assert_eq!( 306 + result["validationStatus"], "valid", 307 + "result must have validationStatus" 308 + ); 309 assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult"); 310 } 311 } ··· 327 } 328 }); 329 client 330 + .post(format!( 331 + "{}/xrpc/com.atproto.repo.putRecord", 332 + base_url().await 333 + )) 334 .bearer_auth(&jwt) 335 .json(&create_payload) 336 .send() ··· 359 }); 360 361 let res = client 362 + .post(format!( 363 + "{}/xrpc/com.atproto.repo.applyWrites", 364 + base_url().await 365 + )) 366 .bearer_auth(&jwt) 367 .json(&payload) 368 .send() ··· 376 assert_eq!(results.len(), 2); 377 378 let update_result = &results[0]; 379 + assert_eq!( 380 + update_result["$type"], 381 + "com.atproto.repo.applyWrites#updateResult" 382 + ); 383 assert!(update_result["uri"].is_string()); 384 assert!(update_result["cid"].is_string()); 385 assert_eq!(update_result["validationStatus"], "valid"); 386 387 let delete_result = &results[1]; 388 + assert_eq!( 389 + delete_result["$type"], 390 + "com.atproto.repo.applyWrites#deleteResult" 391 + ); 392 + assert!( 393 + delete_result["uri"].is_null(), 394 + "delete result should not have uri" 395 + ); 396 + assert!( 397 + delete_result["cid"].is_null(), 398 + "delete result should not have cid" 399 + ); 400 + assert!( 401 + delete_result["validationStatus"].is_null(), 402 + "delete result should not have validationStatus" 403 + ); 404 } 405 406 #[tokio::test] ··· 409 let (did, _jwt) = setup_new_user("conform-get-err").await; 410 411 let res = client 412 + .get(format!( 413 + "{}/xrpc/com.atproto.repo.getRecord", 414 + base_url().await 415 + )) 416 .query(&[ 417 ("repo", did.as_str()), 418 ("collection", "app.bsky.feed.post"), ··· 424 425 assert_eq!(res.status(), StatusCode::NOT_FOUND); 426 let body: Value = res.json().await.unwrap(); 427 + assert_eq!( 428 + body["error"], "RecordNotFound", 429 + "error code should be RecordNotFound per atproto spec" 430 + ); 431 } 432 433 #[tokio::test] ··· 445 }); 446 447 let res = client 448 + .post(format!( 449 + "{}/xrpc/com.atproto.repo.createRecord", 450 + base_url().await 451 + )) 452 .bearer_auth(&jwt) 453 .json(&payload) 454 .send() 455 .await 456 .expect("Failed to create record"); 457 458 + assert_eq!( 459 + res.status(), 460 + StatusCode::OK, 461 + "unknown lexicon should be allowed with default validation" 462 + ); 463 let body: Value = res.json().await.unwrap(); 464 465 assert!(body["uri"].is_string()); 466 assert!(body["cid"].is_string()); 467 assert!(body["commit"].is_object()); 468 + assert_eq!( 469 + body["validationStatus"], "unknown", 470 + "validationStatus should be 'unknown' for unknown lexicons" 471 + ); 472 } 473 474 #[tokio::test] ··· 487 }); 488 489 let res = client 490 + .post(format!( 491 + "{}/xrpc/com.atproto.repo.createRecord", 492 + base_url().await 493 + )) 494 .bearer_auth(&jwt) 495 .json(&payload) 496 .send() 497 .await 498 .expect("Failed to send request"); 499 500 + assert_eq!( 501 + res.status(), 502 + StatusCode::BAD_REQUEST, 503 + "unknown lexicon should fail with validate=true" 504 + ); 505 let body: Value = res.json().await.unwrap(); 506 assert_eq!(body["error"], "InvalidRecord"); 507 + assert!( 508 + body["message"] 509 + .as_str() 510 + .unwrap() 511 + .contains("Lexicon not found"), 512 + "error should mention lexicon not found" 513 + ); 514 } 515 516 #[tokio::test] ··· 533 }); 534 535 let first_res = client 536 + .post(format!( 537 + "{}/xrpc/com.atproto.repo.putRecord", 538 + base_url().await 539 + )) 540 .bearer_auth(&jwt) 541 .json(&payload) 542 .send() ··· 544 .expect("Failed to put record"); 545 assert_eq!(first_res.status(), StatusCode::OK); 546 let first_body: Value = first_res.json().await.unwrap(); 547 + assert!( 548 + first_body["commit"].is_object(), 549 + "first put should have commit" 550 + ); 551 552 let second_res = client 553 + .post(format!( 554 + "{}/xrpc/com.atproto.repo.putRecord", 555 + base_url().await 556 + )) 557 .bearer_auth(&jwt) 558 .json(&payload) 559 .send() ··· 562 assert_eq!(second_res.status(), StatusCode::OK); 563 let second_body: Value = second_res.json().await.unwrap(); 564 565 + assert!( 566 + second_body["commit"].is_null(), 567 + "second put with same content should have no commit (no-op)" 568 + ); 569 + assert_eq!( 570 + first_body["cid"], second_body["cid"], 571 + "CID should be the same for identical content" 572 + ); 573 } 574 575 #[tokio::test] ··· 593 }); 594 595 let res = client 596 + .post(format!( 597 + "{}/xrpc/com.atproto.repo.applyWrites", 598 + base_url().await 599 + )) 600 .bearer_auth(&jwt) 601 .json(&payload) 602 .send() ··· 608 609 let results = body["results"].as_array().unwrap(); 610 assert_eq!(results.len(), 1); 611 + assert_eq!( 612 + results[0]["validationStatus"], "unknown", 613 + "unknown lexicon should have 'unknown' status" 614 + ); 615 }
+20 -5
tests/sync_deprecated.rs
··· 202 .unwrap(); 203 assert_eq!(res.status(), StatusCode::OK); 204 client 205 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 206 .bearer_auth(&jwt) 207 .json(&serde_json::json!({})) 208 .send() ··· 233 .unwrap(); 234 assert_eq!(res.status(), StatusCode::OK); 235 client 236 - .post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base)) 237 .bearer_auth(&admin_jwt) 238 .json(&serde_json::json!({ 239 "subject": { ··· 266 let (admin_jwt, _) = create_admin_account_and_login(&client).await; 267 let (user_jwt, did) = create_account_and_login(&client).await; 268 client 269 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 270 .bearer_auth(&user_jwt) 271 .json(&serde_json::json!({})) 272 .send() ··· 295 .unwrap(); 296 assert_eq!(res.status(), StatusCode::OK); 297 client 298 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 299 .bearer_auth(&jwt) 300 .json(&serde_json::json!({})) 301 .send() ··· 326 .unwrap(); 327 assert_eq!(res.status(), StatusCode::OK); 328 client 329 - .post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base)) 330 .bearer_auth(&admin_jwt) 331 .json(&serde_json::json!({ 332 "subject": {
··· 202 .unwrap(); 203 assert_eq!(res.status(), StatusCode::OK); 204 client 205 + .post(format!( 206 + "{}/xrpc/com.atproto.server.deactivateAccount", 207 + base 208 + )) 209 .bearer_auth(&jwt) 210 .json(&serde_json::json!({})) 211 .send() ··· 236 .unwrap(); 237 assert_eq!(res.status(), StatusCode::OK); 238 client 239 + .post(format!( 240 + "{}/xrpc/com.atproto.admin.updateSubjectStatus", 241 + base 242 + )) 243 .bearer_auth(&admin_jwt) 244 .json(&serde_json::json!({ 245 "subject": { ··· 272 let (admin_jwt, _) = create_admin_account_and_login(&client).await; 273 let (user_jwt, did) = create_account_and_login(&client).await; 274 client 275 + .post(format!( 276 + "{}/xrpc/com.atproto.server.deactivateAccount", 277 + base 278 + )) 279 .bearer_auth(&user_jwt) 280 .json(&serde_json::json!({})) 281 .send() ··· 304 .unwrap(); 305 assert_eq!(res.status(), StatusCode::OK); 306 client 307 + .post(format!( 308 + "{}/xrpc/com.atproto.server.deactivateAccount", 309 + base 310 + )) 311 .bearer_auth(&jwt) 312 .json(&serde_json::json!({})) 313 .send() ··· 338 .unwrap(); 339 assert_eq!(res.status(), StatusCode::OK); 340 client 341 + .post(format!( 342 + "{}/xrpc/com.atproto.admin.updateSubjectStatus", 343 + base 344 + )) 345 .bearer_auth(&admin_jwt) 346 .json(&serde_json::json!({ 347 "subject": {