this repo has no description

OAuth inbound migration

lewis 8984efae 0bdef257

+9 -1
frontend/deno.json
··· 9 9 "test:ui": "deno run -A npm:vitest --ui", 10 10 "test:coverage": "deno run -A npm:vitest run --coverage" 11 11 }, 12 - "nodeModulesDir": "auto" 12 + "nodeModulesDir": "auto", 13 + "lint": { 14 + "rules": { 15 + "exclude": [ 16 + "require-await", 17 + "prefer-const" 18 + ] 19 + } 20 + } 13 21 }
+8
frontend/src/App.svelte
··· 36 36 import DidDocumentEditor from './routes/DidDocumentEditor.svelte' 37 37 import Home from './routes/Home.svelte' 38 38 39 + if (window.location.pathname === '/migrate') { 40 + const newUrl = `${window.location.origin}/${window.location.search}#/migrate` 41 + window.location.replace(newUrl) 42 + } 43 + 39 44 initI18n() 40 45 41 46 const auth = getAuthState() ··· 43 48 let oauthCallbackPending = $state(hasOAuthCallback()) 44 49 45 50 function hasOAuthCallback(): boolean { 51 + if (window.location.hash === '#/migrate') { 52 + return false 53 + } 46 54 const params = new URLSearchParams(window.location.search) 47 55 return !!(params.get('code') && params.get('state')) 48 56 }
+12 -12
frontend/src/components/ReauthModal.svelte
··· 170 170 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 171 171 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 172 172 <div class="modal-header"> 173 - <h2>Re-authentication Required</h2> 173 + <h2>{$_('reauth.title')}</h2> 174 174 <button class="close-btn" onclick={handleClose} aria-label="Close">&times;</button> 175 175 </div> 176 176 177 177 <p class="modal-description"> 178 - This action requires you to verify your identity. 178 + {$_('reauth.subtitle')} 179 179 </p> 180 180 181 181 {#if error} ··· 190 190 class:active={activeMethod === 'password'} 191 191 onclick={() => activeMethod = 'password'} 192 192 > 193 - Password 193 + {$_('reauth.password')} 194 194 </button> 195 195 {/if} 196 196 {#if availableMethods.includes('totp')} ··· 199 199 class:active={activeMethod === 'totp'} 200 200 onclick={() => activeMethod = 'totp'} 201 201 > 202 - TOTP 202 + {$_('reauth.totp')} 203 203 </button> 204 204 {/if} 205 205 {#if availableMethods.includes('passkey')} ··· 208 208 class:active={activeMethod === 'passkey'} 209 209 onclick={() => activeMethod = 'passkey'} 210 210 > 211 - Passkey 211 + {$_('reauth.passkey')} 212 212 </button> 213 213 {/if} 214 214 </div> ··· 218 218 {#if activeMethod === 'password'} 219 219 <form onsubmit={handlePasswordSubmit}> 220 220 <div class="form-group"> 221 - <label for="reauth-password">Password</label> 221 + <label for="reauth-password">{$_('reauth.password')}</label> 222 222 <input 223 223 id="reauth-password" 224 224 type="password" ··· 228 228 /> 229 229 </div> 230 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 - {loading ? 'Verifying...' : 'Verify'} 231 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 232 232 </button> 233 233 </form> 234 234 {:else if activeMethod === 'totp'} 235 235 <form onsubmit={handleTotpSubmit}> 236 236 <div class="form-group"> 237 - <label for="reauth-totp">Authenticator Code</label> 237 + <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 238 238 <input 239 239 id="reauth-totp" 240 240 type="text" ··· 247 247 /> 248 248 </div> 249 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 - {loading ? 'Verifying...' : 'Verify'} 250 + {loading ? $_('reauth.verifying') : $_('reauth.verify')} 251 251 </button> 252 252 </form> 253 253 {:else if activeMethod === 'passkey'} 254 254 <div class="passkey-auth"> 255 - <p>Click the button below to authenticate with your passkey.</p> 255 + <p>{$_('reauth.passkeyPrompt')}</p> 256 256 <button 257 257 class="btn-primary" 258 258 onclick={handlePasskeyAuth} 259 259 disabled={loading} 260 260 > 261 - {loading ? 'Authenticating...' : 'Use Passkey'} 261 + {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 262 262 </button> 263 263 </div> 264 264 {/if} ··· 266 266 267 267 <div class="modal-footer"> 268 268 <button class="btn-secondary" onclick={handleClose} disabled={loading}> 269 - Cancel 269 + {$_('reauth.cancel')} 270 270 </button> 271 271 </div> 272 272 </div>
+394 -569
frontend/src/components/migration/InboundWizard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { InboundMigrationFlow } from '../../lib/migration' 3 - import type { ServerDescription } from '../../lib/migration/types' 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' 4 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 + } 5 20 6 21 interface Props { 7 22 flow: InboundMigrationFlow 23 + resumeInfo?: ResumeInfo | null 8 24 onBack: () => void 9 25 onComplete: () => void 10 26 } 11 27 12 - let { flow, onBack, onComplete }: Props = $props() 28 + let { flow, resumeInfo = null, onBack, onComplete }: Props = $props() 13 29 14 30 let serverInfo = $state<ServerDescription | null>(null) 15 31 let loading = $state(false) 16 32 let handleInput = $state('') 17 - let passwordInput = $state('') 18 33 let localPasswordInput = $state('') 19 34 let understood = $state(false) 20 35 let selectedDomain = $state('') 21 36 let handleAvailable = $state<boolean | null>(null) 22 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) 23 42 24 - const isResumedMigration = $derived(flow.state.progress.repoImported) 43 + const isResuming = $derived(flow.state.needsReauth === true) 25 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) 26 45 27 46 $effect(() => { 28 47 if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 29 48 loadServerInfo() 30 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 + } 31 58 }) 32 59 33 60 ··· 61 88 } 62 89 } 63 90 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 91 async function checkHandle() { 105 92 if (!handleInput.trim()) return 106 93 ··· 134 121 try { 135 122 await flow.startMigration() 136 123 } catch (err) { 137 - flow.setError((err as Error).message) 124 + flow.setError(getErrorMessage(err)) 138 125 } finally { 139 126 loading = false 140 127 } ··· 146 133 try { 147 134 await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 148 135 } catch (err) { 149 - flow.setError((err as Error).message) 136 + flow.setError(getErrorMessage(err)) 150 137 } finally { 151 138 loading = false 152 139 } ··· 158 145 await flow.resendEmailVerification() 159 146 flow.setError(null) 160 147 } catch (err) { 161 - flow.setError((err as Error).message) 148 + flow.setError(getErrorMessage(err)) 162 149 } finally { 163 150 loading = false 164 151 } ··· 170 157 try { 171 158 await flow.submitPlcToken(flow.state.plcToken) 172 159 } catch (err) { 173 - flow.setError((err as Error).message) 160 + flow.setError(getErrorMessage(err)) 174 161 } finally { 175 162 loading = false 176 163 } ··· 182 169 await flow.resendPlcToken() 183 170 flow.setError(null) 184 171 } catch (err) { 185 - flow.setError((err as Error).message) 172 + flow.setError(getErrorMessage(err)) 186 173 } finally { 187 174 loading = false 188 175 } ··· 193 180 try { 194 181 await flow.completeDidWebMigration() 195 182 } catch (err) { 196 - flow.setError((err as Error).message) 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 + } 197 232 } finally { 198 233 loading = false 199 234 } 200 235 } 201 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 + 202 279 const steps = $derived(isDidWeb 203 - ? ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete'] 204 - : ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']) 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 + 205 285 function getCurrentStepIndex(): number { 286 + const isPasskey = flow.state.authMethod === 'passkey' 206 287 switch (flow.state.step) { 207 288 case 'welcome': 208 - case 'source-login': return 0 289 + case 'source-handle': return 0 209 290 case 'choose-handle': return 1 210 291 case 'review': return 2 211 292 case 'migrating': return 3 212 293 case 'email-verify': return 4 294 + case 'passkey-setup': return isPasskey ? 5 : 4 295 + case 'app-password': return 6 213 296 case 'plc-token': 214 297 case 'did-web-update': 215 - case 'finalizing': return 5 216 - case 'success': return 6 298 + case 'finalizing': return isPasskey ? 7 : 5 299 + case 'success': return isPasskey ? 8 : 6 217 300 default: return 0 218 301 } 219 302 } 220 303 </script> 221 304 222 - <div class="inbound-wizard"> 305 + <div class="migration-wizard"> 223 306 <div class="step-indicator"> 224 - {#each steps as stepName, i} 307 + {#each steps as _, i} 225 308 <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 226 309 <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 227 - <span class="step-label">{stepName}</span> 228 310 </div> 229 311 {#if i < steps.length - 1} 230 312 <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 231 313 {/if} 232 314 {/each} 233 315 </div> 316 + <div class="current-step-label"> 317 + <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 318 + </div> 234 319 235 320 {#if flow.state.error} 236 321 <div class="message error">{flow.state.error}</div> ··· 238 323 239 324 {#if flow.state.step === 'welcome'} 240 325 <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> 326 + <h2>{$_('migration.inbound.welcome.title')}</h2> 327 + <p>{$_('migration.inbound.welcome.desc')}</p> 243 328 244 329 <div class="info-box"> 245 - <h3>What will happen:</h3> 330 + <h3>{$_('migration.inbound.common.whatWillHappen')}</h3> 246 331 <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> 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> 252 337 </ol> 253 338 </div> 254 339 255 340 <div class="warning-box"> 256 - <strong>Before you proceed:</strong> 341 + <strong>{$_('migration.inbound.common.beforeProceed')}</strong> 257 342 <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> 343 + <li>{$_('migration.inbound.common.warning1')}</li> 344 + <li>{$_('migration.inbound.common.warning2')}</li> 345 + <li>{$_('migration.inbound.common.warning3')}</li> 261 346 </ul> 262 347 </div> 263 348 264 349 <label class="checkbox-label"> 265 350 <input type="checkbox" bind:checked={understood} /> 266 - <span>I understand the risks and want to proceed with migration</span> 351 + <span>{$_('migration.inbound.welcome.understand')}</span> 267 352 </label> 268 353 269 354 <div class="button-row"> 270 - <button class="ghost" onclick={onBack}>Cancel</button> 271 - <button disabled={!understood} onclick={() => flow.setStep('source-login')}> 272 - Continue 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')} 273 358 </button> 274 359 </div> 275 360 </div> 276 361 277 - {:else if flow.state.step === 'source-login'} 362 + {:else if flow.state.step === 'source-handle'} 278 363 <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> 364 + <h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2> 365 + <p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p> 281 366 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> 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> 286 385 </div> 287 386 {/if} 288 387 289 - <form onsubmit={handleLogin}> 388 + <form onsubmit={handleSourceHandleSubmit}> 290 389 <div class="field"> 291 - <label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label> 390 + <label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label> 292 391 <input 293 - id="handle" 392 + id="source-handle" 294 393 type="text" 295 - placeholder="alice.bsky.social" 394 + placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')} 296 395 bind:value={handleInput} 297 - disabled={loading} 396 + disabled={loading || isResuming} 298 397 required 299 398 /> 300 - <p class="hint">Your current handle on your existing PDS</p> 399 + <p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p> 301 400 </div> 302 401 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 402 <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')} 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'))} 351 406 </button> 352 407 </div> 353 408 </form> ··· 355 410 356 411 {:else if flow.state.step === 'choose-handle'} 357 412 <div class="step-content"> 358 - <h2>Choose Your New Handle</h2> 359 - <p>Select a handle for your account on this PDS.</p> 413 + <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 + <p>{$_('migration.inbound.chooseHandle.desc')}</p> 360 415 361 416 <div class="current-info"> 362 - <span class="label">Migrating from:</span> 417 + <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 363 418 <span class="value">{flow.state.sourceHandle}</span> 364 419 </div> 365 420 366 421 <div class="field"> 367 - <label for="new-handle">New Handle</label> 422 + <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 368 423 <div class="handle-input-group"> 369 424 <input 370 425 id="new-handle" ··· 383 438 </div> 384 439 385 440 {#if checkingHandle} 386 - <p class="hint">Checking availability...</p> 441 + <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 387 442 {:else if handleAvailable === true} 388 - <p class="hint success">Handle is available!</p> 443 + <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 389 444 {:else if handleAvailable === false} 390 - <p class="hint error">Handle is already taken</p> 445 + <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 391 446 {:else} 392 - <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 447 + <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 393 448 {/if} 394 449 </div> 395 450 396 451 <div class="field"> 397 - <label for="email">Email Address</label> 452 + <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 398 453 <input 399 454 id="email" 400 455 type="email" ··· 406 461 </div> 407 462 408 463 <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> 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> 420 491 </div> 421 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 + 422 513 {#if serverInfo?.inviteCodeRequired} 423 514 <div class="field"> 424 - <label for="invite">Invite Code</label> 515 + <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 425 516 <input 426 517 id="invite" 427 518 type="text" ··· 434 525 {/if} 435 526 436 527 <div class="button-row"> 437 - <button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button> 528 + <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 438 529 <button 439 - disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false} 440 - onclick={proceedToReview} 530 + disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 + onclick={proceedToReviewWithAuth} 441 532 > 442 - Continue 533 + {$_('migration.inbound.common.continue')} 443 534 </button> 444 535 </div> 445 536 </div> 446 537 447 538 {:else if flow.state.step === 'review'} 448 539 <div class="step-content"> 449 - <h2>Review Migration</h2> 450 - <p>Please confirm the details of your migration.</p> 540 + <h2>{$_('migration.inbound.review.title')}</h2> 541 + <p>{$_('migration.inbound.review.desc')}</p> 451 542 452 543 <div class="review-card"> 453 544 <div class="review-row"> 454 - <span class="label">Current Handle:</span> 545 + <span class="label">{$_('migration.inbound.review.currentHandle')}:</span> 455 546 <span class="value">{flow.state.sourceHandle}</span> 456 547 </div> 457 548 <div class="review-row"> 458 - <span class="label">New Handle:</span> 549 + <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 459 550 <span class="value">{flow.state.targetHandle}</span> 460 551 </div> 461 552 <div class="review-row"> 462 - <span class="label">DID:</span> 553 + <span class="label">{$_('migration.inbound.review.did')}:</span> 463 554 <span class="value mono">{flow.state.sourceDid}</span> 464 555 </div> 465 556 <div class="review-row"> 466 - <span class="label">From PDS:</span> 557 + <span class="label">{$_('migration.inbound.review.sourcePds')}:</span> 467 558 <span class="value">{flow.state.sourcePdsUrl}</span> 468 559 </div> 469 560 <div class="review-row"> 470 - <span class="label">To PDS:</span> 561 + <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 471 562 <span class="value">{window.location.origin}</span> 472 563 </div> 473 564 <div class="review-row"> 474 - <span class="label">Email:</span> 565 + <span class="label">{$_('migration.inbound.review.email')}:</span> 475 566 <span class="value">{flow.state.targetEmail}</span> 476 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> 477 572 </div> 478 573 479 574 <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. 575 + {$_('migration.inbound.review.warning')} 482 576 </div> 483 577 484 578 <div class="button-row"> 485 - <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button> 579 + <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 486 580 <button onclick={startMigration} disabled={loading}> 487 - {loading ? 'Starting...' : 'Start Migration'} 581 + {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 488 582 </button> 489 583 </div> 490 584 </div> 491 585 492 586 {:else if flow.state.step === 'migrating'} 493 587 <div class="step-content"> 494 - <h2>Migration in Progress</h2> 495 - <p>Please wait while your account is being transferred...</p> 588 + <h2>{$_('migration.inbound.migrating.title')}</h2> 589 + <p>{$_('migration.inbound.migrating.desc')}</p> 496 590 497 591 <div class="progress-section"> 498 592 <div class="progress-item" class:completed={flow.state.progress.repoExported}> 499 593 <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 500 - <span>Export repository</span> 594 + <span>{$_('migration.inbound.migrating.exportRepo')}</span> 501 595 </div> 502 596 <div class="progress-item" class:completed={flow.state.progress.repoImported}> 503 597 <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 504 - <span>Import repository</span> 598 + <span>{$_('migration.inbound.migrating.importRepo')}</span> 505 599 </div> 506 600 <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 507 601 <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> 602 + <span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 509 603 </div> 510 604 <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 511 605 <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 512 - <span>Migrate preferences</span> 606 + <span>{$_('migration.inbound.migrating.migratePrefs')}</span> 513 607 </div> 514 608 </div> 515 609 ··· 525 619 <p class="status-text">{flow.state.progress.currentOperation}</p> 526 620 </div> 527 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 + 528 684 {:else if flow.state.step === 'email-verify'} 529 685 <div class="step-content"> 530 686 <h2>{$_('migration.inbound.emailVerify.title')}</h2> ··· 537 693 </div> 538 694 539 695 {#if flow.state.error} 540 - <div class="error-box"> 696 + <div class="message error"> 541 697 {flow.state.error} 542 698 </div> 543 699 {/if} ··· 569 725 570 726 {:else if flow.state.step === 'plc-token'} 571 727 <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> 728 + <h2>{$_('migration.inbound.plcToken.title')}</h2> 729 + <p>{$_('migration.inbound.plcToken.desc')}</p> 574 730 575 731 <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> 732 + <p>{$_('migration.inbound.plcToken.info')}</p> 580 733 </div> 581 734 582 735 <form onsubmit={submitPlcToken}> 583 736 <div class="field"> 584 - <label for="plc-token">Verification Code</label> 737 + <label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label> 585 738 <input 586 739 id="plc-token" 587 740 type="text" 588 - placeholder="Enter code from email" 741 + placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')} 589 742 bind:value={flow.state.plcToken} 590 743 oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 591 744 disabled={loading} ··· 595 748 596 749 <div class="button-row"> 597 750 <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 598 - Resend Code 751 + {$_('migration.inbound.plcToken.resend')} 599 752 </button> 600 753 <button type="submit" disabled={loading || !flow.state.plcToken}> 601 - {loading ? 'Verifying...' : 'Complete Migration'} 754 + {loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')} 602 755 </button> 603 756 </div> 604 757 </form> ··· 653 806 </div> 654 807 655 808 <div class="button-row"> 656 - <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>Back</button> 809 + <button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 657 810 <button onclick={completeDidWeb} disabled={loading}> 658 811 {loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')} 659 812 </button> ··· 662 815 663 816 {:else if flow.state.step === 'finalizing'} 664 817 <div class="step-content"> 665 - <h2>Finalizing Migration</h2> 666 - <p>Please wait while we complete the migration...</p> 818 + <h2>{$_('migration.inbound.finalizing.title')}</h2> 819 + <p>{$_('migration.inbound.finalizing.desc')}</p> 667 820 668 821 <div class="progress-section"> 669 822 <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 670 823 <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 671 - <span>Sign identity update</span> 824 + <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 672 825 </div> 673 826 <div class="progress-item" class:completed={flow.state.progress.activated}> 674 827 <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 675 - <span>Activate new account</span> 828 + <span>{$_('migration.inbound.finalizing.activating')}</span> 676 829 </div> 677 830 <div class="progress-item" class:completed={flow.state.progress.deactivated}> 678 831 <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 679 - <span>Deactivate old account</span> 832 + <span>{$_('migration.inbound.finalizing.deactivating')}</span> 680 833 </div> 681 834 </div> 682 835 ··· 686 839 {:else if flow.state.step === 'success'} 687 840 <div class="step-content success-content"> 688 841 <div class="success-icon">✓</div> 689 - <h2>Migration Complete!</h2> 690 - <p>Your account has been successfully migrated to this PDS.</p> 842 + <h2>{$_('migration.inbound.success.title')}</h2> 843 + <p>{$_('migration.inbound.success.desc')}</p> 691 844 692 845 <div class="success-details"> 693 846 <div class="detail-row"> 694 - <span class="label">Your new handle:</span> 847 + <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 695 848 <span class="value">{flow.state.targetHandle}</span> 696 849 </div> 697 850 <div class="detail-row"> 698 - <span class="label">DID:</span> 851 + <span class="label">{$_('migration.inbound.success.did')}:</span> 699 852 <span class="value mono">{flow.state.sourceDid}</span> 700 853 </div> 701 854 </div> 702 855 703 856 {#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. 857 + <div class="message warning"> 858 + {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 707 859 </div> 708 860 {/if} 709 861 710 - <p class="redirect-text">Redirecting to dashboard...</p> 862 + <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 711 863 </div> 712 864 713 865 {:else if flow.state.step === 'error'} 714 866 <div class="step-content"> 715 - <h2>Migration Error</h2> 716 - <p>An error occurred during migration.</p> 867 + <h2>{$_('migration.inbound.error.title')}</h2> 868 + <p>{$_('migration.inbound.error.desc')}</p> 717 869 718 - <div class="error-box"> 719 - {flow.state.error} 870 + <div class="message error"> 871 + {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 720 872 </div> 721 873 722 874 <div class="button-row"> 723 - <button class="ghost" onclick={onBack}>Start Over</button> 875 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 724 876 </div> 725 877 </div> 726 878 {/if} 727 879 </div> 728 880 729 881 <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); 882 + .passkey-section { 883 + margin-top: 16px; 741 884 } 742 - 743 - .step { 744 - display: flex; 745 - flex-direction: column; 746 - align-items: center; 747 - gap: var(--space-2); 885 + .passkey-section button { 886 + width: 100%; 887 + margin-top: 12px; 748 888 } 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); 889 + .app-password-display { 890 + background: var(--bg-card); 891 + border: 2px solid var(--accent); 801 892 border-radius: var(--radius-xl); 802 893 padding: var(--space-6); 803 - } 804 - 805 - .step-content h2 { 806 - margin: 0 0 var(--space-3) 0; 894 + text-align: center; 895 + margin: var(--space-4) 0; 807 896 } 808 - 809 - .step-content > p { 897 + .app-password-label { 898 + font-size: var(--text-sm); 810 899 color: var(--text-secondary); 811 - margin: 0 0 var(--space-5) 0; 900 + margin-bottom: var(--space-4); 812 901 } 813 - 814 - .info-box { 815 - background: var(--accent-muted); 816 - border: 1px solid var(--accent); 817 - border-radius: var(--radius-lg); 902 + .app-password-code { 903 + display: block; 904 + font-size: var(--text-xl); 905 + font-family: ui-monospace, monospace; 906 + letter-spacing: 0.1em; 818 907 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); 908 + background: var(--bg-input); 909 + border-radius: var(--radius-md); 910 + margin-bottom: var(--space-4); 911 + user-select: all; 835 912 } 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); 913 + .copy-btn { 914 + padding: var(--space-3) var(--space-5); 848 915 font-size: var(--text-sm); 849 916 } 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); 917 + .resume-info { 865 918 margin-bottom: var(--space-5); 866 - color: var(--error-text); 867 919 } 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; 920 + .resume-info h3 { 921 + margin: 0 0 var(--space-3) 0; 922 + font-size: var(--text-base); 876 923 } 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 { 924 + .resume-details { 931 925 display: flex; 926 + flex-direction: column; 932 927 gap: var(--space-2); 933 928 } 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 { 929 + .resume-row { 968 930 display: flex; 969 931 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 932 font-size: var(--text-sm); 991 933 } 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 { 934 + .resume-row .label { 1070 935 color: var(--text-secondary); 1071 936 } 1072 - 1073 - .success-details .value { 937 + .resume-row .value { 1074 938 font-weight: var(--font-medium); 1075 939 } 1076 - 1077 - .success-details .value.mono { 1078 - font-family: var(--font-mono); 940 + .resume-note { 941 + margin-top: var(--space-3); 1079 942 font-size: var(--text-sm); 1080 - } 1081 - 1082 - .redirect-text { 1083 - color: var(--text-secondary); 1084 943 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 944 } 1120 945 </style>
+20 -466
frontend/src/components/migration/OutboundWizard.svelte
··· 2 2 import type { OutboundMigrationFlow } from '../../lib/migration' 3 3 import type { ServerDescription } from '../../lib/migration/types' 4 4 import { getAuthState, logout } from '../../lib/auth.svelte' 5 + import '../../styles/migration.css' 5 6 6 7 interface Props { 7 8 flow: OutboundMigrationFlow ··· 119 120 } 120 121 </script> 121 122 122 - <div class="outbound-wizard"> 123 + <div class="migration-wizard"> 123 124 {#if flow.state.step !== 'welcome'} 124 125 <div class="step-indicator"> 125 126 {#each steps as stepName, i} ··· 135 136 {/if} 136 137 137 138 {#if flow.state.error} 138 - <div class="message error">{flow.state.error}</div> 139 + <div class="migration-message error">{flow.state.error}</div> 139 140 {/if} 140 141 141 142 {#if flow.state.step === 'welcome'} ··· 149 150 </div> 150 151 151 152 {#if isDidWeb()} 152 - <div class="warning-box"> 153 + <div class="migration-warning-box"> 153 154 <strong>did:web Migration Notice</strong> 154 155 <p> 155 156 Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will ··· 161 162 </div> 162 163 {/if} 163 164 164 - <div class="info-box"> 165 + <div class="migration-info-box"> 165 166 <h3>What will happen:</h3> 166 167 <ol> 167 168 <li>Choose your new PDS</li> ··· 173 174 </ol> 174 175 </div> 175 176 176 - <div class="warning-box"> 177 + <div class="migration-warning-box"> 177 178 <strong>Before you proceed:</strong> 178 179 <ul> 179 180 <li>You need access to the email registered with this account</li> ··· 202 203 <p>Enter the URL of the PDS you want to migrate to.</p> 203 204 204 205 <form onsubmit={validatePds}> 205 - <div class="field"> 206 + <div class="migration-field"> 206 207 <label for="pds-url">PDS URL</label> 207 208 <input 208 209 id="pds-url" ··· 212 213 disabled={loading} 213 214 required 214 215 /> 215 - <p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 216 + <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 216 217 </div> 217 218 218 219 <div class="button-row"> ··· 264 265 <span class="value">{flow.state.targetPdsUrl}</span> 265 266 </div> 266 267 267 - <div class="field"> 268 + <div class="migration-field"> 268 269 <label for="new-handle">New Handle</label> 269 270 <div class="handle-input-group"> 270 271 <input ··· 281 282 </select> 282 283 {/if} 283 284 </div> 284 - <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 285 + <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 285 286 </div> 286 287 287 - <div class="field"> 288 + <div class="migration-field"> 288 289 <label for="email">Email Address</label> 289 290 <input 290 291 id="email" ··· 296 297 /> 297 298 </div> 298 299 299 - <div class="field"> 300 + <div class="migration-field"> 300 301 <label for="new-password">Password</label> 301 302 <input 302 303 id="new-password" ··· 307 308 required 308 309 minlength="8" 309 310 /> 310 - <p class="hint">At least 8 characters. This will be your password on the new PDS.</p> 311 + <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p> 311 312 </div> 312 313 313 314 {#if flow.state.targetServerInfo?.inviteCodeRequired} 314 - <div class="field"> 315 + <div class="migration-field"> 315 316 <label for="invite">Invite Code</label> 316 317 <input 317 318 id="invite" ··· 321 322 oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 322 323 required 323 324 /> 324 - <p class="hint">Required by this PDS to create an account</p> 325 + <p class="migration-hint">Required by this PDS to create an account</p> 325 326 </div> 326 327 {/if} 327 328 ··· 368 369 </div> 369 370 </div> 370 371 371 - <div class="warning-box final-warning"> 372 + <div class="migration-warning-box final-warning"> 372 373 <strong>This action cannot be easily undone!</strong> 373 374 <p> 374 375 After migration completes, your account on this PDS will be deactivated. ··· 430 431 <h2>Verify Migration</h2> 431 432 <p>A verification code has been sent to your email ({auth.session?.email}).</p> 432 433 433 - <div class="info-box"> 434 + <div class="migration-info-box"> 434 435 <p> 435 436 This code confirms you have access to the account and authorizes updating your identity 436 437 to point to the new PDS. ··· 438 439 </div> 439 440 440 441 <form onsubmit={submitPlcToken}> 441 - <div class="field"> 442 + <div class="migration-field"> 442 443 <label for="plc-token">Verification Code</label> 443 444 <input 444 445 id="plc-token" ··· 507 508 </div> 508 509 509 510 {#if flow.state.progress.blobsFailed.length > 0} 510 - <div class="warning-box"> 511 + <div class="migration-warning-box"> 511 512 <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 512 513 These may be images or other media that are no longer available. 513 514 </div> ··· 530 531 <h2>Migration Error</h2> 531 532 <p>An error occurred during migration.</p> 532 533 533 - <div class="error-box"> 534 + <div class="migration-error-box"> 534 535 {flow.state.error} 535 536 </div> 536 537 ··· 542 543 </div> 543 544 544 545 <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 546 </style>
+5 -1
frontend/src/lib/auth.svelte.ts
··· 444 444 state.savedAccounts = newState.savedAccounts ?? []; 445 445 } 446 446 447 - export function _testReset() { 447 + export function _testResetState() { 448 448 state.session = null; 449 449 state.loading = true; 450 450 state.error = null; 451 451 state.savedAccounts = []; 452 + } 453 + 454 + export function _testReset() { 455 + _testResetState(); 452 456 localStorage.removeItem(STORAGE_KEY); 453 457 localStorage.removeItem(ACCOUNTS_KEY); 454 458 }
+1 -1
frontend/src/lib/crypto.ts
··· 10 10 publicKeyDidKey: string; 11 11 } 12 12 13 - export async function generateKeypair(): Promise<Keypair> { 13 + export function generateKeypair(): Keypair { 14 14 const privateKey = secp.utils.randomPrivateKey(); 15 15 const publicKey = secp.getPublicKey(privateKey, true); 16 16
+488 -25
frontend/src/lib/migration/atproto-client.ts
··· 1 1 import type { 2 2 AccountStatus, 3 3 BlobRef, 4 + CompletePasskeySetupResponse, 4 5 CreateAccountParams, 6 + CreatePasskeyAccountParams, 5 7 DidCredentials, 6 8 DidDocument, 7 - MigrationError, 9 + OAuthServerMetadata, 10 + OAuthTokenResponse, 11 + PasskeyAccountSetup, 8 12 PlcOperation, 9 13 Preferences, 10 14 ServerDescription, 11 15 Session, 16 + StartPasskeyRegistrationResponse, 12 17 } from "./types"; 13 18 14 19 function apiLog( ··· 28 33 export class AtprotoClient { 29 34 private baseUrl: string; 30 35 private accessToken: string | null = null; 36 + private dpopKeyPair: DPoPKeyPair | null = null; 37 + private dpopNonce: string | null = null; 31 38 32 39 constructor(pdsUrl: string) { 33 40 this.baseUrl = pdsUrl.replace(/\/$/, ""); ··· 41 48 return this.accessToken; 42 49 } 43 50 51 + setDPoPKeyPair(keyPair: DPoPKeyPair | null) { 52 + this.dpopKeyPair = keyPair; 53 + } 54 + 44 55 private async xrpc<T>( 45 56 method: string, 46 57 options?: { ··· 67 78 url += `?${searchParams}`; 68 79 } 69 80 70 - const headers: Record<string, string> = {}; 71 - const token = authToken ?? this.accessToken; 72 - if (token) { 73 - headers["Authorization"] = `Bearer ${token}`; 74 - } 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 + } 75 111 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"; 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 + } 85 127 } 86 128 87 - const res = await fetch(url, { 88 - method: httpMethod, 89 - headers, 90 - body: requestBody, 91 - }); 92 - 93 129 if (!res.ok) { 94 130 const err = await res.json().catch(() => ({ 95 131 error: "Unknown", 96 132 message: res.statusText, 97 133 })); 98 - const error = new Error(err.message) as Error & { 134 + const error = new Error(err.message || err.error || res.statusText) as Error & { 99 135 status: number; 100 136 error: string; 101 137 }; 102 138 error.status = res.status; 103 139 error.error = err.error; 104 140 throw error; 141 + } 142 + 143 + const newNonce = res.headers.get("DPoP-Nonce"); 144 + if (newNonce) { 145 + this.dpopNonce = newNonce; 105 146 } 106 147 107 148 const responseContentType = res.headers.get("content-type") ?? ""; ··· 231 272 error: "Unknown", 232 273 message: res.statusText, 233 274 })); 234 - const error = new Error(err.message) as Error & { 275 + const error = new Error(err.message || err.error || res.statusText) as Error & { 235 276 status: number; 236 277 error: string; 237 278 }; ··· 436 477 httpMethod: "POST", 437 478 }); 438 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(); 439 744 } 440 745 441 746 export async function resolveDidDocument(did: string): Promise<DidDocument> { ··· 466 771 export async function resolvePdsUrl( 467 772 handleOrDid: string, 468 773 ): Promise<{ did: string; pdsUrl: string }> { 469 - let did: string; 774 + let did: string | undefined; 470 775 471 776 if (handleOrDid.startsWith("did:")) { 472 777 did = handleOrDid; ··· 515 820 } 516 821 } 517 822 823 + if (!did) { 824 + throw new Error("Could not resolve DID"); 825 + } 826 + 518 827 const didDoc = await resolveDidDocument(did); 519 828 520 829 const pdsService = didDoc.service?.find( ··· 529 838 } 530 839 531 840 export function createLocalClient(): AtprotoClient { 532 - return new AtprotoClient(window.location.origin); 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}`; 533 996 }
+350 -78
frontend/src/lib/migration/flow.svelte.ts
··· 2 2 InboundMigrationState, 3 3 InboundStep, 4 4 MigrationProgress, 5 + OAuthServerMetadata, 5 6 OutboundMigrationState, 6 7 OutboundStep, 8 + PasskeyAccountSetup, 7 9 ServerDescription, 8 10 StoredMigrationState, 9 11 } from "./types"; 10 12 import { 11 13 AtprotoClient, 14 + buildOAuthAuthorizationUrl, 15 + clearDPoPKey, 12 16 createLocalClient, 17 + exchangeOAuthCode, 18 + generateDPoPKeyPair, 19 + generateOAuthState, 20 + generatePKCE, 21 + getMigrationOAuthClientId, 22 + getMigrationOAuthRedirectUri, 23 + getOAuthServerMetadata, 24 + loadDPoPKey, 13 25 resolvePdsUrl, 26 + saveDPoPKey, 14 27 } from "./atproto-client"; 15 28 import { 16 29 clearMigrationState, 17 - loadMigrationState, 18 30 saveMigrationState, 19 31 updateProgress, 20 32 updateStep, ··· 63 75 plcToken: "", 64 76 progress: createInitialProgress(), 65 77 error: null, 66 - requires2FA: false, 67 - twoFactorCode: "", 68 78 targetVerificationMethod: null, 79 + authMethod: "password", 80 + passkeySetupToken: null, 81 + oauthCodeVerifier: null, 82 + generatedAppPassword: null, 83 + generatedAppPasswordName: null, 69 84 }); 70 85 71 86 let sourceClient: AtprotoClient | null = null; 72 87 let localClient: AtprotoClient | null = null; 73 88 let localServerInfo: ServerDescription | null = null; 89 + let sourceOAuthMetadata: OAuthServerMetadata | null = null; 74 90 75 91 function setStep(step: InboundStep) { 76 92 state.step = step; ··· 111 127 } 112 128 } 113 129 114 - async function loginToSource( 115 - handle: string, 116 - password: string, 117 - twoFactorCode?: string, 118 - ): Promise<void> { 119 - migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode }); 130 + async function initiateOAuthLogin(handle: string): Promise<void> { 131 + migrationLog("initiateOAuthLogin START", { handle }); 120 132 121 133 if (!state.sourcePdsUrl) { 122 134 await resolveSourcePds(handle); 123 135 } 124 136 125 - if (!sourceClient) { 126 - sourceClient = new AtprotoClient(state.sourcePdsUrl); 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"); 127 205 } 128 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; 129 236 try { 130 - migrationLog("loginToSource: Calling createSession on OLD PDS", { 131 - pdsUrl: state.sourcePdsUrl, 237 + tokenResponse = await exchangeOAuthCode(metadata, { 238 + code, 239 + codeVerifier, 240 + clientId: getMigrationOAuthClientId(), 241 + redirectUri: getMigrationOAuthRedirectUri(), 242 + dpopKeyPair, 132 243 }); 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 - ); 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); 156 281 } 157 - throw e; 282 + } else { 283 + setStep("choose-handle"); 158 284 } 285 + saveMigrationState(state); 159 286 } 160 287 161 288 async function checkHandleAvailability(handle: string): Promise<boolean> { ··· 180 307 await localClient.loginDeactivated(email, password); 181 308 } 182 309 310 + let passkeySetup: PasskeyAccountSetup | null = null; 311 + 183 312 async function startMigration(): Promise<void> { 184 313 migrationLog("startMigration START", { 185 314 sourceDid: state.sourceDid, 186 315 sourceHandle: state.sourceHandle, 187 316 targetHandle: state.targetHandle, 188 317 sourcePdsUrl: state.sourcePdsUrl, 318 + authMethod: state.authMethod, 189 319 }); 190 320 191 321 if (!sourceClient || !state.sourceAccessToken) { 192 - migrationLog("startMigration ERROR: Not logged in to source PDS"); 193 - throw new Error("Not logged in to source PDS"); 322 + migrationLog("startMigration ERROR: Not authenticated to source PDS"); 323 + throw new Error("Not authenticated to source PDS"); 194 324 } 195 325 196 326 if (!localClient) { ··· 198 328 } 199 329 200 330 setStep("migrating"); 201 - setProgress({ currentOperation: "Getting service auth token..." }); 202 331 203 332 try { 333 + setProgress({ currentOperation: "Getting service auth token..." }); 204 334 migrationLog("startMigration: Loading local server info"); 205 335 const serverInfo = await loadLocalServerInfo(); 206 336 migrationLog("startMigration: Got server info", { 207 337 serverDid: serverInfo.did, 208 338 }); 209 339 210 - migrationLog("startMigration: Getting service auth token from OLD PDS"); 340 + migrationLog("startMigration: Getting service auth token from source PDS"); 211 341 const { token } = await sourceClient.getServiceAuth( 212 342 serverInfo.did, 213 343 "com.atproto.server.createAccount", ··· 217 347 218 348 setProgress({ currentOperation: "Creating account on new PDS..." }); 219 349 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 - }; 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 + }; 227 357 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); 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 + } 237 392 238 393 setProgress({ currentOperation: "Exporting repository..." }); 239 - migrationLog("startMigration: Exporting repo from OLD PDS"); 394 + migrationLog("startMigration: Exporting repo from source PDS"); 240 395 const exportStart = Date.now(); 241 396 const car = await sourceClient.getRepo(state.sourceDid); 242 397 migrationLog("startMigration: Repo exported", { ··· 320 475 await localClient.uploadBlob(blobData, "application/octet-stream"); 321 476 migrated++; 322 477 setProgress({ blobsMigrated: migrated }); 323 - } catch (e) { 478 + } catch { 324 479 state.progress.blobsFailed.push(blob.cid); 325 480 } 326 481 } ··· 336 491 const prefs = await sourceClient.getPreferences(); 337 492 await localClient.putPreferences(prefs); 338 493 setProgress({ prefsMigrated: true }); 339 - } catch { 340 - } 494 + } catch { /* optional, best-effort */ } 341 495 } 342 496 343 497 async function submitEmailVerifyToken( ··· 355 509 await localClient.verifyToken(token, state.targetEmail); 356 510 357 511 if (!sourceClient) { 358 - setStep("source-login"); 512 + setStep("source-handle"); 359 513 setError( 360 514 "Email verified! Please log in to your old account again to complete the migration.", 361 515 ); 362 516 return; 363 517 } 364 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 + 365 527 if (localPassword) { 366 528 setProgress({ currentOperation: "Authenticating to new PDS..." }); 367 529 await localClient.loginDeactivated(state.targetEmail, localPassword); ··· 403 565 if (checkingEmailVerification) return false; 404 566 if (!sourceClient || !localClient) return false; 405 567 568 + if (state.authMethod === "passkey") { 569 + return false; 570 + } 571 + 406 572 checkingEmailVerification = true; 407 573 try { 408 574 await localClient.loginDeactivated( ··· 460 626 services: credentials.services, 461 627 }); 462 628 463 - migrationLog("Step 2: Signing PLC operation on OLD PDS", { 629 + migrationLog("Step 2: Signing PLC operation on source PDS", { 464 630 sourcePdsUrl: state.sourcePdsUrl, 465 631 }); 466 632 const signStart = Date.now(); ··· 497 663 setProgress({ activated: true }); 498 664 499 665 setProgress({ currentOperation: "Deactivating old account..." }); 500 - migrationLog("Step 5: Deactivating account on OLD PDS", { 666 + migrationLog("Step 5: Deactivating account on source PDS", { 501 667 sourcePdsUrl: state.sourcePdsUrl, 502 668 }); 503 669 const deactivateStart = Date.now(); 504 670 try { 505 671 await sourceClient.deactivateAccount(); 506 - migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", { 672 + migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", { 507 673 durationMs: Date.now() - deactivateStart, 508 674 success: true, 509 675 }); ··· 513 679 error?: string; 514 680 status?: number; 515 681 }; 516 - migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", { 682 + migrationLog("Step 5 FAILED: Could not deactivate on source PDS", { 517 683 durationMs: Date.now() - deactivateStart, 518 684 error: err.message, 519 685 errorCode: err.error, ··· 581 747 setProgress({ activated: true }); 582 748 583 749 setProgress({ currentOperation: "Deactivating old account..." }); 584 - migrationLog("Deactivating account on OLD PDS"); 750 + migrationLog("Deactivating account on source PDS"); 585 751 const deactivateStart = Date.now(); 586 752 try { 587 753 await sourceClient.deactivateAccount(); 588 - migrationLog("Account deactivated on OLD PDS", { 754 + migrationLog("Account deactivated on source PDS", { 589 755 durationMs: Date.now() - deactivateStart, 590 756 }); 591 757 setProgress({ deactivated: true }); 592 758 } catch (deactivateErr) { 593 759 const err = deactivateErr as Error & { error?: string }; 594 - migrationLog("Could not deactivate on OLD PDS", { error: err.message }); 760 + migrationLog("Could not deactivate on source PDS", { error: err.message }); 595 761 } 596 762 597 763 migrationLog("completeDidWebMigration SUCCESS"); ··· 607 773 } 608 774 } 609 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 + 610 838 function reset(): void { 611 839 state = { 612 840 direction: "inbound", ··· 625 853 plcToken: "", 626 854 progress: createInitialProgress(), 627 855 error: null, 628 - requires2FA: false, 629 - twoFactorCode: "", 630 856 targetVerificationMethod: null, 857 + authMethod: "password", 858 + passkeySetupToken: null, 859 + oauthCodeVerifier: null, 860 + generatedAppPassword: null, 861 + generatedAppPasswordName: null, 631 862 }; 632 863 sourceClient = null; 864 + passkeySetup = null; 865 + sourceOAuthMetadata = null; 633 866 clearMigrationState(); 867 + clearDPoPKey(); 634 868 } 635 869 636 870 async function resumeFromState(stored: StoredMigrationState): Promise<void> { ··· 641 875 state.sourceHandle = stored.sourceHandle; 642 876 state.targetHandle = stored.targetHandle; 643 877 state.targetEmail = stored.targetEmail; 878 + state.authMethod = stored.authMethod ?? "password"; 644 879 state.progress = { 645 880 ...createInitialProgress(), 646 881 ...stored.progress, 647 882 }; 648 883 649 - state.step = "source-login"; 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 + } 650 916 } 651 917 652 918 function getLocalSession(): ··· 666 932 get state() { 667 933 return state; 668 934 }, 935 + get passkeySetup() { 936 + return passkeySetup; 937 + }, 669 938 setStep, 670 939 setError, 671 940 loadLocalServerInfo, 672 - loginToSource, 941 + resolveSourcePds, 942 + initiateOAuthLogin, 943 + handleOAuthCallback, 673 944 authenticateToLocal, 674 945 checkHandleAvailability, 675 946 startMigration, ··· 680 951 submitPlcToken, 681 952 resendPlcToken, 682 953 completeDidWebMigration, 954 + startPasskeyRegistration, 955 + completePasskeyRegistration, 956 + proceedFromAppPassword, 683 957 reset, 684 958 resumeFromState, 685 959 getLocalSession, ··· 856 1130 await targetClient.uploadBlob(blobData, "application/octet-stream"); 857 1131 migrated++; 858 1132 setProgress({ blobsMigrated: migrated }); 859 - } catch (e) { 1133 + } catch { 860 1134 state.progress.blobsFailed.push(blob.cid); 861 1135 } 862 1136 } ··· 872 1146 const prefs = await localClient.getPreferences(); 873 1147 await targetClient.putPreferences(prefs); 874 1148 setProgress({ prefsMigrated: true }); 875 - } catch { 876 - } 1149 + } catch { /* optional, best-effort */ } 877 1150 } 878 1151 879 1152 async function submitPlcToken(token: string): Promise<void> { ··· 908 1181 try { 909 1182 await localClient.deactivateAccount(state.targetPdsUrl); 910 1183 setProgress({ deactivated: true }); 911 - } catch { 912 - } 1184 + } catch { /* optional, best-effort */ } 913 1185 914 1186 setStep("success"); 915 1187 clearMigrationState();
+28 -19
frontend/src/lib/migration/storage.ts
··· 3 3 MigrationState, 4 4 StoredMigrationState, 5 5 } from "./types"; 6 + import { clearDPoPKey } from "./atproto-client"; 6 7 7 8 const STORAGE_KEY = "tranquil_migration_state"; 8 9 const MAX_AGE_MS = 24 * 60 * 60 * 1000; ··· 15 16 startedAt: new Date().toISOString(), 16 17 sourcePdsUrl: state.direction === "inbound" 17 18 ? state.sourcePdsUrl 18 - : window.location.origin, 19 + : globalThis.location.origin, 19 20 targetPdsUrl: state.direction === "inbound" 20 - ? window.location.origin 21 + ? globalThis.location.origin 21 22 : state.targetPdsUrl, 22 23 sourceDid: state.direction === "inbound" ? state.sourceDid : "", 23 24 sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", 24 25 targetHandle: state.targetHandle, 25 26 targetEmail: state.targetEmail, 27 + authMethod: state.direction === "inbound" ? state.authMethod : undefined, 28 + passkeySetupToken: state.direction === "inbound" 29 + ? state.passkeySetupToken ?? undefined 30 + : undefined, 26 31 progress: { 27 32 repoExported: state.progress.repoExported, 28 33 repoImported: state.progress.repoImported, ··· 36 41 }; 37 42 38 43 try { 39 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); 40 - } catch { 41 - } 44 + localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); 45 + } catch { /* localStorage unavailable */ } 42 46 } 43 47 44 48 export function loadMigrationState(): StoredMigrationState | null { 45 49 try { 46 - const stored = sessionStorage.getItem(STORAGE_KEY); 50 + const stored = localStorage.getItem(STORAGE_KEY); 47 51 if (!stored) return null; 48 52 49 53 const state = JSON.parse(stored) as StoredMigrationState; 50 54 51 - if (state.version !== 1) return null; 55 + if (state.version !== 1) { 56 + clearMigrationState(); 57 + return null; 58 + } 52 59 53 60 const startedAt = new Date(state.startedAt).getTime(); 54 61 if (Date.now() - startedAt > MAX_AGE_MS) { ··· 58 65 59 66 return state; 60 67 } catch { 68 + clearMigrationState(); 61 69 return null; 62 70 } 63 71 } 64 72 65 73 export function clearMigrationState(): void { 66 74 try { 67 - sessionStorage.removeItem(STORAGE_KEY); 68 - } catch { 69 - } 75 + localStorage.removeItem(STORAGE_KEY); 76 + clearDPoPKey(); 77 + } catch { /* localStorage unavailable */ } 70 78 } 71 79 72 80 export function hasPendingMigration(): boolean { ··· 79 87 targetHandle: string; 80 88 sourcePdsUrl: string; 81 89 targetPdsUrl: string; 90 + targetEmail: string; 91 + authMethod?: "password" | "passkey"; 82 92 progressSummary: string; 83 93 step: string; 84 94 } | null { ··· 102 112 targetHandle: state.targetHandle, 103 113 sourcePdsUrl: state.sourcePdsUrl, 104 114 targetPdsUrl: state.targetPdsUrl, 115 + targetEmail: state.targetEmail, 116 + authMethod: state.authMethod, 105 117 progressSummary: progressParts.length > 0 106 118 ? progressParts.join(", ") 107 119 : "just started", ··· 117 129 118 130 state.progress = { ...state.progress, ...updates }; 119 131 try { 120 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 121 - } catch { 122 - } 132 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 133 + } catch { /* localStorage unavailable */ } 123 134 } 124 135 125 136 export function updateStep(step: string): void { ··· 128 139 129 140 state.step = step; 130 141 try { 131 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 132 - } catch { 133 - } 142 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 143 + } catch { /* localStorage unavailable */ } 134 144 } 135 145 136 146 export function setError(error: string, step: string): void { ··· 140 150 state.lastError = error; 141 151 state.lastErrorStep = step; 142 152 try { 143 - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 144 - } catch { 145 - } 153 + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 154 + } catch { /* localStorage unavailable */ } 146 155 }
+69 -3
frontend/src/lib/migration/types.ts
··· 1 1 export type InboundStep = 2 2 | "welcome" 3 - | "source-login" 3 + | "source-handle" 4 4 | "choose-handle" 5 5 | "review" 6 6 | "migrating" 7 + | "passkey-setup" 8 + | "app-password" 7 9 | "email-verify" 8 10 | "plc-token" 9 11 | "did-web-update" 10 12 | "finalizing" 11 13 | "success" 12 14 | "error"; 15 + 16 + export type AuthMethod = "password" | "passkey"; 13 17 14 18 export type OutboundStep = 15 19 | "welcome" ··· 54 58 plcToken: string; 55 59 progress: MigrationProgress; 56 60 error: string | null; 57 - requires2FA: boolean; 58 - twoFactorCode: string; 59 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; 60 69 } 61 70 62 71 export interface OutboundMigrationState { ··· 92 101 sourceHandle: string; 93 102 targetHandle: string; 94 103 targetEmail: string; 104 + authMethod?: AuthMethod; 105 + passkeySetupToken?: string; 95 106 progress: { 96 107 repoExported: boolean; 97 108 repoImported: boolean; ··· 199 210 recoveryKey?: string; 200 211 } 201 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 + 202 258 export interface Preferences { 203 259 preferences: unknown[]; 204 260 } ··· 214 270 this.name = "MigrationError"; 215 271 } 216 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 8 "blob:*/*", 9 9 ].join(" "); 10 10 const CLIENT_ID = !(import.meta.env.DEV) 11 - ? `${window.location.origin}/oauth/client-metadata.json` 11 + ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 12 : `http://localhost/?scope=${SCOPES}`; 13 - const REDIRECT_URI = `${window.location.origin}/`; 13 + const REDIRECT_URI = `${globalThis.location.origin}/`; 14 14 15 15 interface OAuthState { 16 16 state: string; ··· 106 106 107 107 const { request_uri } = await parResponse.json(); 108 108 109 - const authorizeUrl = new URL("/oauth/authorize", window.location.origin); 109 + const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin); 110 110 authorizeUrl.searchParams.set("client_id", CLIENT_ID); 111 111 authorizeUrl.searchParams.set("request_uri", request_uri); 112 112 113 - window.location.href = authorizeUrl.toString(); 113 + globalThis.location.href = authorizeUrl.toString(); 114 114 } 115 115 116 116 export interface OAuthTokens { ··· 191 191 export function checkForOAuthCallback(): 192 192 | { code: string; state: string } 193 193 | null { 194 - const params = new URLSearchParams(window.location.search); 194 + if (globalThis.location.hash === "#/migrate") { 195 + return null; 196 + } 197 + 198 + const params = new URLSearchParams(globalThis.location.search); 195 199 const code = params.get("code"); 196 200 const state = params.get("state"); 197 201 ··· 203 207 } 204 208 205 209 export function clearOAuthCallbackParams(): void { 206 - const url = new URL(window.location.href); 210 + const url = new URL(globalThis.location.href); 207 211 url.search = ""; 208 - window.history.replaceState({}, "", url.toString()); 212 + globalThis.history.replaceState({}, "", url.toString()); 209 213 }
+1 -1
frontend/src/lib/registration/flow.svelte.ts
··· 104 104 state.externalDidWeb.reservedSigningKey = result.signingKey; 105 105 publicKeyMultibase = result.signingKey.replace("did:key:", ""); 106 106 } else { 107 - const keypair = await generateKeypair(); 107 + const keypair = generateKeypair(); 108 108 state.externalDidWeb.byodPrivateKey = keypair.privateKey; 109 109 state.externalDidWeb.byodPublicKeyMultibase = 110 110 keypair.publicKeyMultibase;
+4 -4
frontend/src/lib/router.svelte.ts
··· 1 1 let currentPath = $state( 2 - getPathWithoutQuery(window.location.hash.slice(1) || "/"), 2 + getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"), 3 3 ); 4 4 5 5 function getPathWithoutQuery(hash: string): string { ··· 7 7 return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 8 8 } 9 9 10 - window.addEventListener("hashchange", () => { 11 - currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/"); 10 + globalThis.addEventListener("hashchange", () => { 11 + currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"); 12 12 }); 13 13 14 14 export function navigate(path: string) { 15 15 currentPath = path; 16 - window.location.hash = path; 16 + globalThis.location.hash = path; 17 17 } 18 18 19 19 export function getCurrentPath() {
+1 -1
frontend/src/lib/serverConfig.svelte.ts
··· 74 74 if (initialized) return; 75 75 initialized = true; 76 76 77 - darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 77 + darkModeQuery = globalThis.matchMedia("(prefers-color-scheme: dark)"); 78 78 darkModeQuery.addEventListener("change", applyColors); 79 79 80 80 try {
+106 -32
frontend/src/locales/en.json
··· 902 902 "reauth": { 903 903 "title": "Re-authentication Required", 904 904 "subtitle": "Please verify your identity to continue.", 905 + "password": "Password", 906 + "totp": "TOTP", 907 + "passkey": "Passkey", 908 + "authenticatorCode": "Authenticator Code", 905 909 "usePassword": "Use Password", 906 910 "usePasskey": "Use Passkey", 907 911 "useTotp": "Use Authenticator", ··· 909 913 "totpPlaceholder": "Enter 6-digit code", 910 914 "verify": "Verify", 911 915 "verifying": "Verifying...", 916 + "authenticating": "Authenticating...", 917 + "passkeyPrompt": "Click the button below to authenticate with your passkey.", 912 918 "cancel": "Cancel" 913 919 }, 914 920 "delegation": { ··· 1071 1077 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1072 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.", 1073 1079 "learnMore": "Learn more about migration risks", 1080 + "comingSoon": "Coming soon", 1081 + "oauthCompleting": "Completing authentication...", 1082 + "oauthFailed": "Authentication Failed", 1083 + "tryAgain": "Try Again", 1074 1084 "resume": { 1075 1085 "title": "Resume Migration?", 1076 1086 "incomplete": "You have an incomplete migration in progress:", ··· 1090 1100 "desc": "Move your existing AT Protocol account to this server.", 1091 1101 "understand": "I understand the risks and want to proceed" 1092 1102 }, 1093 - "sourceLogin": { 1094 - "title": "Sign In to Your Current PDS", 1095 - "desc": "Enter your credentials for the account you want to migrate.", 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.", 1096 1108 "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" 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." 1102 1119 }, 1103 1120 "chooseHandle": { 1104 1121 "title": "Choose Your New Handle", 1105 1122 "desc": "Select a handle for your account on this PDS.", 1106 - "handleHint": "Your full handle will be: @{handle}" 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" 1107 1139 }, 1108 1140 "review": { 1109 1141 "title": "Review Migration", 1110 - "desc": "Please review and confirm your migration details.", 1142 + "desc": "Please confirm the details of your migration.", 1111 1143 "currentHandle": "Current Handle", 1112 1144 "newHandle": "New Handle", 1113 - "sourcePds": "Source PDS", 1114 - "targetPds": "This PDS", 1145 + "did": "DID", 1146 + "sourcePds": "From PDS", 1147 + "targetPds": "To PDS", 1115 1148 "email": "Email", 1149 + "authentication": "Authentication", 1150 + "authPasskey": "Passkey (passwordless)", 1151 + "authPassword": "Password", 1116 1152 "inviteCode": "Invite Code", 1117 - "confirm": "I confirm I want to migrate my account", 1118 - "startMigration": "Start Migration" 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..." 1119 1156 }, 1120 1157 "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..." 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" 1131 1182 }, 1132 1183 "emailVerify": { 1133 1184 "title": "Verify Your Email", ··· 1140 1191 "verifying": "Verifying..." 1141 1192 }, 1142 1193 "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..." 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..." 1149 1202 }, 1150 1203 "didWebUpdate": { 1151 1204 "title": "Update Your DID Document", ··· 1168 1221 "success": { 1169 1222 "title": "Migration Complete!", 1170 1223 "desc": "Your account has been successfully migrated to this PDS.", 1171 - "newHandle": "New Handle", 1224 + "yourNewHandle": "Your new handle", 1172 1225 "did": "DID", 1173 - "goToDashboard": "Go to Dashboard" 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" 1174 1248 } 1175 1249 }, 1176 1250 "outbound": {
+103 -29
frontend/src/locales/fi.json
··· 902 902 "reauth": { 903 903 "title": "Uudelleentodennus vaaditaan", 904 904 "subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.", 905 + "password": "Salasana", 906 + "totp": "TOTP", 907 + "passkey": "Pääsyavain", 908 + "authenticatorCode": "Todentajan koodi", 905 909 "usePassword": "Käytä salasanaa", 906 910 "usePasskey": "Käytä pääsyavainta", 907 911 "useTotp": "Käytä todentajaa", ··· 909 913 "totpPlaceholder": "Syötä 6-numeroinen koodi", 910 914 "verify": "Vahvista", 911 915 "verifying": "Vahvistetaan...", 916 + "authenticating": "Todennetaan...", 917 + "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 912 918 "cancel": "Peruuta" 913 919 }, 914 920 "verifyChannel": { ··· 1071 1077 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1072 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ä.", 1073 1079 "learnMore": "Lue lisää siirron riskeistä", 1080 + "comingSoon": "Tulossa pian", 1081 + "oauthCompleting": "Viimeistellään todennusta...", 1082 + "oauthFailed": "Todennus epäonnistui", 1083 + "tryAgain": "Yritä uudelleen", 1074 1084 "resume": { 1075 1085 "title": "Jatka siirtoa?", 1076 1086 "incomplete": "Sinulla on keskeneräinen siirto:", ··· 1090 1100 "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", 1091 1101 "understand": "Ymmärrän riskit ja haluan jatkaa" 1092 1102 }, 1093 - "sourceLogin": { 1094 - "title": "Kirjaudu nykyiseen PDS:ääsi", 1095 - "desc": "Syötä siirrettävän tilin tunnukset.", 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.", 1096 1108 "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" 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." 1102 1119 }, 1103 1120 "chooseHandle": { 1104 1121 "title": "Valitse uusi käyttäjätunnuksesi", 1105 1122 "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", 1106 - "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}" 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" 1107 1139 }, 1108 1140 "review": { 1109 1141 "title": "Tarkista siirto", 1110 - "desc": "Tarkista ja vahvista siirtotietosi.", 1142 + "desc": "Vahvista siirtosi tiedot.", 1111 1143 "currentHandle": "Nykyinen käyttäjätunnus", 1112 1144 "newHandle": "Uusi käyttäjätunnus", 1145 + "did": "DID", 1113 1146 "sourcePds": "Lähde-PDS", 1114 - "targetPds": "Tämä PDS", 1147 + "targetPds": "Kohde-PDS", 1115 1148 "email": "Sähköposti", 1149 + "authentication": "Tunnistautuminen", 1150 + "authPasskey": "Pääsyavain (salasanaton)", 1151 + "authPassword": "Salasana", 1116 1152 "inviteCode": "Kutsukoodi", 1117 - "confirm": "Vahvistan haluavani siirtää tilini", 1118 - "startMigration": "Aloita siirto" 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..." 1119 1156 }, 1120 1157 "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..." 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" 1131 1182 }, 1132 1183 "emailVerify": { 1133 1184 "title": "Vahvista sähköpostisi", ··· 1140 1191 "verifying": "Vahvistetaan..." 1141 1192 }, 1142 1193 "plcToken": { 1143 - "title": "Vahvista henkilöllisyytesi", 1144 - "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.", 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.", 1145 1197 "tokenLabel": "Vahvistuskoodi", 1146 1198 "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1147 - "resend": "Lähetä uudelleen", 1148 - "resending": "Lähetetään..." 1199 + "resend": "Lähetä koodi uudelleen", 1200 + "complete": "Viimeistele siirto", 1201 + "completing": "Vahvistetaan..." 1149 1202 }, 1150 1203 "didWebUpdate": { 1151 1204 "title": "Päivitä DID-dokumenttisi", ··· 1168 1221 "success": { 1169 1222 "title": "Siirto valmis!", 1170 1223 "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", 1171 - "newHandle": "Uusi käyttäjätunnus", 1224 + "yourNewHandle": "Uusi käyttäjätunnuksesi", 1172 1225 "did": "DID", 1173 - "goToDashboard": "Siirry hallintapaneeliin" 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" 1174 1248 } 1175 1249 }, 1176 1250 "outbound": {
+117 -31
frontend/src/locales/ja.json
··· 189 189 "title": "DID ドキュメントエディター", 190 190 "preview": "現在の DID ドキュメント", 191 191 "verificationMethods": "検証方法(署名キー)", 192 + "verificationMethodsDesc": "DIDの代わりに動作できる署名キー。新しいPDSに移行する際は、そのPDSの署名キーをここに追加してください。", 192 193 "addKey": "キーを追加", 193 194 "removeKey": "削除", 194 195 "keyId": "キー ID", 195 196 "keyIdPlaceholder": "#atproto", 196 197 "publicKey": "公開キー(Multibase)", 197 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "検証方法が設定されていません。ローカルPDSキーを使用しています。", 198 200 "alsoKnownAs": "別名(ハンドル)", 201 + "alsoKnownAsDesc": "DIDを指すハンドル。新しいPDSでハンドルが変更されたら更新してください。", 199 202 "addHandle": "ハンドルを追加", 203 + "removeHandle": "削除", 204 + "handle": "ハンドル", 200 205 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "サービスエンドポイント(現在の PDS)", 206 + "noHandles": "ハンドルが設定されていません。ローカルハンドルを使用しています。", 207 + "serviceEndpoint": "サービスエンドポイント", 208 + "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 209 + "currentPds": "現在のPDS URL", 202 210 "save": "変更を保存", 203 211 "saving": "保存中...", 204 212 "success": "DID ドキュメントを更新しました", 213 + "saveFailed": "DIDドキュメントの保存に失敗しました", 214 + "loadFailed": "DIDドキュメントの読み込みに失敗しました", 215 + "invalidMultibase": "公開キーは'z'で始まる有効なmultibase文字列である必要があります", 216 + "invalidHandle": "ハンドルはat:// URIである必要があります(例:at://handle.example.com)", 205 217 "helpTitle": "これは何ですか?", 206 218 "helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。" 207 219 }, ··· 890 902 "reauth": { 891 903 "title": "再認証が必要です", 892 904 "subtitle": "続行するには本人確認を行ってください。", 905 + "password": "パスワード", 906 + "totp": "TOTP", 907 + "passkey": "パスキー", 908 + "authenticatorCode": "認証コード", 893 909 "usePassword": "パスワードを使用", 894 910 "usePasskey": "パスキーを使用", 895 911 "useTotp": "認証アプリを使用", ··· 897 913 "totpPlaceholder": "6桁のコードを入力", 898 914 "verify": "確認", 899 915 "verifying": "確認中...", 916 + "authenticating": "認証中...", 917 + "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 900 918 "cancel": "キャンセル" 901 919 }, 902 920 "verifyChannel": { ··· 1059 1077 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1060 1078 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1061 1079 "learnMore": "移行のリスクについて詳しく", 1080 + "comingSoon": "近日公開", 1081 + "oauthCompleting": "認証を完了しています...", 1082 + "oauthFailed": "認証に失敗しました", 1083 + "tryAgain": "再試行", 1062 1084 "resume": { 1063 1085 "title": "移行を再開しますか?", 1064 1086 "incomplete": "未完了の移行があります:", ··· 1078 1100 "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", 1079 1101 "understand": "リスクを理解し、続行します" 1080 1102 }, 1081 - "sourceLogin": { 1082 - "title": "現在のPDSにサインイン", 1083 - "desc": "移行するアカウントの認証情報を入力してください。", 1103 + "sourceAuth": { 1104 + "title": "現在のハンドルを入力", 1105 + "titleResume": "移行を再開", 1106 + "desc": "移行するアカウントのハンドルを入力してください。", 1107 + "descResume": "移行を続行するには、元のPDSに再認証してください。", 1084 1108 "handle": "ハンドル", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "パスワード", 1087 - "twoFactorCode": "2要素認証コード", 1088 - "twoFactorRequired": "2要素認証が必要です", 1089 - "signIn": "サインインして続行" 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で再認証が必要です。" 1090 1119 }, 1091 1120 "chooseHandle": { 1092 1121 "title": "新しいハンドルを選択", 1093 1122 "desc": "このPDSでのアカウントのハンドルを選択してください。", 1094 - "handleHint": "完全なハンドル: @{handle}" 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": "招待コード" 1095 1139 }, 1096 1140 "review": { 1097 1141 "title": "移行の確認", 1098 1142 "desc": "移行の詳細を確認してください。", 1099 1143 "currentHandle": "現在のハンドル", 1100 1144 "newHandle": "新しいハンドル", 1145 + "did": "DID", 1101 1146 "sourcePds": "移行元PDS", 1102 - "targetPds": "このPDS", 1147 + "targetPds": "移行先PDS", 1103 1148 "email": "メール", 1149 + "authentication": "認証", 1150 + "authPasskey": "パスキー(パスワードレス)", 1151 + "authPassword": "パスワード", 1104 1152 "inviteCode": "招待コード", 1105 - "confirm": "アカウントを移行することを確認します", 1106 - "startMigration": "移行を開始" 1153 + "warning": "「移行を開始」をクリックすると、リポジトリとデータの転送が始まります。このプロセスは簡単に元に戻すことができません。", 1154 + "startMigration": "移行を開始", 1155 + "starting": "開始中..." 1107 1156 }, 1108 1157 "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操作をリクエスト中..." 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": "続ける" 1119 1182 }, 1120 1183 "emailVerify": { 1121 1184 "title": "メールアドレスを確認", ··· 1128 1191 "verifying": "確認中..." 1129 1192 }, 1130 1193 "plcToken": { 1131 - "title": "本人確認", 1132 - "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。", 1133 - "tokenLabel": "確認トークン", 1134 - "tokenPlaceholder": "メールに記載されたトークンを入力", 1135 - "resend": "再送信", 1136 - "resending": "送信中..." 1194 + "title": "移行を確認", 1195 + "desc": "古いアカウントに登録されているメールアドレスに確認コードが送信されました。", 1196 + "info": "このコードはアカウントへのアクセス権を確認し、このPDSを指すようにアイデンティティを更新することを承認します。", 1197 + "tokenLabel": "確認コード", 1198 + "tokenPlaceholder": "メールに記載されたコードを入力", 1199 + "resend": "コードを再送信", 1200 + "complete": "移行を完了", 1201 + "completing": "確認中..." 1137 1202 }, 1138 1203 "didWebUpdate": { 1139 1204 "title": "DIDドキュメントを更新", ··· 1156 1221 "success": { 1157 1222 "title": "移行完了!", 1158 1223 "desc": "アカウントはこのPDSに正常に移行されました。", 1159 - "newHandle": "新しいハンドル", 1224 + "yourNewHandle": "新しいハンドル", 1160 1225 "did": "DID", 1161 - "goToDashboard": "ダッシュボードへ" 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": "移行後、古いアカウントは無効化されます" 1162 1248 } 1163 1249 }, 1164 1250 "outbound": {
+118 -32
frontend/src/locales/ko.json
··· 189 189 "title": "DID 문서 편집기", 190 190 "preview": "현재 DID 문서", 191 191 "verificationMethods": "검증 방법 (서명 키)", 192 + "verificationMethodsDesc": "DID를 대신하여 동작할 수 있는 서명 키입니다. 새 PDS로 마이그레이션할 때 해당 서명 키를 여기에 추가하세요.", 192 193 "addKey": "키 추가", 193 194 "removeKey": "삭제", 194 195 "keyId": "키 ID", 195 196 "keyIdPlaceholder": "#atproto", 196 197 "publicKey": "공개 키 (Multibase)", 197 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "구성된 검증 방법이 없습니다. 로컬 PDS 키를 사용 중입니다.", 198 200 "alsoKnownAs": "다른 이름 (핸들)", 201 + "alsoKnownAsDesc": "DID를 가리키는 핸들입니다. 새 PDS에서 핸들이 변경되면 업데이트하세요.", 199 202 "addHandle": "핸들 추가", 203 + "removeHandle": "삭제", 204 + "handle": "핸들", 200 205 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "서비스 엔드포인트 (현재 PDS)", 206 + "noHandles": "구성된 핸들이 없습니다. 로컬 핸들을 사용 중입니다.", 207 + "serviceEndpoint": "서비스 엔드포인트", 208 + "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 209 + "currentPds": "현재 PDS URL", 202 210 "save": "변경사항 저장", 203 211 "saving": "저장 중...", 204 212 "success": "DID 문서가 업데이트되었습니다", 213 + "saveFailed": "DID 문서 저장에 실패했습니다", 214 + "loadFailed": "DID 문서 로드에 실패했습니다", 215 + "invalidMultibase": "공개 키는 'z'로 시작하는 유효한 multibase 문자열이어야 합니다", 216 + "invalidHandle": "핸들은 at:// URI여야 합니다 (예: at://handle.example.com)", 205 217 "helpTitle": "이것은 무엇인가요?", 206 218 "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요." 207 219 }, ··· 890 902 "reauth": { 891 903 "title": "재인증 필요", 892 904 "subtitle": "계속하려면 본인 확인을 해주세요.", 905 + "password": "비밀번호", 906 + "totp": "TOTP", 907 + "passkey": "패스키", 908 + "authenticatorCode": "인증 코드", 893 909 "usePassword": "비밀번호 사용", 894 910 "usePasskey": "패스키 사용", 895 911 "useTotp": "인증 앱 사용", ··· 897 913 "totpPlaceholder": "6자리 코드 입력", 898 914 "verify": "확인", 899 915 "verifying": "확인 중...", 916 + "authenticating": "인증 중...", 917 + "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 900 918 "cancel": "취소" 901 919 }, 902 920 "verifyChannel": { ··· 1059 1077 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1060 1078 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1061 1079 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1080 + "comingSoon": "곧 출시 예정", 1081 + "oauthCompleting": "인증 완료 중...", 1082 + "oauthFailed": "인증 실패", 1083 + "tryAgain": "다시 시도", 1062 1084 "resume": { 1063 1085 "title": "마이그레이션을 재개하시겠습니까?", 1064 1086 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", ··· 1078 1100 "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", 1079 1101 "understand": "위험을 이해하고 계속 진행합니다" 1080 1102 }, 1081 - "sourceLogin": { 1082 - "title": "현재 PDS에 로그인", 1083 - "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.", 1103 + "sourceAuth": { 1104 + "title": "현재 핸들 입력", 1105 + "titleResume": "마이그레이션 재개", 1106 + "desc": "마이그레이션할 계정의 핸들을 입력하세요.", 1107 + "descResume": "마이그레이션을 계속하려면 소스 PDS에 재인증하세요.", 1084 1108 "handle": "핸들", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "비밀번호", 1087 - "twoFactorCode": "2단계 인증 코드", 1088 - "twoFactorRequired": "2단계 인증이 필요합니다", 1089 - "signIn": "로그인 및 계속" 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로 재인증이 필요합니다." 1090 1119 }, 1091 1120 "chooseHandle": { 1092 1121 "title": "새 핸들 선택", 1093 1122 "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", 1094 - "handleHint": "전체 핸들: @{handle}" 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": "초대 코드" 1095 1139 }, 1096 1140 "review": { 1097 1141 "title": "마이그레이션 검토", 1098 - "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1142 + "desc": "마이그레이션 세부 정보를 확인하세요.", 1099 1143 "currentHandle": "현재 핸들", 1100 1144 "newHandle": "새 핸들", 1145 + "did": "DID", 1101 1146 "sourcePds": "소스 PDS", 1102 - "targetPds": "이 PDS", 1147 + "targetPds": "대상 PDS", 1103 1148 "email": "이메일", 1149 + "authentication": "인증", 1150 + "authPasskey": "패스키 (비밀번호 없음)", 1151 + "authPassword": "비밀번호", 1104 1152 "inviteCode": "초대 코드", 1105 - "confirm": "계정 마이그레이션을 확인합니다", 1106 - "startMigration": "마이그레이션 시작" 1153 + "warning": "\"마이그레이션 시작\"을 클릭하면 저장소와 데이터 전송이 시작됩니다. 이 과정은 쉽게 되돌릴 수 없습니다.", 1154 + "startMigration": "마이그레이션 시작", 1155 + "starting": "시작 중..." 1107 1156 }, 1108 1157 "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 작업 요청 중..." 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": "계속" 1119 1182 }, 1120 1183 "emailVerify": { 1121 1184 "title": "이메일 인증", ··· 1128 1191 "verifying": "인증 중..." 1129 1192 }, 1130 1193 "plcToken": { 1131 - "title": "신원 확인", 1132 - "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.", 1133 - "tokenLabel": "인증 토큰", 1134 - "tokenPlaceholder": "이메일에서 받은 토큰 입력", 1135 - "resend": "재전송", 1136 - "resending": "전송 중..." 1194 + "title": "마이그레이션 확인", 1195 + "desc": "이전 계정에 등록된 이메일로 인증 코드가 전송되었습니다.", 1196 + "info": "이 코드는 계정 접근 권한을 확인하고 이 PDS를 가리키도록 아이덴티티 업데이트를 승인합니다.", 1197 + "tokenLabel": "인증 코드", 1198 + "tokenPlaceholder": "이메일에서 받은 코드 입력", 1199 + "resend": "코드 재전송", 1200 + "complete": "마이그레이션 완료", 1201 + "completing": "확인 중..." 1137 1202 }, 1138 1203 "didWebUpdate": { 1139 1204 "title": "DID 문서 업데이트", ··· 1156 1221 "success": { 1157 1222 "title": "마이그레이션 완료!", 1158 1223 "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", 1159 - "newHandle": "새 핸들", 1224 + "yourNewHandle": "새 핸들", 1160 1225 "did": "DID", 1161 - "goToDashboard": "대시보드로 이동" 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": "마이그레이션 후 이전 계정은 비활성화됩니다" 1162 1248 } 1163 1249 }, 1164 1250 "outbound": {
+119 -33
frontend/src/locales/sv.json
··· 189 189 "title": "DID-dokumentredigerare", 190 190 "preview": "Nuvarande DID-dokument", 191 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.", 192 193 "addKey": "Lägg till nyckel", 193 194 "removeKey": "Ta bort", 194 195 "keyId": "Nyckel-ID", 195 196 "keyIdPlaceholder": "#atproto", 196 197 "publicKey": "Publik nyckel (Multibase)", 197 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "Inga verifieringsmetoder konfigurerade. Använder lokal PDS-nyckel.", 198 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.", 199 202 "addHandle": "Lägg till användarnamn", 203 + "removeHandle": "Ta bort", 204 + "handle": "Användarnamn", 200 205 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)", 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", 202 210 "save": "Spara ändringar", 203 211 "saving": "Sparar...", 204 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)", 205 217 "helpTitle": "Vad är detta?", 206 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." 207 219 }, ··· 890 902 "reauth": { 891 903 "title": "Återautentisering krävs", 892 904 "subtitle": "Verifiera din identitet för att fortsätta.", 905 + "password": "Lösenord", 906 + "totp": "TOTP", 907 + "passkey": "Passkey", 908 + "authenticatorCode": "Autentiseringskod", 893 909 "usePassword": "Använd lösenord", 894 910 "usePasskey": "Använd nyckel", 895 911 "useTotp": "Använd autentiserare", ··· 897 913 "totpPlaceholder": "Ange 6-siffrig kod", 898 914 "verify": "Verifiera", 899 915 "verifying": "Verifierar...", 916 + "authenticating": "Autentiserar...", 917 + "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 900 918 "cancel": "Avbryt" 901 919 }, 902 920 "verifyChannel": { ··· 1059 1077 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1060 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.", 1061 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", 1062 1084 "resume": { 1063 1085 "title": "Återuppta flytt?", 1064 1086 "incomplete": "Du har en ofullständig flytt pågående:", ··· 1078 1100 "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", 1079 1101 "understand": "Jag förstår riskerna och vill fortsätta" 1080 1102 }, 1081 - "sourceLogin": { 1082 - "title": "Logga in på din nuvarande PDS", 1083 - "desc": "Ange uppgifterna för kontot du vill flytta.", 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.", 1084 1108 "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" 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." 1090 1119 }, 1091 1120 "chooseHandle": { 1092 1121 "title": "Välj ditt nya användarnamn", 1093 1122 "desc": "Välj ett användarnamn för ditt konto på denna PDS.", 1094 - "handleHint": "Ditt fullständiga användarnamn blir: @{handle}" 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" 1095 1139 }, 1096 1140 "review": { 1097 1141 "title": "Granska flytt", 1098 - "desc": "Granska och bekräfta dina flyttdetaljer.", 1142 + "desc": "Bekräfta detaljerna för din flytt.", 1099 1143 "currentHandle": "Nuvarande användarnamn", 1100 1144 "newHandle": "Nytt användarnamn", 1101 - "sourcePds": "Käll-PDS", 1102 - "targetPds": "Denna PDS", 1145 + "did": "DID", 1146 + "sourcePds": "Från PDS", 1147 + "targetPds": "Till PDS", 1103 1148 "email": "E-post", 1149 + "authentication": "Autentisering", 1150 + "authPasskey": "Passkey (lösenordslös)", 1151 + "authPassword": "Lösenord", 1104 1152 "inviteCode": "Inbjudningskod", 1105 - "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1106 - "startMigration": "Starta flytt" 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..." 1107 1156 }, 1108 1157 "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..." 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" 1119 1182 }, 1120 1183 "emailVerify": { 1121 1184 "title": "Verifiera din e-post", ··· 1128 1191 "verifying": "Verifierar..." 1129 1192 }, 1130 1193 "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..." 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..." 1137 1202 }, 1138 1203 "didWebUpdate": { 1139 1204 "title": "Uppdatera ditt DID-dokument", ··· 1156 1221 "success": { 1157 1222 "title": "Flytt klar!", 1158 1223 "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", 1159 - "newHandle": "Nytt användarnamn", 1224 + "yourNewHandle": "Ditt nya användarnamn", 1160 1225 "did": "DID", 1161 - "goToDashboard": "Gå till instrumentpanel" 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" 1162 1248 } 1163 1249 }, 1164 1250 "outbound": {
+117 -31
frontend/src/locales/zh.json
··· 189 189 "title": "DID 文档编辑器", 190 190 "preview": "当前 DID 文档", 191 191 "verificationMethods": "验证方法(签名密钥)", 192 + "verificationMethodsDesc": "可以代表您的 DID 进行操作的签名密钥。迁移到新 PDS 时,请在此添加其签名密钥。", 192 193 "addKey": "添加密钥", 193 194 "removeKey": "删除", 194 195 "keyId": "密钥 ID", 195 196 "keyIdPlaceholder": "#atproto", 196 197 "publicKey": "公钥(Multibase)", 197 198 "publicKeyPlaceholder": "zQ3sh...", 199 + "noKeys": "未配置验证方法。正在使用本地 PDS 密钥。", 198 200 "alsoKnownAs": "别名(用户名)", 201 + "alsoKnownAsDesc": "指向您的 DID 的用户名。当您在新 PDS 上更改用户名时请更新此项。", 199 202 "addHandle": "添加用户名", 203 + "removeHandle": "删除", 204 + "handle": "用户名", 200 205 "handlePlaceholder": "at://handle.pds.com", 201 - "serviceEndpoint": "服务端点(当前 PDS)", 206 + "noHandles": "未配置用户名。正在使用本地用户名。", 207 + "serviceEndpoint": "服务端点", 208 + "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 209 + "currentPds": "当前 PDS URL", 202 210 "save": "保存更改", 203 211 "saving": "保存中...", 204 212 "success": "DID 文档已更新", 213 + "saveFailed": "保存 DID 文档失败", 214 + "loadFailed": "加载 DID 文档失败", 215 + "invalidMultibase": "公钥必须是以 'z' 开头的有效 multibase 字符串", 216 + "invalidHandle": "用户名必须是 at:// URI(例如:at://handle.example.com)", 205 217 "helpTitle": "这是什么?", 206 218 "helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。" 207 219 }, ··· 890 902 "reauth": { 891 903 "title": "需要重新验证", 892 904 "subtitle": "请验证您的身份以继续。", 905 + "password": "密码", 906 + "totp": "TOTP", 907 + "passkey": "通行密钥", 908 + "authenticatorCode": "验证码", 893 909 "usePassword": "使用密码", 894 910 "usePasskey": "使用通行密钥", 895 911 "useTotp": "使用身份验证器", ··· 897 913 "totpPlaceholder": "输入6位验证码", 898 914 "verify": "验证", 899 915 "verifying": "验证中...", 916 + "authenticating": "正在验证...", 917 + "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 900 918 "cancel": "取消" 901 919 }, 902 920 "verifyChannel": { ··· 1059 1077 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1060 1078 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1061 1079 "learnMore": "了解更多迁移风险", 1080 + "comingSoon": "即将推出", 1081 + "oauthCompleting": "正在完成身份验证...", 1082 + "oauthFailed": "身份验证失败", 1083 + "tryAgain": "重试", 1062 1084 "resume": { 1063 1085 "title": "恢复迁移?", 1064 1086 "incomplete": "您有一个未完成的迁移:", ··· 1078 1100 "desc": "将您现有的AT Protocol账户移至此服务器。", 1079 1101 "understand": "我了解风险并希望继续" 1080 1102 }, 1081 - "sourceLogin": { 1082 - "title": "登录到您当前的PDS", 1083 - "desc": "输入您要迁移的账户凭据。", 1103 + "sourceAuth": { 1104 + "title": "输入您当前的用户名", 1105 + "titleResume": "恢复迁移", 1106 + "desc": "输入您要迁移的账户用户名。", 1107 + "descResume": "重新验证您的源PDS以继续迁移。", 1084 1108 "handle": "用户名", 1085 - "handlePlaceholder": "you.bsky.social", 1086 - "password": "密码", 1087 - "twoFactorCode": "双因素验证码", 1088 - "twoFactorRequired": "需要双因素认证", 1089 - "signIn": "登录并继续" 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重新验证才能继续。" 1090 1119 }, 1091 1120 "chooseHandle": { 1092 1121 "title": "选择新用户名", 1093 1122 "desc": "为您在此PDS上的账户选择用户名。", 1094 - "handleHint": "您的完整用户名将是:@{handle}" 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": "邀请码" 1095 1139 }, 1096 1140 "review": { 1097 1141 "title": "检查迁移", 1098 - "desc": "请检查并确认您的迁移详情。", 1142 + "desc": "确认您的迁移详情。", 1099 1143 "currentHandle": "当前用户名", 1100 1144 "newHandle": "新用户名", 1145 + "did": "DID", 1101 1146 "sourcePds": "源PDS", 1102 - "targetPds": "此PDS", 1147 + "targetPds": "目标PDS", 1103 1148 "email": "邮箱", 1149 + "authentication": "身份验证", 1150 + "authPasskey": "通行密钥(无密码)", 1151 + "authPassword": "密码", 1104 1152 "inviteCode": "邀请码", 1105 - "confirm": "我确认要迁移我的账户", 1106 - "startMigration": "开始迁移" 1153 + "warning": "点击「开始迁移」后,您的存储库和数据将开始转移。此过程无法轻易撤销。", 1154 + "startMigration": "开始迁移", 1155 + "starting": "启动中..." 1107 1156 }, 1108 1157 "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操作..." 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": "继续" 1119 1182 }, 1120 1183 "emailVerify": { 1121 1184 "title": "验证您的邮箱", ··· 1128 1191 "verifying": "验证中..." 1129 1192 }, 1130 1193 "plcToken": { 1131 - "title": "验证您的身份", 1132 - "desc": "验证码已发送到您在当前PDS注册的邮箱。", 1133 - "tokenLabel": "验证令牌", 1134 - "tokenPlaceholder": "输入邮件中的令牌", 1194 + "title": "验证迁移", 1195 + "desc": "验证码已发送到您旧账户注册的邮箱。", 1196 + "info": "此代码确认您有权访问该账户,并授权将您的身份更新为指向此PDS。", 1197 + "tokenLabel": "验证码", 1198 + "tokenPlaceholder": "输入邮件中的验证码", 1135 1199 "resend": "重新发送", 1136 - "resending": "发送中..." 1200 + "complete": "完成迁移", 1201 + "completing": "验证中..." 1137 1202 }, 1138 1203 "didWebUpdate": { 1139 1204 "title": "更新您的DID文档", ··· 1156 1221 "success": { 1157 1222 "title": "迁移完成!", 1158 1223 "desc": "您的账户已成功迁移到此PDS。", 1159 - "newHandle": "新用户名", 1224 + "yourNewHandle": "您的新用户名", 1160 1225 "did": "DID", 1161 - "goToDashboard": "前往仪表板" 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": "迁移后您的旧账户将被停用" 1162 1248 } 1163 1249 }, 1164 1250 "outbound": {
+150 -42
frontend/src/routes/Migration.svelte
··· 1 1 <script lang="ts"> 2 2 import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 4 5 import { 5 6 createInboundMigrationFlow, 6 7 createOutboundMigrationFlow, ··· 18 19 let direction = $state<Direction>('select') 19 20 let showResumeModal = $state(false) 20 21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) 22 + let oauthError = $state<string | null>(null) 23 + let oauthLoading = $state(false) 21 24 22 25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 23 26 let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 27 + let oauthCallbackProcessed = $state(false) 28 + 29 + $effect(() => { 30 + if (oauthCallbackProcessed) return 24 31 25 - if (hasPendingMigration()) { 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()) { 26 70 resumeInfo = getResumeInfo() 27 71 if (resumeInfo) { 28 - showResumeModal = true 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 + } 29 83 } 30 84 } 31 85 ··· 106 160 {#if showResumeModal && resumeInfo} 107 161 <div class="modal-overlay"> 108 162 <div class="modal"> 109 - <h2>Resume Migration?</h2> 110 - <p>You have an incomplete migration in progress:</p> 163 + <h2>{$_('migration.resume.title')}</h2> 164 + <p>{$_('migration.resume.incomplete')}</p> 111 165 <div class="resume-details"> 112 166 <div class="detail-row"> 113 - <span class="label">Direction:</span> 114 - <span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span> 167 + <span class="label">{$_('migration.resume.direction')}:</span> 168 + <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 115 169 </div> 116 170 {#if resumeInfo.sourceHandle} 117 171 <div class="detail-row"> 118 - <span class="label">From:</span> 172 + <span class="label">{$_('migration.resume.from')}:</span> 119 173 <span class="value">{resumeInfo.sourceHandle}</span> 120 174 </div> 121 175 {/if} 122 176 {#if resumeInfo.targetHandle} 123 177 <div class="detail-row"> 124 - <span class="label">To:</span> 178 + <span class="label">{$_('migration.resume.to')}:</span> 125 179 <span class="value">{resumeInfo.targetHandle}</span> 126 180 </div> 127 181 {/if} 128 182 <div class="detail-row"> 129 - <span class="label">Progress:</span> 183 + <span class="label">{$_('migration.resume.progress')}:</span> 130 184 <span class="value">{resumeInfo.progressSummary}</span> 131 185 </div> 132 186 </div> 133 - <p class="note">You will need to re-enter your credentials to continue.</p> 187 + <p class="note">{$_('migration.resume.reenterCredentials')}</p> 134 188 <div class="modal-actions"> 135 - <button class="ghost" onclick={handleStartOver}>Start Over</button> 136 - <button onclick={handleResume}>Resume</button> 189 + <button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button> 190 + <button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button> 137 191 </div> 138 192 </div> 139 193 </div> 140 194 {/if} 141 195 142 - {#if direction === 'select'} 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'} 143 208 <header class="page-header"> 144 - <h1>Account Migration</h1> 145 - <p class="subtitle">Move your AT Protocol identity between servers</p> 209 + <h1>{$_('migration.title')}</h1> 210 + <p class="subtitle">{$_('migration.subtitle')}</p> 146 211 </header> 147 212 148 213 <div class="direction-cards"> 149 214 <button class="direction-card ghost" onclick={selectInbound}> 150 215 <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> 216 + <h2>{$_('migration.migrateHere')}</h2> 217 + <p>{$_('migration.migrateHereDesc')}</p> 153 218 <ul class="features"> 154 - <li>Bring your DID and identity</li> 155 - <li>Transfer all your data</li> 156 - <li>Keep your followers</li> 219 + <li>{$_('migration.bringDid')}</li> 220 + <li>{$_('migration.transferData')}</li> 221 + <li>{$_('migration.keepFollowers')}</li> 157 222 </ul> 158 223 </button> 159 224 160 - <button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}> 225 + <button class="direction-card ghost" onclick={selectOutbound} disabled> 161 226 <div class="card-icon">↑</div> 162 - <h2>Migrate Away</h2> 163 - <p>Move your account from this PDS to another server.</p> 227 + <h2>{$_('migration.migrateAway')}</h2> 228 + <p>{$_('migration.migrateAwayDesc')}</p> 164 229 <ul class="features"> 165 - <li>Export your repository</li> 166 - <li>Transfer to new PDS</li> 167 - <li>Update your identity</li> 230 + <li>{$_('migration.exportRepo')}</li> 231 + <li>{$_('migration.transferToPds')}</li> 232 + <li>{$_('migration.updateIdentity')}</li> 168 233 </ul> 169 - {#if !auth.session} 170 - <p class="login-required">Login required</p> 171 - {/if} 234 + <p class="login-required">{$_('migration.comingSoon')}</p> 172 235 </button> 173 236 </div> 174 237 175 238 <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> 239 + <h3>{$_('migration.whatIsMigration')}</h3> 240 + <p>{$_('migration.whatIsMigrationDesc')}</p> 181 241 182 - <h3>Before you migrate</h3> 242 + <h3>{$_('migration.beforeMigrate')}</h3> 183 243 <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> 244 + <li>{$_('migration.beforeMigrate1')}</li> 245 + <li>{$_('migration.beforeMigrate2')}</li> 246 + <li>{$_('migration.beforeMigrate3')}</li> 247 + <li>{$_('migration.beforeMigrate4')}</li> 188 248 </ul> 189 249 190 250 <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. 251 + <strong>Important:</strong> {$_('migration.importantWarning')} 193 252 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener"> 194 - Learn more about migration risks 253 + {$_('migration.learnMore')} 195 254 </a> 196 255 </div> 197 256 </div> ··· 199 258 {:else if direction === 'inbound' && inboundFlow} 200 259 <InboundWizard 201 260 flow={inboundFlow} 261 + {resumeInfo} 202 262 onBack={handleBack} 203 263 onComplete={handleInboundComplete} 204 264 /> ··· 409 469 display: flex; 410 470 gap: var(--space-3); 411 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; 412 520 } 413 521 </style>
+3 -3
frontend/src/styles/base.css
··· 229 229 } 230 230 231 231 code { 232 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 232 + font-family: var(--font-mono); 233 233 font-size: 0.9em; 234 234 background: var(--bg-tertiary); 235 235 padding: var(--space-1) var(--space-2); ··· 237 237 } 238 238 239 239 pre { 240 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 240 + font-family: var(--font-mono); 241 241 font-size: var(--text-sm); 242 242 background: var(--bg-tertiary); 243 243 padding: var(--space-4); ··· 400 400 } 401 401 402 402 .mono { 403 - font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 403 + font-family: var(--font-mono); 404 404 } 405 405 406 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 48 --transition-normal: 0.15s ease; 49 49 --transition-slow: 0.25s ease; 50 50 51 + --font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 52 + 51 53 --bg-primary: #f9fafa; 52 54 --bg-secondary: #f1f3f3; 53 55 --bg-tertiary: #e8ebeb; 56 + --bg-hover: #e8ebeb; 54 57 --bg-card: #ffffff; 55 58 --bg-input: #ffffff; 56 59 --bg-input-disabled: #f1f3f3; ··· 93 96 --bg-primary: #0a0c0c; 94 97 --bg-secondary: #131616; 95 98 --bg-tertiary: #1a1d1d; 99 + --bg-hover: #1a1d1d; 96 100 --bg-card: #131616; 97 101 --bg-input: #1a1d1d; 98 102 --bg-input-disabled: #131616;
+17 -14
frontend/src/tests/AppPasswords.test.ts
··· 15 15 beforeEach(() => { 16 16 clearMocks(); 17 17 setupFetchMock(); 18 - window.confirm = vi.fn(() => true); 18 + globalThis.confirm = vi.fn(() => true); 19 19 }); 20 20 describe("authentication guard", () => { 21 21 it("redirects to login when not authenticated", async () => { 22 22 setupUnauthenticatedUser(); 23 23 render(AppPasswords); 24 24 await waitFor(() => { 25 - expect(window.location.hash).toBe("#/login"); 25 + expect(globalThis.location.hash).toBe("#/login"); 26 26 }); 27 27 }); 28 28 }); ··· 97 97 await waitFor(() => { 98 98 expect(screen.getByText("Graysky")).toBeInTheDocument(); 99 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(); 100 + expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument(); 101 + expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument(); 102 102 expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength( 103 103 2, 104 104 ); ··· 199 199 await fireEvent.input(input, { target: { value: "MyApp" } }); 200 200 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 201 201 await waitFor(() => { 202 - expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 202 + expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 203 203 expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument(); 204 - expect(screen.getByText(/name: myapp/i)).toBeInTheDocument(); 204 + expect(screen.getByText("MyApp")).toBeInTheDocument(); 205 205 expect(input.value).toBe(""); 206 206 }); 207 207 }); ··· 221 221 }); 222 222 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 223 223 await waitFor(() => { 224 - expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 224 + expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 225 225 }); 226 + await fireEvent.click( 227 + screen.getByLabelText(/i have saved my app password/i), 228 + ); 226 229 await fireEvent.click(screen.getByRole("button", { name: /done/i })); 227 230 await waitFor(() => { 228 - expect(screen.queryByText(/app password created/i)).not 231 + expect(screen.queryByText(/save this app password/i)).not 229 232 .toBeInTheDocument(); 230 233 }); 231 234 }); ··· 255 258 }); 256 259 it("shows confirmation dialog before revoking", async () => { 257 260 const confirmSpy = vi.fn(() => false); 258 - window.confirm = confirmSpy; 261 + globalThis.confirm = confirmSpy; 259 262 mockEndpoint( 260 263 "com.atproto.server.listAppPasswords", 261 264 () => jsonResponse({ passwords: [testPassword] }), ··· 270 273 ); 271 274 }); 272 275 it("does not revoke when confirmation is cancelled", async () => { 273 - window.confirm = vi.fn(() => false); 276 + globalThis.confirm = vi.fn(() => false); 274 277 let revokeCalled = false; 275 278 mockEndpoint( 276 279 "com.atproto.server.listAppPasswords", ··· 288 291 expect(revokeCalled).toBe(false); 289 292 }); 290 293 it("calls revokeAppPassword with correct name", async () => { 291 - window.confirm = vi.fn(() => true); 294 + globalThis.confirm = vi.fn(() => true); 292 295 let capturedName: string | null = null; 293 296 mockEndpoint( 294 297 "com.atproto.server.listAppPasswords", ··· 309 312 }); 310 313 }); 311 314 it("shows loading state while revoking", async () => { 312 - window.confirm = vi.fn(() => true); 315 + globalThis.confirm = vi.fn(() => true); 313 316 mockEndpoint( 314 317 "com.atproto.server.listAppPasswords", 315 318 () => jsonResponse({ passwords: [testPassword] }), ··· 328 331 expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled(); 329 332 }); 330 333 it("reloads password list after successful revocation", async () => { 331 - window.confirm = vi.fn(() => true); 334 + globalThis.confirm = vi.fn(() => true); 332 335 let listCallCount = 0; 333 336 mockEndpoint("com.atproto.server.listAppPasswords", () => { 334 337 listCallCount++; ··· 352 355 }); 353 356 }); 354 357 it("shows error when revocation fails", async () => { 355 - window.confirm = vi.fn(() => true); 358 + globalThis.confirm = vi.fn(() => true); 356 359 mockEndpoint( 357 360 "com.atproto.server.listAppPasswords", 358 361 () => jsonResponse({ passwords: [testPassword] }),
+76 -13
frontend/src/tests/Comms.test.ts
··· 8 8 mockData, 9 9 mockEndpoint, 10 10 setupAuthenticatedUser, 11 - setupFetchMock, 11 + setupDefaultMocks, 12 12 setupUnauthenticatedUser, 13 13 } from "./mocks"; 14 14 describe("Comms", () => { 15 15 beforeEach(() => { 16 16 clearMocks(); 17 - setupFetchMock(); 17 + setupDefaultMocks(); 18 18 }); 19 19 describe("authentication guard", () => { 20 20 it("redirects to login when not authenticated", async () => { 21 21 setupUnauthenticatedUser(); 22 22 render(Comms); 23 23 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 24 + expect(globalThis.location.hash).toBe("#/login"); 25 25 }); 26 26 }); 27 27 }); ··· 32 32 "com.tranquil.account.getNotificationPrefs", 33 33 () => jsonResponse(mockData.notificationPrefs()), 34 34 ); 35 + mockEndpoint( 36 + "com.atproto.server.describeServer", 37 + () => jsonResponse(mockData.describeServer()), 38 + ); 39 + mockEndpoint( 40 + "com.tranquil.account.getNotificationHistory", 41 + () => jsonResponse({ notifications: [] }), 42 + ); 35 43 }); 36 44 it("displays all page elements and sections", async () => { 37 45 render(Comms); 38 46 await waitFor(() => { 39 47 expect( 40 48 screen.getByRole("heading", { 41 - name: /notification preferences/i, 49 + name: /communication preferences|notification preferences/i, 42 50 level: 1, 43 51 }), 44 52 ).toBeInTheDocument(); 45 53 expect(screen.getByRole("link", { name: /dashboard/i })) 46 54 .toHaveAttribute("href", "#/dashboard"); 47 - expect(screen.getByText(/password resets/i)).toBeInTheDocument(); 48 55 expect(screen.getByRole("heading", { name: /preferred channel/i })) 49 56 .toBeInTheDocument(); 50 57 expect(screen.getByRole("heading", { name: /channel configuration/i })) ··· 55 62 describe("loading state", () => { 56 63 beforeEach(() => { 57 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 + ); 58 73 }); 59 74 it("shows loading text while fetching preferences", async () => { 60 75 mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { ··· 68 83 describe("channel options", () => { 69 84 beforeEach(() => { 70 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 + ); 71 94 }); 72 95 it("displays all four channel options", async () => { 73 96 mockEndpoint( ··· 127 150 ); 128 151 render(Comms); 129 152 await waitFor(() => { 130 - expect(screen.getAllByText(/configure below to enable/i).length) 153 + expect(screen.getAllByText(/configure.*to enable/i).length) 131 154 .toBeGreaterThan(0); 132 155 }); 133 156 }); ··· 151 174 describe("channel configuration", () => { 152 175 beforeEach(() => { 153 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 + ); 154 185 }); 155 186 it("displays email as readonly with current value", async () => { 156 187 mockEndpoint( ··· 179 210 render(Comms); 180 211 await waitFor(() => { 181 212 expect( 182 - (screen.getByLabelText(/discord user id/i) as HTMLInputElement).value, 213 + (screen.getByLabelText(/discord.*id/i) as HTMLInputElement).value, 183 214 ).toBe("123456789"); 184 215 expect( 185 - (screen.getByLabelText(/telegram username/i) as HTMLInputElement) 216 + (screen.getByLabelText(/telegram.*username/i) as HTMLInputElement) 186 217 .value, 187 218 ).toBe("testuser"); 188 219 expect( 189 - (screen.getByLabelText(/signal phone number/i) as HTMLInputElement) 220 + (screen.getByLabelText(/signal.*number/i) as HTMLInputElement) 190 221 .value, 191 222 ).toBe("+1234567890"); 192 223 }); ··· 195 226 describe("verification status badges", () => { 196 227 beforeEach(() => { 197 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 + ); 198 237 }); 199 238 it("shows Primary badge for email", async () => { 200 239 mockEndpoint( ··· 250 289 describe("save preferences", () => { 251 290 beforeEach(() => { 252 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 + ); 253 300 }); 254 301 it("calls updateNotificationPrefs with correct data", async () => { 255 302 let capturedBody: Record<string, unknown> | null = null; ··· 266 313 ); 267 314 render(Comms); 268 315 await waitFor(() => { 269 - expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument(); 316 + expect(screen.getByLabelText(/discord.*id/i)).toBeInTheDocument(); 270 317 }); 271 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { 318 + await fireEvent.input(screen.getByLabelText(/discord.*id/i), { 272 319 target: { value: "999888777" }, 273 320 }); 274 321 await fireEvent.click( ··· 319 366 screen.getByRole("button", { name: /save preferences/i }), 320 367 ); 321 368 await waitFor(() => { 322 - expect(screen.getByText(/notification preferences saved/i)) 369 + expect(screen.getByText(/preferences saved/i)) 323 370 .toBeInTheDocument(); 324 371 }); 325 372 }); ··· 378 425 describe("channel selection interaction", () => { 379 426 beforeEach(() => { 380 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 + ); 381 436 }); 382 437 it("enables discord channel after entering discord ID", async () => { 383 438 mockEndpoint( ··· 388 443 await waitFor(() => { 389 444 expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled(); 390 445 }); 391 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { 446 + await fireEvent.input(screen.getByLabelText(/discord.*id/i), { 392 447 target: { value: "123456789" }, 393 448 }); 394 449 await waitFor(() => { ··· 420 475 describe("error handling", () => { 421 476 beforeEach(() => { 422 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 + ); 423 486 }); 424 487 it("shows error when loading preferences fails", async () => { 425 488 mockEndpoint(
+27 -5
frontend/src/tests/Dashboard.test.ts
··· 21 21 setupUnauthenticatedUser(); 22 22 render(Dashboard); 23 23 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 24 + expect(globalThis.location.hash).toBe("#/login"); 25 25 }); 26 26 }); 27 27 it("shows loading state while checking auth", () => { ··· 40 40 .toBeInTheDocument(); 41 41 expect(screen.getByRole("heading", { name: /account overview/i })) 42 42 .toBeInTheDocument(); 43 - expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)) 44 - .toBeInTheDocument(); 43 + expect(screen.getAllByText(/@testuser\.test\.tranquil\.dev/).length) 44 + .toBeGreaterThan(0); 45 45 expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)) 46 46 .toBeInTheDocument(); 47 47 expect(screen.getByText("test@example.com")).toBeInTheDocument(); ··· 62 62 await waitFor(() => { 63 63 const navCards = [ 64 64 { name: /app passwords/i, href: "#/app-passwords" }, 65 - { name: /invite codes/i, href: "#/invite-codes" }, 66 65 { name: /account settings/i, href: "#/settings" }, 67 66 { name: /communication preferences/i, href: "#/comms" }, 68 67 { name: /repository explorer/i, href: "#/repo" }, ··· 74 73 } 75 74 }); 76 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 + }); 77 89 }); 78 90 describe("logout functionality", () => { 79 91 beforeEach(() => { ··· 89 101 }); 90 102 render(Dashboard); 91 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(() => { 92 109 expect(screen.getByRole("button", { name: /sign out/i })) 93 110 .toBeInTheDocument(); 94 111 }); 95 112 await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 96 113 await waitFor(() => { 97 114 expect(deleteSessionCalled).toBe(true); 98 - expect(window.location.hash).toBe("#/login"); 115 + expect(globalThis.location.hash).toBe("#/login"); 99 116 }); 100 117 }); 101 118 it("clears session from localStorage after logout", async () => { 102 119 const storedSession = localStorage.getItem(STORAGE_KEY); 103 120 expect(storedSession).not.toBeNull(); 104 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 })); 105 127 await waitFor(() => { 106 128 expect(screen.getByRole("button", { name: /sign out/i })) 107 129 .toBeInTheDocument();
+132 -132
frontend/src/tests/Login.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 3 import Login from "../routes/Login.svelte"; 4 4 import { 5 5 clearMocks, 6 - errorResponse, 7 6 jsonResponse, 8 7 mockData, 9 8 mockEndpoint, 10 9 setupFetchMock, 11 10 } from "./mocks"; 11 + import { _testSetState, type SavedAccount } from "../lib/auth.svelte"; 12 + 12 13 describe("Login", () => { 13 14 beforeEach(() => { 14 15 clearMocks(); 15 16 setupFetchMock(); 16 - window.location.hash = ""; 17 + globalThis.location.hash = ""; 18 + mockEndpoint("/oauth/par", () => 19 + jsonResponse({ request_uri: "urn:mock:request" }) 20 + ); 17 21 }); 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" }, 22 + 23 + describe("initial render with no saved accounts", () => { 24 + beforeEach(() => { 25 + _testSetState({ 26 + session: null, 27 + loading: false, 28 + error: null, 29 + savedAccounts: [], 46 30 }); 47 - expect(submitButton).toBeDisabled(); 48 - await fireEvent.input(identifierInput, { target: { value: "testuser" } }); 49 - expect(submitButton).not.toBeDisabled(); 50 31 }); 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 - }); 32 + 33 + it("renders login page with title and OAuth button", async () => { 59 34 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 35 await waitFor(() => { 68 - expect(capturedBody).toEqual({ 69 - identifier: "testuser@example.com", 70 - password: "mypassword", 71 - }); 36 + expect(screen.getByRole("heading", { name: /sign in/i })) 37 + .toBeInTheDocument(); 38 + expect(screen.getByRole("button", { name: /sign in/i })) 39 + .toBeInTheDocument(); 72 40 }); 73 41 }); 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 - ); 42 + 43 + it("shows create account link", async () => { 84 44 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 45 await waitFor(() => { 93 - const errorDiv = screen.getByText(/invalid identifier or password/i); 94 - expect(errorDiv).toBeInTheDocument(); 95 - expect(errorDiv).toHaveClass("error"); 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 + ); 96 51 }); 97 52 }); 98 - it("navigates to dashboard on successful login", async () => { 99 - mockEndpoint( 100 - "com.atproto.server.createSession", 101 - () => jsonResponse(mockData.session()), 102 - ); 53 + 54 + it("shows forgot password and lost passkey links", async () => { 103 55 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 56 await waitFor(() => { 112 - expect(window.location.hash).toBe("#/dashboard"); 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"); 113 61 }); 114 62 }); 115 63 }); 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" }, 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, 133 87 }); 134 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 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); 135 94 await waitFor(() => { 136 - expect(screen.getByRole("heading", { name: /verify your account/i })) 95 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 137 96 .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 })) 97 + expect(screen.getByText(/@bob\.test\.tranquil\.dev/)) 142 98 .toBeInTheDocument(); 143 99 }); 144 100 }); 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 - })); 101 + 102 + it("shows sign in to another account option", async () => { 155 103 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" }, 104 + await waitFor(() => { 105 + expect(screen.getByText(/sign in to another/i)).toBeInTheDocument(); 161 106 }); 162 - await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 107 + }); 108 + 109 + it("can click on saved account to switch", async () => { 110 + render(Login); 163 111 await waitFor(() => { 164 - expect(screen.getByRole("button", { name: /back to login/i })) 112 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 165 113 .toBeInTheDocument(); 166 114 }); 167 - await fireEvent.click( 168 - screen.getByRole("button", { name: /back to login/i }), 169 - ); 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); 170 127 await waitFor(() => { 171 - expect(screen.getByRole("heading", { name: /sign in/i })) 172 - .toBeInTheDocument(); 173 - expect(screen.queryByLabelText(/verification code/i)).not 128 + expect(screen.getByText(/@alice\.test\.tranquil\.dev/)) 174 129 .toBeInTheDocument(); 130 + const forgetButtons = screen.getAllByTitle(/remove/i); 131 + expect(forgetButtons.length).toBe(2); 175 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 176 }); 177 177 }); 178 178 });
+82 -71
frontend/src/tests/Settings.test.ts
··· 5 5 clearMocks, 6 6 errorResponse, 7 7 jsonResponse, 8 + mockData, 8 9 mockEndpoint, 9 10 setupAuthenticatedUser, 10 11 setupFetchMock, ··· 14 15 beforeEach(() => { 15 16 clearMocks(); 16 17 setupFetchMock(); 17 - window.confirm = vi.fn(() => true); 18 + globalThis.confirm = vi.fn(() => true); 18 19 }); 19 20 describe("authentication guard", () => { 20 21 it("redirects to login when not authenticated", async () => { 21 22 setupUnauthenticatedUser(); 22 23 render(Settings); 23 24 await waitFor(() => { 24 - expect(window.location.hash).toBe("#/login"); 25 + expect(globalThis.location.hash).toBe("#/login"); 25 26 }); 26 27 }); 27 28 }); ··· 50 51 beforeEach(() => { 51 52 setupAuthenticatedUser(); 52 53 }); 53 - it("displays current email and input field", async () => { 54 + it("displays current email and change button", async () => { 54 55 render(Settings); 55 56 await waitFor(() => { 56 - expect(screen.getByText(/current: test@example.com/i)) 57 + expect(screen.getByText(/current.*test@example.com/i)) 57 58 .toBeInTheDocument(); 58 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 59 + expect(screen.getByRole("button", { name: /change email/i })) 60 + .toBeInTheDocument(); 59 61 }); 60 62 }); 61 - it("calls requestEmailUpdate when submitting", async () => { 63 + it("calls requestEmailUpdate when clicking change email button", async () => { 62 64 let requestCalled = false; 63 65 mockEndpoint("com.atproto.server.requestEmailUpdate", () => { 64 66 requestCalled = true; ··· 66 68 }); 67 69 render(Settings); 68 70 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" }, 71 + expect(screen.getByRole("button", { name: /change email/i })) 72 + .toBeInTheDocument(); 73 73 }); 74 74 await fireEvent.click( 75 75 screen.getByRole("button", { name: /change email/i }), ··· 78 78 expect(requestCalled).toBe(true); 79 79 }); 80 80 }); 81 - it("shows verification code input when token is required", async () => { 81 + it("shows verification code and new email inputs when token is required", async () => { 82 82 mockEndpoint( 83 83 "com.atproto.server.requestEmailUpdate", 84 84 () => jsonResponse({ tokenRequired: true }), 85 85 ); 86 86 render(Settings); 87 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" }, 88 + expect(screen.getByRole("button", { name: /change email/i })) 89 + .toBeInTheDocument(); 92 90 }); 93 91 await fireEvent.click( 94 92 screen.getByRole("button", { name: /change email/i }), 95 93 ); 96 94 await waitFor(() => { 97 95 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 96 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 98 97 expect(screen.getByRole("button", { name: /confirm email change/i })) 99 98 .toBeInTheDocument(); 100 99 }); ··· 111 110 capturedBody = JSON.parse((options?.body as string) || "{}"); 112 111 return jsonResponse({}); 113 112 }); 113 + mockEndpoint("com.atproto.server.getSession", () => 114 + jsonResponse(mockData.session())); 114 115 render(Settings); 115 116 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" }, 117 + expect(screen.getByRole("button", { name: /change email/i })) 118 + .toBeInTheDocument(); 120 119 }); 121 120 await fireEvent.click( 122 121 screen.getByRole("button", { name: /change email/i }), ··· 127 126 await fireEvent.input(screen.getByLabelText(/verification code/i), { 128 127 target: { value: "123456" }, 129 128 }); 129 + await fireEvent.input(screen.getByLabelText(/new email/i), { 130 + target: { value: "newemail@example.com" }, 131 + }); 130 132 await fireEvent.click( 131 133 screen.getByRole("button", { name: /confirm email change/i }), 132 134 ); ··· 142 144 () => jsonResponse({ tokenRequired: true }), 143 145 ); 144 146 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 147 + mockEndpoint("com.atproto.server.getSession", () => 148 + jsonResponse(mockData.session())); 145 149 render(Settings); 146 150 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 + expect(screen.getByRole("button", { name: /change email/i })) 152 + .toBeInTheDocument(); 151 153 }); 152 154 await fireEvent.click( 153 155 screen.getByRole("button", { name: /change email/i }), ··· 158 160 await fireEvent.input(screen.getByLabelText(/verification code/i), { 159 161 target: { value: "123456" }, 160 162 }); 163 + await fireEvent.input(screen.getByLabelText(/new email/i), { 164 + target: { value: "new@test.com" }, 165 + }); 161 166 await fireEvent.click( 162 167 screen.getByRole("button", { name: /confirm email change/i }), 163 168 ); 164 169 await waitFor(() => { 165 - expect(screen.getByText(/email updated successfully/i)) 170 + expect(screen.getByText(/email updated/i)) 166 171 .toBeInTheDocument(); 167 172 }); 168 173 }); 169 - it("shows cancel button to return to email form", async () => { 174 + it("shows cancel button to return to initial state", async () => { 170 175 mockEndpoint( 171 176 "com.atproto.server.requestEmailUpdate", 172 177 () => jsonResponse({ tokenRequired: true }), 173 178 ); 174 179 render(Settings); 175 180 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" }, 181 + expect(screen.getByRole("button", { name: /change email/i })) 182 + .toBeInTheDocument(); 180 183 }); 181 184 await fireEvent.click( 182 185 screen.getByRole("button", { name: /change email/i }), ··· 185 188 expect(screen.getByRole("button", { name: /cancel/i })) 186 189 .toBeInTheDocument(); 187 190 }); 188 - await fireEvent.click(screen.getByRole("button", { name: /cancel/i })); 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 + } 189 197 await waitFor(() => { 190 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 191 198 expect(screen.queryByLabelText(/verification code/i)).not 192 199 .toBeInTheDocument(); 193 200 }); 194 201 }); 195 - it("shows error when email update fails", async () => { 202 + it("shows error when request fails", async () => { 196 203 mockEndpoint( 197 204 "com.atproto.server.requestEmailUpdate", 198 205 () => errorResponse("InvalidEmail", "Invalid email format", 400), 199 206 ); 200 207 render(Settings); 201 208 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(); 209 + expect(screen.getByRole("button", { name: /change email/i })) 210 + .toBeInTheDocument(); 210 211 }); 211 212 await fireEvent.click( 212 213 screen.getByRole("button", { name: /change email/i }), ··· 219 220 describe("handle change", () => { 220 221 beforeEach(() => { 221 222 setupAuthenticatedUser(); 223 + mockEndpoint("com.atproto.server.describeServer", () => 224 + jsonResponse(mockData.describeServer())); 222 225 }); 223 226 it("displays current handle", async () => { 224 227 render(Settings); 225 228 await waitFor(() => { 226 - expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)) 229 + expect(screen.getByText(/current.*@testuser\.test\.tranquil\.dev/i)) 227 230 .toBeInTheDocument(); 228 231 }); 229 232 }); 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({}); 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(); 236 240 }); 241 + }); 242 + it("allows entering handle and shows domain suffix", async () => { 237 243 render(Settings); 238 244 await waitFor(() => { 239 245 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 246 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 240 247 }); 241 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 242 - target: { value: "newhandle.bsky.social" }, 248 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 249 + await fireEvent.input(input, { 250 + target: { value: "newhandle" }, 243 251 }); 244 - await fireEvent.click( 245 - screen.getByRole("button", { name: /change handle/i }), 246 - ); 247 - await waitFor(() => { 248 - expect(capturedHandle).toBe("newhandle.bsky.social"); 249 - }); 252 + expect(input.value).toBe("newhandle"); 253 + expect(screen.getByRole("button", { name: /change handle/i })) 254 + .toBeInTheDocument(); 250 255 }); 251 256 it("shows success message after handle change", async () => { 252 257 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 258 + mockEndpoint("com.atproto.server.getSession", () => 259 + jsonResponse(mockData.session())); 253 260 render(Settings); 254 261 await waitFor(() => { 255 262 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 263 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 256 264 }); 257 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 265 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 266 + await fireEvent.input(input, { 258 267 target: { value: "newhandle" }, 259 268 }); 260 - await fireEvent.click( 261 - screen.getByRole("button", { name: /change handle/i }), 262 - ); 269 + const button = screen.getByRole("button", { name: /change handle/i }); 270 + await fireEvent.submit(button.closest("form")!); 263 271 await waitFor(() => { 264 - expect(screen.getByText(/handle updated successfully/i)) 272 + expect(screen.getByText(/handle updated/i)) 265 273 .toBeInTheDocument(); 266 274 }); 267 275 }); ··· 274 282 render(Settings); 275 283 await waitFor(() => { 276 284 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 285 + expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument(); 277 286 }); 278 - await fireEvent.input(screen.getByLabelText(/new handle/i), { 287 + const input = screen.getByLabelText(/new handle/i) as HTMLInputElement; 288 + await fireEvent.input(input, { 279 289 target: { value: "taken" }, 280 290 }); 281 - await fireEvent.click( 282 - screen.getByRole("button", { name: /change handle/i }), 283 - ); 291 + expect(input.value).toBe("taken"); 292 + const button = screen.getByRole("button", { name: /change handle/i }); 293 + await fireEvent.submit(button.closest("form")!); 284 294 await waitFor(() => { 285 - expect(screen.getByText(/handle is already taken/i)) 286 - .toBeInTheDocument(); 295 + const errorMessage = screen.queryByText(/handle is already taken/i) || 296 + screen.queryByText(/handle update failed/i); 297 + expect(errorMessage).toBeInTheDocument(); 287 298 }); 288 299 }); 289 300 }); ··· 345 356 }); 346 357 it("shows confirmation dialog before final deletion", async () => { 347 358 const confirmSpy = vi.fn(() => false); 348 - window.confirm = confirmSpy; 359 + globalThis.confirm = confirmSpy; 349 360 mockEndpoint( 350 361 "com.atproto.server.requestAccountDelete", 351 362 () => jsonResponse({}), ··· 376 387 ); 377 388 }); 378 389 it("calls deleteAccount with correct parameters", async () => { 379 - window.confirm = vi.fn(() => true); 390 + globalThis.confirm = vi.fn(() => true); 380 391 let capturedBody: Record<string, string> | null = null; 381 392 mockEndpoint( 382 393 "com.atproto.server.requestAccountDelete", ··· 414 425 }); 415 426 }); 416 427 it("navigates to login after successful deletion", async () => { 417 - window.confirm = vi.fn(() => true); 428 + globalThis.confirm = vi.fn(() => true); 418 429 mockEndpoint( 419 430 "com.atproto.server.requestAccountDelete", 420 431 () => jsonResponse({}), ··· 442 453 screen.getByRole("button", { name: /permanently delete account/i }), 443 454 ); 444 455 await waitFor(() => { 445 - expect(window.location.hash).toBe("#/login"); 456 + expect(globalThis.location.hash).toBe("#/login"); 446 457 }); 447 458 }); 448 459 it("shows cancel button to return to request state", async () => { ··· 480 491 }); 481 492 }); 482 493 it("shows error when deletion fails", async () => { 483 - window.confirm = vi.fn(() => true); 494 + globalThis.confirm = vi.fn(() => true); 484 495 mockEndpoint( 485 496 "com.atproto.server.requestAccountDelete", 486 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 29 return match ? match[1] : url; 30 30 } 31 31 export function setupFetchMock(): void { 32 - global.fetch = vi.fn( 32 + globalThis.fetch = vi.fn( 33 33 async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { 34 34 const url = typeof input === "string" ? input : input.toString(); 35 35 const endpoint = extractEndpoint(url); ··· 137 137 signalVerified: false, 138 138 ...overrides, 139 139 }), 140 - describeServer: () => ({ 140 + describeServer: (overrides?: Record<string, unknown>) => ({ 141 141 availableUserDomains: ["test.tranquil.dev"], 142 142 inviteCodeRequired: false, 143 143 links: { ··· 145 145 termsOfService: "https://example.com/tos", 146 146 }, 147 147 selfHostedDidWebEnabled: true, 148 + availableCommsChannels: ["email", "discord", "telegram", "signal"], 149 + ...overrides, 148 150 }), 149 151 describeRepo: (did: string) => ({ 150 152 handle: "testuser.test.tranquil.dev", ··· 210 212 mockEndpoint( 211 213 "com.tranquil.account.updateNotificationPrefs", 212 214 () => jsonResponse({ success: true }), 215 + ); 216 + mockEndpoint( 217 + "com.tranquil.account.getNotificationHistory", 218 + () => jsonResponse({ notifications: [] }), 213 219 ); 214 220 mockEndpoint( 215 221 "com.atproto.server.requestEmailUpdate",
+12 -5
frontend/src/tests/setup.ts
··· 1 1 import "@testing-library/jest-dom/vitest"; 2 2 import { afterEach, beforeEach, vi } from "vitest"; 3 - import { _testReset } from "../lib/auth.svelte"; 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 + }); 4 12 5 13 let locationHash = ""; 6 14 ··· 24 32 configurable: true, 25 33 }); 26 34 27 - beforeEach(() => { 35 + beforeEach(async () => { 28 36 vi.clearAllMocks(); 29 - localStorage.clear(); 30 - sessionStorage.clear(); 31 37 locationHash = ""; 32 - _testReset(); 38 + _testResetState(); 39 + await waitLocale(); 33 40 }); 34 41 35 42 afterEach(() => {
+1
frontend/svelte.config.js
··· 1 + import process from "node:process"; 1 2 import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 3 const isTest = process.env.VITEST === "true" || process.env.VITEST === true; 3 4 export default {
+1
frontend/vite.config.ts
··· 1 + import process from "node:process"; 1 2 import { defineConfig, loadEnv } from "vite"; 2 3 import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 4
+27 -28
src/api/actor/preferences.rs
··· 108 108 serde_json::from_value(row.value_json).ok() 109 109 }) 110 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 - } 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); 123 124 } 124 125 (StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response() 125 126 } ··· 157 158 } 158 159 }; 159 160 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 - }; 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 + }; 176 175 if input.preferences.len() > MAX_PREFERENCES_COUNT { 177 176 return ( 178 177 StatusCode::BAD_REQUEST,
+1 -4
src/api/admin/account/info.rs
··· 125 125 } 126 126 } 127 127 128 - async fn get_invited_by( 129 - db: &sqlx::PgPool, 130 - user_id: uuid::Uuid, 131 - ) -> Option<InviteCodeInfo> { 128 + async fn get_invited_by(db: &sqlx::PgPool, user_id: uuid::Uuid) -> Option<InviteCodeInfo> { 132 129 let use_row = sqlx::query!( 133 130 r#" 134 131 SELECT icu.code
+9 -1
src/api/admin/account/search.rs
··· 91 91 .into_iter() 92 92 .take(limit as usize) 93 93 .map( 94 - |(did, handle, email, created_at, email_verified, deactivated_at, invites_disabled)| { 94 + |( 95 + did, 96 + handle, 97 + email, 98 + created_at, 99 + email_verified, 100 + deactivated_at, 101 + invites_disabled, 102 + )| { 95 103 AccountView { 96 104 did: did.clone(), 97 105 handle,
+4 -1
src/api/admin/account/update.rs
··· 131 131 if let Err(e) = 132 132 crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await 133 133 { 134 - warn!("Failed to sequence identity event for admin handle update: {}", e); 134 + warn!( 135 + "Failed to sequence identity event for admin handle update: {}", 136 + e 137 + ); 135 138 } 136 139 if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await 137 140 {
+8 -3
src/api/identity/account.rs
··· 1005 1005 { 1006 1006 warn!("Failed to sequence account event for {}: {}", did, e); 1007 1007 } 1008 - if let Err(e) = 1009 - crate::api::repo::record::sequence_genesis_commit(&state, &did, &commit_cid, &mst_root, &rev_str).await 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 1010 1016 { 1011 1017 warn!("Failed to sequence commit event for {}: {}", did, e); 1012 1018 } ··· 1144 1150 ) 1145 1151 .into_response() 1146 1152 } 1147 -
+74 -74
src/api/identity/did.rs
··· 191 191 192 192 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 193 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 - }; 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 + }; 204 205 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 - } 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(); 228 227 } 229 228 230 229 let key_row = sqlx::query!( ··· 351 350 352 351 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 353 352 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 - }; 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 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 - } 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(); 388 386 } 389 387 390 388 let key_row = sqlx::query!( ··· 637 635 let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") { 638 636 Ok(key) => key, 639 637 Err(_) => { 640 - warn!("PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"); 638 + warn!( 639 + "PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation" 640 + ); 641 641 did_key.clone() 642 642 } 643 643 }; ··· 709 709 ) 710 710 .into_response(); 711 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 712 + let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 713 + .fetch_optional(&state.db) 714 + .await 718 715 { 719 716 Ok(Some(row)) => row, 720 717 _ => return ApiError::InternalError.into_response(), ··· 879 876 match result { 880 877 Ok(_) => { 881 878 if !current_handle.is_empty() { 882 - let _ = state.cache.delete(&format!("handle:{}", current_handle)).await; 879 + let _ = state 880 + .cache 881 + .delete(&format!("handle:{}", current_handle)) 882 + .await; 883 883 } 884 884 let _ = state.cache.delete(&format!("handle:{}", handle)).await; 885 885 if let Err(e) =
+18 -20
src/api/identity/plc/submit.rs
··· 58 58 let op = &input.operation; 59 59 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 60 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 61 + let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) 62 + .fetch_optional(&state.db) 63 + .await 67 64 { 68 65 Ok(Some(row)) => row, 69 66 _ => { ··· 170 167 ) 171 168 .into_response(); 172 169 } 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 - } 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(); 187 185 } 188 186 } 189 187 let plc_client = PlcClient::new(None);
+7 -13
src/api/moderation/mod.rs
··· 51 51 None => return ApiError::AuthenticationRequired.into_response(), 52 52 }; 53 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 - }; 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 59 60 60 let did = &auth_user.did; 61 61 62 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; 63 + return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input) 64 + .await; 71 65 } 72 66 73 67 create_report_locally(&state, did, auth_user.is_takendown, input).await
+4 -1
src/api/repo/import.rs
··· 346 346 } 347 347 } 348 348 if blob_ref_count > 0 { 349 - info!("Recorded {} blob references for imported repo", blob_ref_count); 349 + info!( 350 + "Recorded {} blob references for imported repo", 351 + blob_ref_count 352 + ); 350 353 } 351 354 let key_row = match sqlx::query!( 352 355 r#"SELECT uk.key_bytes, uk.encryption_version
+1 -1
src/api/repo/record/batch.rs
··· 1 1 use super::validation::validate_record_with_status; 2 2 use super::write::has_verified_comms_channel; 3 - use crate::validation::ValidationStatus; 4 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 5 4 use crate::delegation::{self, DelegationActionType}; 6 5 use crate::repo::tracking::TrackingBlockStore; 7 6 use crate::state::AppState; 7 + use crate::validation::ValidationStatus; 8 8 use axum::{ 9 9 Json, 10 10 extract::State,
+1 -5
src/api/repo/record/delete.rs
··· 127 127 } 128 128 let prev_record_cid = mst.get(&key).await.ok().flatten(); 129 129 if prev_record_cid.is_none() { 130 - return ( 131 - StatusCode::OK, 132 - Json(DeleteRecordOutput { commit: None }), 133 - ) 134 - .into_response(); 130 + return (StatusCode::OK, Json(DeleteRecordOutput { commit: None })).into_response(); 135 131 } 136 132 let new_mst = match mst.delete(&key).await { 137 133 Ok(m) => m,
+1 -1
src/api/repo/record/write.rs
··· 1 1 use super::validation::validate_record_with_status; 2 - use crate::validation::ValidationStatus; 3 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 3 use crate::delegation::{self, DelegationActionType}; 5 4 use crate::repo::tracking::TrackingBlockStore; 6 5 use crate::state::AppState; 6 + use crate::validation::ValidationStatus; 7 7 use axum::{ 8 8 Json, 9 9 extract::State,
+11 -12
src/api/server/account_status.rs
··· 92 92 Ok(Some(row)) => (row.repo_root_cid, row.repo_rev), 93 93 _ => (String::new(), None), 94 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); 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); 101 103 let repo_rev = if let Some(rev) = repo_rev_from_db { 102 104 rev 103 105 } else if !repo_commit.is_empty() { ··· 241 243 let rotation_keys = doc_data 242 244 .get("rotationKeys") 243 245 .and_then(|v| v.as_array()) 244 - .map(|arr| { 245 - arr.iter() 246 - .filter_map(|k| k.as_str()) 247 - .collect::<Vec<_>>() 248 - }) 246 + .map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>()) 249 247 .unwrap_or_default(); 250 248 if !rotation_keys.contains(&expected_rotation_key.as_str()) { 251 249 return Err(( ··· 440 438 did 441 439 ); 442 440 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 { 441 + if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await 442 + { 444 443 info!( 445 444 "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", 446 445 did,
+10 -12
src/api/server/email.rs
··· 86 86 "email_update", 87 87 &current_email.to_lowercase(), 88 88 ); 89 - let formatted_code = 90 - crate::auth::verification_token::format_token_for_display(&code); 89 + let formatted_code = crate::auth::verification_token::format_token_for_display(&code); 91 90 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 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 101 95 { 102 96 warn!("Failed to enqueue email update notification: {:?}", e); 103 97 } 104 98 } 105 99 106 100 info!("Email update requested for user {}", user.id); 107 - (StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response() 101 + ( 102 + StatusCode::OK, 103 + Json(json!({ "tokenRequired": token_required })), 104 + ) 105 + .into_response() 108 106 } 109 107 110 108 #[derive(Deserialize)]
+16 -17
src/api/server/invite.rs
··· 1 1 use crate::api::ApiError; 2 - use crate::auth::extractor::BearerAuthAdmin; 3 2 use crate::auth::BearerAuth; 3 + use crate::auth::extractor::BearerAuthAdmin; 4 4 use crate::state::AppState; 5 5 use axum::{ 6 6 Json, ··· 114 114 .filter(|v| !v.is_empty()) 115 115 .unwrap_or_else(|| vec![auth_user.did.clone()]); 116 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 - }; 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 + }; 133 132 134 133 let mut result_codes = Vec::new(); 135 134
+42 -49
src/api/server/migration.rs
··· 332 332 333 333 if let Some(ref methods) = input.verification_methods { 334 334 if methods.is_empty() { 335 - return ApiError::InvalidRequest( 336 - "verification_methods cannot be empty".into(), 337 - ) 338 - .into_response(); 335 + return ApiError::InvalidRequest("verification_methods cannot be empty".into()) 336 + .into_response(); 339 337 } 340 338 for method in methods { 341 339 if method.id.is_empty() { ··· 366 364 if let Some(ref handles) = input.also_known_as { 367 365 for handle in handles { 368 366 if !handle.starts_with("at://") { 369 - return ApiError::InvalidRequest( 370 - "alsoKnownAs entries must be at:// URIs".into(), 371 - ) 372 - .into_response(); 367 + return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) 368 + .into_response(); 373 369 } 374 370 } 375 371 } ··· 377 373 if let Some(ref endpoint) = input.service_endpoint { 378 374 let endpoint = endpoint.trim(); 379 375 if !endpoint.starts_with("https://") { 380 - return ApiError::InvalidRequest( 381 - "serviceEndpoint must start with https://".into(), 382 - ) 383 - .into_response(); 376 + return ApiError::InvalidRequest("serviceEndpoint must start with https://".into()) 377 + .into_response(); 384 378 } 385 379 } 386 380 ··· 523 517 .migrated_to_pds 524 518 .unwrap_or_else(|| format!("https://{}", hostname)); 525 519 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 - } 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 + }); 556 551 } 557 552 558 553 let key_row = sqlx::query!( ··· 563 558 .await; 564 559 565 560 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 - } 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 + }, 573 566 _ => "error".to_string(), 574 567 }; 575 568
+104 -30
src/api/server/passkey_account.rs
··· 84 84 pub handle: String, 85 85 pub setup_token: String, 86 86 pub setup_expires_at: chrono::DateTime<Utc>, 87 + #[serde(skip_serializing_if = "Option::is_none")] 88 + pub access_jwt: Option<String>, 87 89 } 88 90 89 91 pub async fn create_passkey_account( ··· 378 380 d.to_string() 379 381 } 380 382 _ => { 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); 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 { 393 409 return ( 394 - StatusCode::INTERNAL_SERVER_ERROR, 395 - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), 410 + StatusCode::BAD_REQUEST, 411 + Json(json!({ 412 + "error": "InvalidRequest", 413 + "message": "BYOD migration requires the 'did' field" 414 + })), 396 415 ) 397 416 .into_response(); 398 417 } 399 - }; 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)); 400 421 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(); 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 415 455 } 416 - genesis_result.did 417 456 } 418 457 }; 419 458 ··· 726 765 727 766 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); 728 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 + 729 802 Json(CreatePasskeyAccountResponse { 730 803 did, 731 804 handle, 732 805 setup_token, 733 806 setup_expires_at, 807 + access_jwt, 734 808 }) 735 809 .into_response() 736 810 }
+3 -6
src/api/server/session.rs
··· 334 334 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 335 335 let handle = full_handle(&row.handle, &pds_hostname); 336 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(); 337 + let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some(); 339 338 let is_active = row.deactivated_at.is_none() && !is_takendown; 340 339 let email_value = if can_read_email { 341 340 row.email.clone() ··· 368 367 if let Some(doc) = did_doc { 369 368 response["didDoc"] = doc; 370 369 } 371 - Json(response) 372 - .into_response() 370 + Json(response).into_response() 373 371 } 374 372 Ok(None) => ApiError::AuthenticationFailed.into_response(), 375 373 Err(e) => { ··· 613 611 } else if u.deactivated_at.is_some() { 614 612 response["status"] = json!("deactivated"); 615 613 } 616 - Json(response) 617 - .into_response() 614 + Json(response).into_response() 618 615 } 619 616 Ok(None) => { 620 617 error!("User not found for existing session: {}", session_row.did);
+2 -14
src/handle/reserved.rs
··· 2 2 use std::sync::LazyLock; 3 3 4 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", 5 + "at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky", 6 + "bluesky", "handle", 19 7 ]; 20 8 21 9 const COMMONLY_RESERVED: &[&str] = &[
+4 -1
src/main.rs
··· 5 5 use tracing::{error, info, warn}; 6 6 use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; 7 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}; 8 + use tranquil_pds::scheduled::{ 9 + backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 + start_scheduled_tasks, 11 + }; 9 12 use tranquil_pds::state::AppState; 10 13 11 14 #[tokio::main]
+5 -2
src/oauth/endpoints/metadata.rs
··· 167 167 client_id, 168 168 client_name: "PDS Account Manager".to_string(), 169 169 client_uri: base_url.clone(), 170 - redirect_uris: vec![format!("{}/", base_url)], 170 + redirect_uris: vec![ 171 + format!("{}/", base_url), 172 + format!("{}/migrate", base_url), 173 + ], 171 174 grant_types: vec![ 172 175 "authorization_code".to_string(), 173 176 "refresh_token".to_string(), 174 177 ], 175 178 response_types: vec!["code".to_string()], 176 - scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*" 179 + scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*" 177 180 .to_string(), 178 181 token_endpoint_auth_method: "none".to_string(), 179 182 application_type: "web".to_string(),
+33 -39
src/scheduled.rs
··· 1 1 use cid::Cid; 2 + use ipld_core::ipld::Ipld; 2 3 use jacquard_repo::commit::Commit; 3 4 use jacquard_repo::storage::BlockStore; 4 - use ipld_core::ipld::Ipld; 5 5 use sqlx::PgPool; 6 6 use std::str::FromStr; 7 7 use std::sync::Arc; ··· 107 107 } 108 108 } 109 109 110 - info!(success, failed, "Completed genesis commit blocks_cids backfill"); 110 + info!( 111 + success, 112 + failed, "Completed genesis commit blocks_cids backfill" 113 + ); 111 114 } 112 115 113 116 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 - }; 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 + }; 126 128 127 129 if repos_missing_rev.is_empty() { 128 130 debug!("No repos need repo_rev backfill"); ··· 244 246 if let Some(prev) = commit.prev { 245 247 to_visit.push(prev); 246 248 } 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 - } 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 261 } 262 262 } 263 263 } ··· 361 361 362 362 let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0); 363 363 for blob_ref in blob_refs { 364 - let record_uri = format!( 365 - "at://{}/{}/{}", 366 - user.did, record.collection, record.rkey 367 - ); 364 + let record_uri = format!("at://{}/{}/{}", user.did, record.collection, record.rkey); 368 365 if let Err(e) = sqlx::query!( 369 366 r#" 370 367 INSERT INTO record_blobs (repo_id, record_uri, blob_cid) ··· 490 487 did: &str, 491 488 _handle: &str, 492 489 ) -> 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))?; 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))?; 500 494 501 495 let blob_storage_keys: Vec<String> = sqlx::query_scalar!( 502 496 r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
+101 -28
tests/account_lifecycle.rs
··· 11 11 let (access_jwt, did) = create_account_and_login(&client).await; 12 12 13 13 let status1 = client 14 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 14 + .get(format!( 15 + "{}/xrpc/com.atproto.server.checkAccountStatus", 16 + base 17 + )) 15 18 .bearer_auth(&access_jwt) 16 19 .send() 17 20 .await ··· 19 22 assert_eq!(status1.status(), StatusCode::OK); 20 23 let body1: Value = status1.json().await.unwrap(); 21 24 let initial_blocks = body1["repoBlocks"].as_i64().unwrap(); 22 - assert!(initial_blocks >= 2, "New account should have at least 2 blocks (commit + empty MST)"); 25 + assert!( 26 + initial_blocks >= 2, 27 + "New account should have at least 2 blocks (commit + empty MST)" 28 + ); 23 29 24 30 let create_res = client 25 31 .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) ··· 38 44 .unwrap(); 39 45 assert_eq!(create_res.status(), StatusCode::OK); 40 46 let create_body: Value = create_res.json().await.unwrap(); 41 - let rkey = create_body["uri"].as_str().unwrap().split('/').last().unwrap().to_string(); 47 + let rkey = create_body["uri"] 48 + .as_str() 49 + .unwrap() 50 + .split('/') 51 + .last() 52 + .unwrap() 53 + .to_string(); 42 54 43 55 let status2 = client 44 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 56 + .get(format!( 57 + "{}/xrpc/com.atproto.server.checkAccountStatus", 58 + base 59 + )) 45 60 .bearer_auth(&access_jwt) 46 61 .send() 47 62 .await 48 63 .unwrap(); 49 64 let body2: Value = status2.json().await.unwrap(); 50 65 let after_create_blocks = body2["repoBlocks"].as_i64().unwrap(); 51 - assert!(after_create_blocks > initial_blocks, "Block count should increase after creating a record"); 66 + assert!( 67 + after_create_blocks > initial_blocks, 68 + "Block count should increase after creating a record" 69 + ); 52 70 53 71 let delete_res = client 54 72 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) ··· 64 82 assert_eq!(delete_res.status(), StatusCode::OK); 65 83 66 84 let status3 = client 67 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 85 + .get(format!( 86 + "{}/xrpc/com.atproto.server.checkAccountStatus", 87 + base 88 + )) 68 89 .bearer_auth(&access_jwt) 69 90 .send() 70 91 .await ··· 86 107 let (access_jwt, _) = create_account_and_login(&client).await; 87 108 88 109 let status = client 89 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 110 + .get(format!( 111 + "{}/xrpc/com.atproto.server.checkAccountStatus", 112 + base 113 + )) 90 114 .bearer_auth(&access_jwt) 91 115 .send() 92 116 .await ··· 96 120 97 121 let repo_rev = body["repoRev"].as_str().unwrap(); 98 122 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"); 123 + assert!( 124 + repo_rev.chars().all(|c| c.is_alphanumeric()), 125 + "repoRev should be alphanumeric TID" 126 + ); 100 127 } 101 128 102 129 #[tokio::test] ··· 106 133 let (access_jwt, _) = create_account_and_login(&client).await; 107 134 108 135 let status = client 109 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 136 + .get(format!( 137 + "{}/xrpc/com.atproto.server.checkAccountStatus", 138 + base 139 + )) 110 140 .bearer_auth(&access_jwt) 111 141 .send() 112 142 .await ··· 114 144 assert_eq!(status.status(), StatusCode::OK); 115 145 let body: Value = status.json().await.unwrap(); 116 146 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"); 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 + ); 119 155 } 120 156 121 157 #[tokio::test] ··· 128 164 let delete_after = future_time.to_rfc3339(); 129 165 130 166 let deactivate = client 131 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 167 + .post(format!( 168 + "{}/xrpc/com.atproto.server.deactivateAccount", 169 + base 170 + )) 132 171 .bearer_auth(&access_jwt) 133 172 .json(&json!({ 134 173 "deleteAfter": delete_after ··· 139 178 assert_eq!(deactivate.status(), StatusCode::OK); 140 179 141 180 let status = client 142 - .get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base)) 181 + .get(format!( 182 + "{}/xrpc/com.atproto.server.checkAccountStatus", 183 + base 184 + )) 143 185 .bearer_auth(&access_jwt) 144 186 .send() 145 187 .await ··· 170 212 assert_eq!(create_res.status(), StatusCode::OK); 171 213 let body: Value = create_res.json().await.unwrap(); 172 214 173 - assert!(body["accessJwt"].is_string(), "accessJwt should always be returned"); 174 - assert!(body["refreshJwt"].is_string(), "refreshJwt should always be returned"); 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 + ); 175 223 assert!(body["did"].is_string(), "did should be returned"); 176 224 177 225 if body["didDoc"].is_object() { ··· 201 249 assert_eq!(create_res.status(), StatusCode::OK); 202 250 let body: Value = create_res.json().await.unwrap(); 203 251 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"); 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"); 206 258 207 259 assert!(!access_jwt.is_empty(), "accessJwt should not be empty"); 208 260 assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty"); 209 261 210 262 let parts: Vec<&str> = access_jwt.split('.').collect(); 211 - assert_eq!(parts.len(), 3, "accessJwt should be a valid JWT with 3 parts"); 263 + assert_eq!( 264 + parts.len(), 265 + 3, 266 + "accessJwt should be a valid JWT with 3 parts" 267 + ); 212 268 } 213 269 214 270 #[tokio::test] ··· 224 280 assert_eq!(describe.status(), StatusCode::OK); 225 281 let body: Value = describe.json().await.unwrap(); 226 282 227 - assert!(body.get("links").is_some(), "describeServer should include links object"); 228 - assert!(body.get("contact").is_some(), "describeServer should include contact object"); 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 + ); 229 291 230 292 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)"); 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 + ); 235 301 236 302 let contact = &body["contact"]; 237 - assert!(contact.get("email").is_some() || contact["email"].is_null(), 238 - "contact should have email field (can be null)"); 303 + assert!( 304 + contact.get("email").is_some() || contact["email"].is_null(), 305 + "contact should have email field (can be null)" 306 + ); 239 307 } 240 308 241 309 #[tokio::test] ··· 274 342 275 343 assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); 276 344 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"); 345 + assert!( 346 + error_body["message"] 347 + .as_str() 348 + .unwrap() 349 + .contains("password length") 350 + || error_body["error"].as_str().unwrap() == "InvalidRequest" 351 + ); 279 352 }
+1 -3
tests/account_notifications.rs
··· 23 23 format!("Subject {}", i), 24 24 format!("Body {}", i), 25 25 ); 26 - enqueue_comms(pool, comms) 27 - .await 28 - .expect("Failed to enqueue"); 26 + enqueue_comms(pool, comms).await.expect("Failed to enqueue"); 29 27 } 30 28 31 29 let resp = client
+9 -2
tests/actor.rs
··· 174 174 let body: Value = get_resp.json().await.unwrap(); 175 175 let prefs_arr = body["preferences"].as_array().unwrap(); 176 176 assert_eq!(prefs_arr.len(), 1); 177 - assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#adultContentPref"); 177 + assert_eq!( 178 + prefs_arr[0]["$type"], 179 + "app.bsky.actor.defs#adultContentPref" 180 + ); 178 181 } 179 182 180 183 #[tokio::test] ··· 393 396 let client = client(); 394 397 let base = base_url().await; 395 398 let (token, _did) = create_account_and_login(&client).await; 396 - let current_year = chrono::Utc::now().format("%Y").to_string().parse::<i32>().unwrap(); 399 + let current_year = chrono::Utc::now() 400 + .format("%Y") 401 + .to_string() 402 + .parse::<i32>() 403 + .unwrap(); 397 404 let birth_year = current_year - 15; 398 405 let prefs = json!({ 399 406 "preferences": [
+4 -1
tests/admin_invite.rs
··· 217 217 .expect("Failed to get invite codes"); 218 218 let list_body: Value = list_res.json().await.unwrap(); 219 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(); 220 + let admin_codes: Vec<_> = codes 221 + .iter() 222 + .filter(|c| c["forAccount"].as_str() == Some(&did)) 223 + .collect(); 221 224 for code in admin_codes { 222 225 assert_eq!(code["disabled"], true); 223 226 }
+20 -5
tests/did_web.rs
··· 569 569 let jwt = verify_new_account(&client, &did).await; 570 570 let target_pds = "https://pds2.example.com"; 571 571 let res = client 572 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 572 + .post(format!( 573 + "{}/xrpc/com.atproto.server.deactivateAccount", 574 + base 575 + )) 573 576 .bearer_auth(&jwt) 574 577 .json(&json!({ "migratingTo": target_pds })) 575 578 .send() ··· 633 636 .expect("Failed to send request"); 634 637 assert_eq!(res.status(), StatusCode::OK); 635 638 let res = client 636 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 639 + .post(format!( 640 + "{}/xrpc/com.atproto.server.deactivateAccount", 641 + base 642 + )) 637 643 .bearer_auth(&jwt) 638 644 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 639 645 .send() ··· 770 776 ); 771 777 let target_pds = "https://pds3.example.com"; 772 778 let res = client 773 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 779 + .post(format!( 780 + "{}/xrpc/com.atproto.server.deactivateAccount", 781 + base 782 + )) 774 783 .bearer_auth(&jwt) 775 784 .json(&json!({ "migratingTo": target_pds })) 776 785 .send() ··· 785 794 .expect("Failed to send request"); 786 795 assert_eq!(res.status(), StatusCode::OK); 787 796 let body: Value = res.json().await.expect("Response was not JSON"); 788 - assert_eq!(body["active"], false, "Migrated account should not be active"); 797 + assert_eq!( 798 + body["active"], false, 799 + "Migrated account should not be active" 800 + ); 789 801 assert_eq!( 790 802 body["status"], "migrated", 791 803 "Status should be 'migrated' after migration" ··· 819 831 assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 820 832 let jwt = verify_new_account(&client, &did).await; 821 833 let res = client 822 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 834 + .post(format!( 835 + "{}/xrpc/com.atproto.server.deactivateAccount", 836 + base 837 + )) 823 838 .bearer_auth(&jwt) 824 839 .json(&json!({ "migratingTo": "https://pds2.example.com" })) 825 840 .send()
+32 -19
tests/email_update.rs
··· 112 112 .expect("Failed to update email"); 113 113 assert_eq!(res.status(), StatusCode::OK); 114 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"); 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"); 119 120 assert_eq!(user_email, Some(new_email)); 120 121 } 121 122 ··· 255 256 assert_eq!(res.status(), StatusCode::OK); 256 257 let body: Value = res.json().await.expect("Invalid JSON"); 257 258 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 + let access_jwt = body["accessJwt"] 260 + .as_str() 261 + .expect("No accessJwt") 262 + .to_string(); 259 263 260 264 let body_text: String = sqlx::query_scalar!( 261 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", ··· 283 287 .expect("Failed to confirm email"); 284 288 assert_eq!(res.status(), StatusCode::OK); 285 289 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"); 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"); 293 295 assert!(verified); 294 296 } 295 297 ··· 317 319 assert_eq!(res.status(), StatusCode::OK); 318 320 let body: Value = res.json().await.expect("Invalid JSON"); 319 321 let did = body["did"].as_str().expect("No did").to_string(); 320 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 322 + let access_jwt = body["accessJwt"] 323 + .as_str() 324 + .expect("No accessJwt") 325 + .to_string(); 321 326 322 327 let body_text: String = sqlx::query_scalar!( 323 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", ··· 370 375 .expect("Failed to create account"); 371 376 assert_eq!(res.status(), StatusCode::OK); 372 377 let body: Value = res.json().await.expect("Invalid JSON"); 373 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 378 + let access_jwt = body["accessJwt"] 379 + .as_str() 380 + .expect("No accessJwt") 381 + .to_string(); 374 382 375 383 let res = client 376 384 .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) ··· 411 419 assert_eq!(res.status(), StatusCode::OK); 412 420 let body: Value = res.json().await.expect("Invalid JSON"); 413 421 let did = body["did"].as_str().expect("No did").to_string(); 414 - let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string(); 422 + let access_jwt = body["accessJwt"] 423 + .as_str() 424 + .expect("No accessJwt") 425 + .to_string(); 415 426 416 427 let res = client 417 428 .post(format!( ··· 491 502 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 492 503 let body: Value = res.json().await.expect("Invalid JSON"); 493 504 assert_eq!(body["error"], "InvalidRequest"); 494 - assert!(body["message"] 495 - .as_str() 496 - .unwrap_or("") 497 - .contains("already in use")); 505 + assert!( 506 + body["message"] 507 + .as_str() 508 + .unwrap_or("") 509 + .contains("already in use") 510 + ); 498 511 }
+4 -1
tests/identity.rs
··· 393 393 .await 394 394 .expect("Failed to get session"); 395 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(); 396 + let current_handle = session_body["handle"] 397 + .as_str() 398 + .expect("No handle") 399 + .to_string(); 397 400 let short_handle = current_handle.split('.').next().unwrap_or(&current_handle); 398 401 let res = client 399 402 .post(format!(
+13 -3
tests/invite.rs
··· 25 25 assert!(body["code"].is_string()); 26 26 let code = body["code"].as_str().unwrap(); 27 27 assert!(!code.is_empty()); 28 - assert!(code.contains('-'), "Code should be in hostname-xxxxx-xxxxx format"); 28 + assert!( 29 + code.contains('-'), 30 + "Code should be in hostname-xxxxx-xxxxx format" 31 + ); 29 32 let parts: Vec<&str> = code.split('-').collect(); 30 - assert!(parts.len() >= 3, "Code should have at least 3 parts (hostname + 2 random parts)"); 33 + assert!( 34 + parts.len() >= 3, 35 + "Code should have at least 3 parts (hostname + 2 random parts)" 36 + ); 31 37 } 32 38 33 39 #[tokio::test] ··· 363 369 let body: Value = res.json().await.expect("Response was not valid JSON"); 364 370 let codes = body["codes"].as_array().unwrap(); 365 371 for c in codes { 366 - assert_ne!(c["code"].as_str().unwrap(), code, "Disabled code should be filtered out"); 372 + assert_ne!( 373 + c["code"].as_str().unwrap(), 374 + code, 375 + "Disabled code should be filtered out" 376 + ); 367 377 } 368 378 }
+20 -5
tests/lifecycle_session.rs
··· 291 291 let base = base_url().await; 292 292 let (jwt, _did) = create_account_and_login(&client).await; 293 293 let create_res = client 294 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 294 + .post(format!( 295 + "{}/xrpc/com.atproto.server.createAppPassword", 296 + base 297 + )) 295 298 .bearer_auth(&jwt) 296 299 .json(&json!({ "name": "My App" })) 297 300 .send() ··· 299 302 .expect("Failed to create app password"); 300 303 assert_eq!(create_res.status(), StatusCode::OK); 301 304 let duplicate_res = client 302 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 305 + .post(format!( 306 + "{}/xrpc/com.atproto.server.createAppPassword", 307 + base 308 + )) 303 309 .bearer_auth(&jwt) 304 310 .json(&json!({ "name": "My App" })) 305 311 .send() ··· 320 326 let base = base_url().await; 321 327 let (jwt, _did) = create_account_and_login(&client).await; 322 328 let revoke_res = client 323 - .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 329 + .post(format!( 330 + "{}/xrpc/com.atproto.server.revokeAppPassword", 331 + base 332 + )) 324 333 .bearer_auth(&jwt) 325 334 .json(&json!({ "name": "Does Not Exist" })) 326 335 .send() ··· 356 365 let did = account["did"].as_str().unwrap(); 357 366 let main_jwt = verify_new_account(&client, did).await; 358 367 let create_app_res = client 359 - .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 368 + .post(format!( 369 + "{}/xrpc/com.atproto.server.createAppPassword", 370 + base 371 + )) 360 372 .bearer_auth(&main_jwt) 361 373 .json(&json!({ "name": "Session Test App" })) 362 374 .send() ··· 389 401 "App password session should be valid before revocation" 390 402 ); 391 403 let revoke_res = client 392 - .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 404 + .post(format!( 405 + "{}/xrpc/com.atproto.server.revokeAppPassword", 406 + base 407 + )) 393 408 .bearer_auth(&main_jwt) 394 409 .json(&json!({ "name": "Session Test App" })) 395 410 .send()
+2 -8
tests/moderation.rs
··· 85 85 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 86 86 let body: Value = res.json().await.unwrap(); 87 87 assert_eq!(body["error"], "InvalidRequest"); 88 - assert!(body["message"] 89 - .as_str() 90 - .unwrap() 91 - .contains("reasonType")); 88 + assert!(body["message"].as_str().unwrap().contains("reasonType")); 92 89 } 93 90 94 91 #[tokio::test] ··· 266 263 ); 267 264 let body: Value = report_res.json().await.unwrap(); 268 265 assert_eq!(body["error"], "InvalidRequest"); 269 - assert!(body["message"] 270 - .as_str() 271 - .unwrap() 272 - .contains("takendown")); 266 + assert!(body["message"].as_str().unwrap().contains("takendown")); 273 267 }
+1 -2
tests/oauth_lifecycle.rs
··· 949 949 let url = base_url().await; 950 950 let http_client = client(); 951 951 let (alice, _mock_alice) = 952 - create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback") 953 - .await; 952 + create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback").await; 954 953 let (bob, _mock_bob) = 955 954 create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await; 956 955 let collection = "app.bsky.feed.post";
+173 -42
tests/repo_conformance.rs
··· 23 23 }); 24 24 25 25 let res = client 26 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 26 + .post(format!( 27 + "{}/xrpc/com.atproto.repo.createRecord", 28 + base_url().await 29 + )) 27 30 .bearer_auth(&jwt) 28 31 .json(&payload) 29 32 .send() ··· 35 38 36 39 assert!(body["uri"].is_string(), "response must have uri"); 37 40 assert!(body["cid"].is_string(), "response must have cid"); 38 - assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid"); 41 + assert!( 42 + body["cid"].as_str().unwrap().starts_with("bafy"), 43 + "cid must be valid" 44 + ); 39 45 40 - assert!(body["commit"].is_object(), "response must have commit object"); 46 + assert!( 47 + body["commit"].is_object(), 48 + "response must have commit object" 49 + ); 41 50 let commit = &body["commit"]; 42 51 assert!(commit["cid"].is_string(), "commit must have cid"); 43 - assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid"); 52 + assert!( 53 + commit["cid"].as_str().unwrap().starts_with("bafy"), 54 + "commit.cid must be valid" 55 + ); 44 56 assert!(commit["rev"].is_string(), "commit must have rev"); 45 57 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'"); 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 + ); 48 66 } 49 67 50 68 #[tokio::test] ··· 65 83 }); 66 84 67 85 let res = client 68 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 86 + .post(format!( 87 + "{}/xrpc/com.atproto.repo.createRecord", 88 + base_url().await 89 + )) 69 90 .bearer_auth(&jwt) 70 91 .json(&payload) 71 92 .send() ··· 77 98 78 99 assert!(body["uri"].is_string()); 79 100 assert!(body["commit"].is_object()); 80 - assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false"); 101 + assert!( 102 + body["validationStatus"].is_null(), 103 + "validationStatus should be omitted when validate=false" 104 + ); 81 105 } 82 106 83 107 #[tokio::test] ··· 98 122 }); 99 123 100 124 let res = client 101 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 125 + .post(format!( 126 + "{}/xrpc/com.atproto.repo.putRecord", 127 + base_url().await 128 + )) 102 129 .bearer_auth(&jwt) 103 130 .json(&payload) 104 131 .send() ··· 111 138 assert!(body["uri"].is_string(), "response must have uri"); 112 139 assert!(body["cid"].is_string(), "response must have cid"); 113 140 114 - assert!(body["commit"].is_object(), "response must have commit object"); 141 + assert!( 142 + body["commit"].is_object(), 143 + "response must have commit object" 144 + ); 115 145 let commit = &body["commit"]; 116 146 assert!(commit["cid"].is_string(), "commit must have cid"); 117 147 assert!(commit["rev"].is_string(), "commit must have rev"); 118 148 119 - assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'"); 149 + assert_eq!( 150 + body["validationStatus"], "valid", 151 + "validationStatus should be 'valid'" 152 + ); 120 153 } 121 154 122 155 #[tokio::test] ··· 136 169 } 137 170 }); 138 171 let create_res = client 139 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 172 + .post(format!( 173 + "{}/xrpc/com.atproto.repo.putRecord", 174 + base_url().await 175 + )) 140 176 .bearer_auth(&jwt) 141 177 .json(&create_payload) 142 178 .send() ··· 150 186 "rkey": "to-delete" 151 187 }); 152 188 let delete_res = client 153 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 189 + .post(format!( 190 + "{}/xrpc/com.atproto.repo.deleteRecord", 191 + base_url().await 192 + )) 154 193 .bearer_auth(&jwt) 155 194 .json(&delete_payload) 156 195 .send() ··· 160 199 assert_eq!(delete_res.status(), StatusCode::OK); 161 200 let body: Value = delete_res.json().await.unwrap(); 162 201 163 - assert!(body["commit"].is_object(), "response must have commit object when record was deleted"); 202 + assert!( 203 + body["commit"].is_object(), 204 + "response must have commit object when record was deleted" 205 + ); 164 206 let commit = &body["commit"]; 165 207 assert!(commit["cid"].is_string(), "commit must have cid"); 166 208 assert!(commit["rev"].is_string(), "commit must have rev"); ··· 177 219 "rkey": "nonexistent-record" 178 220 }); 179 221 let delete_res = client 180 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await)) 222 + .post(format!( 223 + "{}/xrpc/com.atproto.repo.deleteRecord", 224 + base_url().await 225 + )) 181 226 .bearer_auth(&jwt) 182 227 .json(&delete_payload) 183 228 .send() ··· 187 232 assert_eq!(delete_res.status(), StatusCode::OK); 188 233 let body: Value = delete_res.json().await.unwrap(); 189 234 190 - assert!(body["commit"].is_null(), "commit should be omitted on no-op delete"); 235 + assert!( 236 + body["commit"].is_null(), 237 + "commit should be omitted on no-op delete" 238 + ); 191 239 } 192 240 193 241 #[tokio::test] ··· 223 271 }); 224 272 225 273 let res = client 226 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 274 + .post(format!( 275 + "{}/xrpc/com.atproto.repo.applyWrites", 276 + base_url().await 277 + )) 227 278 .bearer_auth(&jwt) 228 279 .json(&payload) 229 280 .send() ··· 233 284 assert_eq!(res.status(), StatusCode::OK); 234 285 let body: Value = res.json().await.unwrap(); 235 286 236 - assert!(body["commit"].is_object(), "response must have commit object"); 287 + assert!( 288 + body["commit"].is_object(), 289 + "response must have commit object" 290 + ); 237 291 let commit = &body["commit"]; 238 292 assert!(commit["cid"].is_string(), "commit must have cid"); 239 293 assert!(commit["rev"].is_string(), "commit must have rev"); 240 294 241 - assert!(body["results"].is_array(), "response must have results array"); 295 + assert!( 296 + body["results"].is_array(), 297 + "response must have results array" 298 + ); 242 299 let results = body["results"].as_array().unwrap(); 243 300 assert_eq!(results.len(), 2, "should have 2 results"); 244 301 245 302 for result in results { 246 303 assert!(result["uri"].is_string(), "result must have uri"); 247 304 assert!(result["cid"].is_string(), "result must have cid"); 248 - assert_eq!(result["validationStatus"], "valid", "result must have validationStatus"); 305 + assert_eq!( 306 + result["validationStatus"], "valid", 307 + "result must have validationStatus" 308 + ); 249 309 assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult"); 250 310 } 251 311 } ··· 267 327 } 268 328 }); 269 329 client 270 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 330 + .post(format!( 331 + "{}/xrpc/com.atproto.repo.putRecord", 332 + base_url().await 333 + )) 271 334 .bearer_auth(&jwt) 272 335 .json(&create_payload) 273 336 .send() ··· 296 359 }); 297 360 298 361 let res = client 299 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 362 + .post(format!( 363 + "{}/xrpc/com.atproto.repo.applyWrites", 364 + base_url().await 365 + )) 300 366 .bearer_auth(&jwt) 301 367 .json(&payload) 302 368 .send() ··· 310 376 assert_eq!(results.len(), 2); 311 377 312 378 let update_result = &results[0]; 313 - assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult"); 379 + assert_eq!( 380 + update_result["$type"], 381 + "com.atproto.repo.applyWrites#updateResult" 382 + ); 314 383 assert!(update_result["uri"].is_string()); 315 384 assert!(update_result["cid"].is_string()); 316 385 assert_eq!(update_result["validationStatus"], "valid"); 317 386 318 387 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"); 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 + ); 323 404 } 324 405 325 406 #[tokio::test] ··· 328 409 let (did, _jwt) = setup_new_user("conform-get-err").await; 329 410 330 411 let res = client 331 - .get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await)) 412 + .get(format!( 413 + "{}/xrpc/com.atproto.repo.getRecord", 414 + base_url().await 415 + )) 332 416 .query(&[ 333 417 ("repo", did.as_str()), 334 418 ("collection", "app.bsky.feed.post"), ··· 340 424 341 425 assert_eq!(res.status(), StatusCode::NOT_FOUND); 342 426 let body: Value = res.json().await.unwrap(); 343 - assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec"); 427 + assert_eq!( 428 + body["error"], "RecordNotFound", 429 + "error code should be RecordNotFound per atproto spec" 430 + ); 344 431 } 345 432 346 433 #[tokio::test] ··· 358 445 }); 359 446 360 447 let res = client 361 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 448 + .post(format!( 449 + "{}/xrpc/com.atproto.repo.createRecord", 450 + base_url().await 451 + )) 362 452 .bearer_auth(&jwt) 363 453 .json(&payload) 364 454 .send() 365 455 .await 366 456 .expect("Failed to create record"); 367 457 368 - assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation"); 458 + assert_eq!( 459 + res.status(), 460 + StatusCode::OK, 461 + "unknown lexicon should be allowed with default validation" 462 + ); 369 463 let body: Value = res.json().await.unwrap(); 370 464 371 465 assert!(body["uri"].is_string()); 372 466 assert!(body["cid"].is_string()); 373 467 assert!(body["commit"].is_object()); 374 - assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons"); 468 + assert_eq!( 469 + body["validationStatus"], "unknown", 470 + "validationStatus should be 'unknown' for unknown lexicons" 471 + ); 375 472 } 376 473 377 474 #[tokio::test] ··· 390 487 }); 391 488 392 489 let res = client 393 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await)) 490 + .post(format!( 491 + "{}/xrpc/com.atproto.repo.createRecord", 492 + base_url().await 493 + )) 394 494 .bearer_auth(&jwt) 395 495 .json(&payload) 396 496 .send() 397 497 .await 398 498 .expect("Failed to send request"); 399 499 400 - assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true"); 500 + assert_eq!( 501 + res.status(), 502 + StatusCode::BAD_REQUEST, 503 + "unknown lexicon should fail with validate=true" 504 + ); 401 505 let body: Value = res.json().await.unwrap(); 402 506 assert_eq!(body["error"], "InvalidRecord"); 403 - assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found"); 507 + assert!( 508 + body["message"] 509 + .as_str() 510 + .unwrap() 511 + .contains("Lexicon not found"), 512 + "error should mention lexicon not found" 513 + ); 404 514 } 405 515 406 516 #[tokio::test] ··· 423 533 }); 424 534 425 535 let first_res = client 426 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 536 + .post(format!( 537 + "{}/xrpc/com.atproto.repo.putRecord", 538 + base_url().await 539 + )) 427 540 .bearer_auth(&jwt) 428 541 .json(&payload) 429 542 .send() ··· 431 544 .expect("Failed to put record"); 432 545 assert_eq!(first_res.status(), StatusCode::OK); 433 546 let first_body: Value = first_res.json().await.unwrap(); 434 - assert!(first_body["commit"].is_object(), "first put should have commit"); 547 + assert!( 548 + first_body["commit"].is_object(), 549 + "first put should have commit" 550 + ); 435 551 436 552 let second_res = client 437 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await)) 553 + .post(format!( 554 + "{}/xrpc/com.atproto.repo.putRecord", 555 + base_url().await 556 + )) 438 557 .bearer_auth(&jwt) 439 558 .json(&payload) 440 559 .send() ··· 443 562 assert_eq!(second_res.status(), StatusCode::OK); 444 563 let second_body: Value = second_res.json().await.unwrap(); 445 564 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"); 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 + ); 448 573 } 449 574 450 575 #[tokio::test] ··· 468 593 }); 469 594 470 595 let res = client 471 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await)) 596 + .post(format!( 597 + "{}/xrpc/com.atproto.repo.applyWrites", 598 + base_url().await 599 + )) 472 600 .bearer_auth(&jwt) 473 601 .json(&payload) 474 602 .send() ··· 480 608 481 609 let results = body["results"].as_array().unwrap(); 482 610 assert_eq!(results.len(), 1); 483 - assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status"); 611 + assert_eq!( 612 + results[0]["validationStatus"], "unknown", 613 + "unknown lexicon should have 'unknown' status" 614 + ); 484 615 }
+20 -5
tests/sync_deprecated.rs
··· 202 202 .unwrap(); 203 203 assert_eq!(res.status(), StatusCode::OK); 204 204 client 205 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 205 + .post(format!( 206 + "{}/xrpc/com.atproto.server.deactivateAccount", 207 + base 208 + )) 206 209 .bearer_auth(&jwt) 207 210 .json(&serde_json::json!({})) 208 211 .send() ··· 233 236 .unwrap(); 234 237 assert_eq!(res.status(), StatusCode::OK); 235 238 client 236 - .post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base)) 239 + .post(format!( 240 + "{}/xrpc/com.atproto.admin.updateSubjectStatus", 241 + base 242 + )) 237 243 .bearer_auth(&admin_jwt) 238 244 .json(&serde_json::json!({ 239 245 "subject": { ··· 266 272 let (admin_jwt, _) = create_admin_account_and_login(&client).await; 267 273 let (user_jwt, did) = create_account_and_login(&client).await; 268 274 client 269 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 275 + .post(format!( 276 + "{}/xrpc/com.atproto.server.deactivateAccount", 277 + base 278 + )) 270 279 .bearer_auth(&user_jwt) 271 280 .json(&serde_json::json!({})) 272 281 .send() ··· 295 304 .unwrap(); 296 305 assert_eq!(res.status(), StatusCode::OK); 297 306 client 298 - .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base)) 307 + .post(format!( 308 + "{}/xrpc/com.atproto.server.deactivateAccount", 309 + base 310 + )) 299 311 .bearer_auth(&jwt) 300 312 .json(&serde_json::json!({})) 301 313 .send() ··· 326 338 .unwrap(); 327 339 assert_eq!(res.status(), StatusCode::OK); 328 340 client 329 - .post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base)) 341 + .post(format!( 342 + "{}/xrpc/com.atproto.admin.updateSubjectStatus", 343 + base 344 + )) 330 345 .bearer_auth(&admin_jwt) 331 346 .json(&serde_json::json!({ 332 347 "subject": {