this repo has no description

Three-quarter-done in-house migration flow

lewis 76f1d622 dd67947e

+26
.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT cid, data FROM blocks", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "cid", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "data", 14 + "type_info": "Bytea" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [] 19 + }, 20 + "nullable": [ 21 + false, 22 + false 23 + ] 24 + }, 25 + "hash": "1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5" 26 + }
+28
.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "key_bytes", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db" 28 + }
+14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1" 14 + }
+34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "migrated_to_pds", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "migrated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + true 31 + ] 32 + }, 33 + "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e" 34 + }
+16
.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Timestamptz", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93" 16 + }
+3
frontend/src/App.svelte
··· 32 import Controllers from './routes/Controllers.svelte' 33 import DelegationAudit from './routes/DelegationAudit.svelte' 34 import ActAs from './routes/ActAs.svelte' 35 import Home from './routes/Home.svelte' 36 37 initI18n() ··· 113 return DelegationAudit 114 case '/act-as': 115 return ActAs 116 default: 117 return Home 118 }
··· 32 import Controllers from './routes/Controllers.svelte' 33 import DelegationAudit from './routes/DelegationAudit.svelte' 34 import ActAs from './routes/ActAs.svelte' 35 + import Migration from './routes/Migration.svelte' 36 import Home from './routes/Home.svelte' 37 38 initI18n() ··· 114 return DelegationAudit 115 case '/act-as': 116 return ActAs 117 + case '/migrate': 118 + return Migration 119 default: 120 return Home 121 }
+1024
frontend/src/components/migration/InboundWizard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { InboundMigrationFlow } from '../../lib/migration' 3 + import type { ServerDescription } from '../../lib/migration/types' 4 + import { _ } from '../../lib/i18n' 5 + 6 + interface Props { 7 + flow: InboundMigrationFlow 8 + onBack: () => void 9 + onComplete: () => void 10 + } 11 + 12 + let { flow, onBack, onComplete }: Props = $props() 13 + 14 + let serverInfo = $state<ServerDescription | null>(null) 15 + let loading = $state(false) 16 + let handleInput = $state('') 17 + let passwordInput = $state('') 18 + let localPasswordInput = $state('') 19 + let understood = $state(false) 20 + let selectedDomain = $state('') 21 + let handleAvailable = $state<boolean | null>(null) 22 + let checkingHandle = $state(false) 23 + 24 + const isResumedMigration = $derived(flow.state.progress.repoImported) 25 + 26 + $effect(() => { 27 + if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 28 + loadServerInfo() 29 + } 30 + }) 31 + 32 + 33 + let redirectTriggered = $state(false) 34 + 35 + $effect(() => { 36 + if (flow.state.step === 'success' && !redirectTriggered) { 37 + redirectTriggered = true 38 + setTimeout(() => { 39 + onComplete() 40 + }, 2000) 41 + } 42 + }) 43 + 44 + $effect(() => { 45 + if (flow.state.step === 'email-verify') { 46 + const interval = setInterval(async () => { 47 + if (flow.state.emailVerifyToken.trim()) return 48 + await flow.checkEmailVerifiedAndProceed() 49 + }, 3000) 50 + return () => clearInterval(interval) 51 + } 52 + }) 53 + 54 + async function loadServerInfo() { 55 + if (!serverInfo) { 56 + serverInfo = await flow.loadLocalServerInfo() 57 + if (serverInfo.availableUserDomains.length > 0) { 58 + selectedDomain = serverInfo.availableUserDomains[0] 59 + } 60 + } 61 + } 62 + 63 + async function handleLogin(e: Event) { 64 + e.preventDefault() 65 + loading = true 66 + flow.updateField('error', null) 67 + 68 + try { 69 + await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined) 70 + const username = flow.state.sourceHandle.split('.')[0] 71 + handleInput = username 72 + flow.updateField('targetPassword', passwordInput) 73 + 74 + if (flow.state.progress.repoImported) { 75 + if (!localPasswordInput) { 76 + flow.setError('Please enter your password for your new account on this PDS') 77 + return 78 + } 79 + await flow.loadLocalServerInfo() 80 + 81 + try { 82 + await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput) 83 + await flow.requestPlcToken() 84 + flow.setStep('plc-token') 85 + } catch (err) { 86 + const error = err as Error & { error?: string } 87 + if (error.error === 'AccountNotVerified') { 88 + flow.setStep('email-verify') 89 + } else { 90 + throw err 91 + } 92 + } 93 + } else { 94 + flow.setStep('choose-handle') 95 + } 96 + } catch (err) { 97 + flow.setError((err as Error).message) 98 + } finally { 99 + loading = false 100 + } 101 + } 102 + 103 + async function checkHandle() { 104 + if (!handleInput.trim()) return 105 + 106 + const fullHandle = handleInput.includes('.') 107 + ? handleInput 108 + : `${handleInput}.${selectedDomain}` 109 + 110 + checkingHandle = true 111 + handleAvailable = null 112 + 113 + try { 114 + handleAvailable = await flow.checkHandleAvailability(fullHandle) 115 + } catch { 116 + handleAvailable = true 117 + } finally { 118 + checkingHandle = false 119 + } 120 + } 121 + 122 + function proceedToReview() { 123 + const fullHandle = handleInput.includes('.') 124 + ? handleInput 125 + : `${handleInput}.${selectedDomain}` 126 + 127 + flow.updateField('targetHandle', fullHandle) 128 + flow.setStep('review') 129 + } 130 + 131 + async function startMigration() { 132 + loading = true 133 + try { 134 + await flow.startMigration() 135 + } catch (err) { 136 + flow.setError((err as Error).message) 137 + } finally { 138 + loading = false 139 + } 140 + } 141 + 142 + async function submitEmailVerify(e: Event) { 143 + e.preventDefault() 144 + loading = true 145 + try { 146 + await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined) 147 + } catch (err) { 148 + flow.setError((err as Error).message) 149 + } finally { 150 + loading = false 151 + } 152 + } 153 + 154 + async function resendEmailVerify() { 155 + loading = true 156 + try { 157 + await flow.resendEmailVerification() 158 + flow.setError(null) 159 + } catch (err) { 160 + flow.setError((err as Error).message) 161 + } finally { 162 + loading = false 163 + } 164 + } 165 + 166 + async function submitPlcToken(e: Event) { 167 + e.preventDefault() 168 + loading = true 169 + try { 170 + await flow.submitPlcToken(flow.state.plcToken) 171 + } catch (err) { 172 + flow.setError((err as Error).message) 173 + } finally { 174 + loading = false 175 + } 176 + } 177 + 178 + async function resendToken() { 179 + loading = true 180 + try { 181 + await flow.resendPlcToken() 182 + flow.setError(null) 183 + } catch (err) { 184 + flow.setError((err as Error).message) 185 + } finally { 186 + loading = false 187 + } 188 + } 189 + 190 + const steps = ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'] 191 + function getCurrentStepIndex(): number { 192 + switch (flow.state.step) { 193 + case 'welcome': 194 + case 'source-login': return 0 195 + case 'choose-handle': return 1 196 + case 'review': return 2 197 + case 'migrating': return 3 198 + case 'email-verify': return 4 199 + case 'plc-token': 200 + case 'finalizing': return 5 201 + case 'success': return 6 202 + default: return 0 203 + } 204 + } 205 + </script> 206 + 207 + <div class="inbound-wizard"> 208 + <div class="step-indicator"> 209 + {#each steps as stepName, i} 210 + <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 211 + <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 212 + <span class="step-label">{stepName}</span> 213 + </div> 214 + {#if i < steps.length - 1} 215 + <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 216 + {/if} 217 + {/each} 218 + </div> 219 + 220 + {#if flow.state.error} 221 + <div class="message error">{flow.state.error}</div> 222 + {/if} 223 + 224 + {#if flow.state.step === 'welcome'} 225 + <div class="step-content"> 226 + <h2>Migrate Your Account Here</h2> 227 + <p>This wizard will help you move your AT Protocol account from another PDS to this one.</p> 228 + 229 + <div class="info-box"> 230 + <h3>What will happen:</h3> 231 + <ol> 232 + <li>Log in to your current PDS</li> 233 + <li>Choose your new handle on this server</li> 234 + <li>Your repository and blobs will be transferred</li> 235 + <li>Verify the migration via email</li> 236 + <li>Your identity will be updated to point here</li> 237 + </ol> 238 + </div> 239 + 240 + <div class="warning-box"> 241 + <strong>Before you proceed:</strong> 242 + <ul> 243 + <li>You need access to the email registered with your current account</li> 244 + <li>Large accounts may take several minutes to transfer</li> 245 + <li>Your old account will be deactivated after migration</li> 246 + </ul> 247 + </div> 248 + 249 + <label class="checkbox-label"> 250 + <input type="checkbox" bind:checked={understood} /> 251 + <span>I understand the risks and want to proceed with migration</span> 252 + </label> 253 + 254 + <div class="button-row"> 255 + <button class="ghost" onclick={onBack}>Cancel</button> 256 + <button disabled={!understood} onclick={() => flow.setStep('source-login')}> 257 + Continue 258 + </button> 259 + </div> 260 + </div> 261 + 262 + {:else if flow.state.step === 'source-login'} 263 + <div class="step-content"> 264 + <h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2> 265 + <p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p> 266 + 267 + {#if isResumedMigration} 268 + <div class="info-box"> 269 + <p>Your migration was interrupted. Log in to both accounts to resume.</p> 270 + <p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong> → <strong>{flow.state.targetHandle}</strong></p> 271 + </div> 272 + {/if} 273 + 274 + <form onsubmit={handleLogin}> 275 + <div class="field"> 276 + <label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label> 277 + <input 278 + id="handle" 279 + type="text" 280 + placeholder="alice.bsky.social" 281 + bind:value={handleInput} 282 + disabled={loading} 283 + required 284 + /> 285 + <p class="hint">Your current handle on your existing PDS</p> 286 + </div> 287 + 288 + <div class="field"> 289 + <label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label> 290 + <input 291 + id="password" 292 + type="password" 293 + bind:value={passwordInput} 294 + disabled={loading} 295 + required 296 + /> 297 + <p class="hint">Your account password (not an app password)</p> 298 + </div> 299 + 300 + {#if flow.state.requires2FA} 301 + <div class="field"> 302 + <label for="2fa">Two-Factor Code</label> 303 + <input 304 + id="2fa" 305 + type="text" 306 + placeholder="Enter code from email" 307 + bind:value={flow.state.twoFactorCode} 308 + disabled={loading} 309 + required 310 + /> 311 + <p class="hint">Check your email for the verification code</p> 312 + </div> 313 + {/if} 314 + 315 + {#if isResumedMigration} 316 + <hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" /> 317 + 318 + <div class="field"> 319 + <label for="local-password">New Account Password</label> 320 + <input 321 + id="local-password" 322 + type="password" 323 + placeholder="Password for your new account" 324 + bind:value={localPasswordInput} 325 + disabled={loading} 326 + required 327 + /> 328 + <p class="hint">The password you set for your account on this PDS</p> 329 + </div> 330 + {/if} 331 + 332 + <div class="button-row"> 333 + <button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button> 334 + <button type="submit" disabled={loading}> 335 + {loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')} 336 + </button> 337 + </div> 338 + </form> 339 + </div> 340 + 341 + {:else if flow.state.step === 'choose-handle'} 342 + <div class="step-content"> 343 + <h2>Choose Your New Handle</h2> 344 + <p>Select a handle for your account on this PDS.</p> 345 + 346 + <div class="current-info"> 347 + <span class="label">Migrating from:</span> 348 + <span class="value">{flow.state.sourceHandle}</span> 349 + </div> 350 + 351 + <div class="field"> 352 + <label for="new-handle">New Handle</label> 353 + <div class="handle-input-group"> 354 + <input 355 + id="new-handle" 356 + type="text" 357 + placeholder="username" 358 + bind:value={handleInput} 359 + onblur={checkHandle} 360 + /> 361 + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 362 + <select bind:value={selectedDomain}> 363 + {#each serverInfo.availableUserDomains as domain} 364 + <option value={domain}>.{domain}</option> 365 + {/each} 366 + </select> 367 + {/if} 368 + </div> 369 + 370 + {#if checkingHandle} 371 + <p class="hint">Checking availability...</p> 372 + {:else if handleAvailable === true} 373 + <p class="hint success">Handle is available!</p> 374 + {:else if handleAvailable === false} 375 + <p class="hint error">Handle is already taken</p> 376 + {:else} 377 + <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 378 + {/if} 379 + </div> 380 + 381 + <div class="field"> 382 + <label for="email">Email Address</label> 383 + <input 384 + id="email" 385 + type="email" 386 + placeholder="you@example.com" 387 + bind:value={flow.state.targetEmail} 388 + oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 389 + required 390 + /> 391 + </div> 392 + 393 + <div class="field"> 394 + <label for="new-password">Password</label> 395 + <input 396 + id="new-password" 397 + type="password" 398 + placeholder="Password for your new account" 399 + bind:value={flow.state.targetPassword} 400 + oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 401 + required 402 + minlength="8" 403 + /> 404 + <p class="hint">At least 8 characters</p> 405 + </div> 406 + 407 + {#if serverInfo?.inviteCodeRequired} 408 + <div class="field"> 409 + <label for="invite">Invite Code</label> 410 + <input 411 + id="invite" 412 + type="text" 413 + placeholder="Enter invite code" 414 + bind:value={flow.state.inviteCode} 415 + oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 416 + required 417 + /> 418 + </div> 419 + {/if} 420 + 421 + <div class="button-row"> 422 + <button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button> 423 + <button 424 + disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false} 425 + onclick={proceedToReview} 426 + > 427 + Continue 428 + </button> 429 + </div> 430 + </div> 431 + 432 + {:else if flow.state.step === 'review'} 433 + <div class="step-content"> 434 + <h2>Review Migration</h2> 435 + <p>Please confirm the details of your migration.</p> 436 + 437 + <div class="review-card"> 438 + <div class="review-row"> 439 + <span class="label">Current Handle:</span> 440 + <span class="value">{flow.state.sourceHandle}</span> 441 + </div> 442 + <div class="review-row"> 443 + <span class="label">New Handle:</span> 444 + <span class="value">{flow.state.targetHandle}</span> 445 + </div> 446 + <div class="review-row"> 447 + <span class="label">DID:</span> 448 + <span class="value mono">{flow.state.sourceDid}</span> 449 + </div> 450 + <div class="review-row"> 451 + <span class="label">From PDS:</span> 452 + <span class="value">{flow.state.sourcePdsUrl}</span> 453 + </div> 454 + <div class="review-row"> 455 + <span class="label">To PDS:</span> 456 + <span class="value">{window.location.origin}</span> 457 + </div> 458 + <div class="review-row"> 459 + <span class="label">Email:</span> 460 + <span class="value">{flow.state.targetEmail}</span> 461 + </div> 462 + </div> 463 + 464 + <div class="warning-box"> 465 + <strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin 466 + transferring. This process cannot be easily undone. 467 + </div> 468 + 469 + <div class="button-row"> 470 + <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button> 471 + <button onclick={startMigration} disabled={loading}> 472 + {loading ? 'Starting...' : 'Start Migration'} 473 + </button> 474 + </div> 475 + </div> 476 + 477 + {:else if flow.state.step === 'migrating'} 478 + <div class="step-content"> 479 + <h2>Migration in Progress</h2> 480 + <p>Please wait while your account is being transferred...</p> 481 + 482 + <div class="progress-section"> 483 + <div class="progress-item" class:completed={flow.state.progress.repoExported}> 484 + <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 485 + <span>Export repository</span> 486 + </div> 487 + <div class="progress-item" class:completed={flow.state.progress.repoImported}> 488 + <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 489 + <span>Import repository</span> 490 + </div> 491 + <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 492 + <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 493 + <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 494 + </div> 495 + <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 496 + <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 497 + <span>Migrate preferences</span> 498 + </div> 499 + </div> 500 + 501 + {#if flow.state.progress.blobsTotal > 0} 502 + <div class="progress-bar"> 503 + <div 504 + class="progress-fill" 505 + style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 506 + ></div> 507 + </div> 508 + {/if} 509 + 510 + <p class="status-text">{flow.state.progress.currentOperation}</p> 511 + </div> 512 + 513 + {:else if flow.state.step === 'email-verify'} 514 + <div class="step-content"> 515 + <h2>{$_('migration.inbound.emailVerify.title')}</h2> 516 + <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 517 + 518 + <div class="info-box"> 519 + <p> 520 + {$_('migration.inbound.emailVerify.hint')} 521 + </p> 522 + </div> 523 + 524 + {#if flow.state.error} 525 + <div class="error-box"> 526 + {flow.state.error} 527 + </div> 528 + {/if} 529 + 530 + <form onsubmit={submitEmailVerify}> 531 + <div class="field"> 532 + <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 533 + <input 534 + id="email-verify-token" 535 + type="text" 536 + placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 537 + bind:value={flow.state.emailVerifyToken} 538 + oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 539 + disabled={loading} 540 + required 541 + /> 542 + </div> 543 + 544 + <div class="button-row"> 545 + <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 546 + {$_('migration.inbound.emailVerify.resend')} 547 + </button> 548 + <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 549 + {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 550 + </button> 551 + </div> 552 + </form> 553 + </div> 554 + 555 + {:else if flow.state.step === 'plc-token'} 556 + <div class="step-content"> 557 + <h2>Verify Migration</h2> 558 + <p>A verification code has been sent to the email registered with your old account.</p> 559 + 560 + <div class="info-box"> 561 + <p> 562 + This code confirms you have access to the account and authorizes updating your identity 563 + to point to this PDS. 564 + </p> 565 + </div> 566 + 567 + <form onsubmit={submitPlcToken}> 568 + <div class="field"> 569 + <label for="plc-token">Verification Code</label> 570 + <input 571 + id="plc-token" 572 + type="text" 573 + placeholder="Enter code from email" 574 + bind:value={flow.state.plcToken} 575 + oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 576 + disabled={loading} 577 + required 578 + /> 579 + </div> 580 + 581 + <div class="button-row"> 582 + <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 583 + Resend Code 584 + </button> 585 + <button type="submit" disabled={loading || !flow.state.plcToken}> 586 + {loading ? 'Verifying...' : 'Complete Migration'} 587 + </button> 588 + </div> 589 + </form> 590 + </div> 591 + 592 + {:else if flow.state.step === 'finalizing'} 593 + <div class="step-content"> 594 + <h2>Finalizing Migration</h2> 595 + <p>Please wait while we complete the migration...</p> 596 + 597 + <div class="progress-section"> 598 + <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 599 + <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 600 + <span>Sign identity update</span> 601 + </div> 602 + <div class="progress-item" class:completed={flow.state.progress.activated}> 603 + <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 604 + <span>Activate new account</span> 605 + </div> 606 + <div class="progress-item" class:completed={flow.state.progress.deactivated}> 607 + <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 608 + <span>Deactivate old account</span> 609 + </div> 610 + </div> 611 + 612 + <p class="status-text">{flow.state.progress.currentOperation}</p> 613 + </div> 614 + 615 + {:else if flow.state.step === 'success'} 616 + <div class="step-content success-content"> 617 + <div class="success-icon">✓</div> 618 + <h2>Migration Complete!</h2> 619 + <p>Your account has been successfully migrated to this PDS.</p> 620 + 621 + <div class="success-details"> 622 + <div class="detail-row"> 623 + <span class="label">Your new handle:</span> 624 + <span class="value">{flow.state.targetHandle}</span> 625 + </div> 626 + <div class="detail-row"> 627 + <span class="label">DID:</span> 628 + <span class="value mono">{flow.state.sourceDid}</span> 629 + </div> 630 + </div> 631 + 632 + {#if flow.state.progress.blobsFailed.length > 0} 633 + <div class="warning-box"> 634 + <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 635 + These may be images or other media that are no longer available. 636 + </div> 637 + {/if} 638 + 639 + <p class="redirect-text">Redirecting to dashboard...</p> 640 + </div> 641 + 642 + {:else if flow.state.step === 'error'} 643 + <div class="step-content"> 644 + <h2>Migration Error</h2> 645 + <p>An error occurred during migration.</p> 646 + 647 + <div class="error-box"> 648 + {flow.state.error} 649 + </div> 650 + 651 + <div class="button-row"> 652 + <button class="ghost" onclick={onBack}>Start Over</button> 653 + </div> 654 + </div> 655 + {/if} 656 + </div> 657 + 658 + <style> 659 + .inbound-wizard { 660 + max-width: 600px; 661 + margin: 0 auto; 662 + } 663 + 664 + .step-indicator { 665 + display: flex; 666 + align-items: center; 667 + justify-content: center; 668 + margin-bottom: var(--space-8); 669 + padding: 0 var(--space-4); 670 + } 671 + 672 + .step { 673 + display: flex; 674 + flex-direction: column; 675 + align-items: center; 676 + gap: var(--space-2); 677 + } 678 + 679 + .step-dot { 680 + width: 32px; 681 + height: 32px; 682 + border-radius: 50%; 683 + background: var(--bg-secondary); 684 + border: 2px solid var(--border); 685 + display: flex; 686 + align-items: center; 687 + justify-content: center; 688 + font-size: var(--text-sm); 689 + font-weight: var(--font-medium); 690 + color: var(--text-secondary); 691 + } 692 + 693 + .step.active .step-dot { 694 + background: var(--accent); 695 + border-color: var(--accent); 696 + color: var(--text-inverse); 697 + } 698 + 699 + .step.completed .step-dot { 700 + background: var(--success-bg); 701 + border-color: var(--success-text); 702 + color: var(--success-text); 703 + } 704 + 705 + .step-label { 706 + font-size: var(--text-xs); 707 + color: var(--text-secondary); 708 + } 709 + 710 + .step.active .step-label { 711 + color: var(--accent); 712 + font-weight: var(--font-medium); 713 + } 714 + 715 + .step-line { 716 + flex: 1; 717 + height: 2px; 718 + background: var(--border); 719 + margin: 0 var(--space-2); 720 + margin-bottom: var(--space-6); 721 + min-width: 20px; 722 + } 723 + 724 + .step-line.completed { 725 + background: var(--success-text); 726 + } 727 + 728 + .step-content { 729 + background: var(--bg-secondary); 730 + border-radius: var(--radius-xl); 731 + padding: var(--space-6); 732 + } 733 + 734 + .step-content h2 { 735 + margin: 0 0 var(--space-3) 0; 736 + } 737 + 738 + .step-content > p { 739 + color: var(--text-secondary); 740 + margin: 0 0 var(--space-5) 0; 741 + } 742 + 743 + .info-box { 744 + background: var(--accent-muted); 745 + border: 1px solid var(--accent); 746 + border-radius: var(--radius-lg); 747 + padding: var(--space-5); 748 + margin-bottom: var(--space-5); 749 + } 750 + 751 + .info-box h3 { 752 + margin: 0 0 var(--space-3) 0; 753 + font-size: var(--text-base); 754 + } 755 + 756 + .info-box ol, .info-box ul { 757 + margin: 0; 758 + padding-left: var(--space-5); 759 + } 760 + 761 + .info-box li { 762 + margin-bottom: var(--space-2); 763 + color: var(--text-secondary); 764 + } 765 + 766 + .info-box p { 767 + margin: 0; 768 + color: var(--text-secondary); 769 + } 770 + 771 + .warning-box { 772 + background: var(--warning-bg); 773 + border: 1px solid var(--warning-border); 774 + border-radius: var(--radius-lg); 775 + padding: var(--space-5); 776 + margin-bottom: var(--space-5); 777 + font-size: var(--text-sm); 778 + } 779 + 780 + .warning-box strong { 781 + color: var(--warning-text); 782 + } 783 + 784 + .warning-box ul { 785 + margin: var(--space-3) 0 0 0; 786 + padding-left: var(--space-5); 787 + } 788 + 789 + .error-box { 790 + background: var(--error-bg); 791 + border: 1px solid var(--error-border); 792 + border-radius: var(--radius-lg); 793 + padding: var(--space-5); 794 + margin-bottom: var(--space-5); 795 + color: var(--error-text); 796 + } 797 + 798 + .checkbox-label { 799 + display: inline-flex; 800 + align-items: flex-start; 801 + gap: var(--space-3); 802 + cursor: pointer; 803 + margin-bottom: var(--space-5); 804 + text-align: left; 805 + } 806 + 807 + .checkbox-label input[type="checkbox"] { 808 + width: 18px; 809 + height: 18px; 810 + margin: 0; 811 + flex-shrink: 0; 812 + } 813 + 814 + .button-row { 815 + display: flex; 816 + gap: var(--space-3); 817 + justify-content: flex-end; 818 + margin-top: var(--space-5); 819 + } 820 + 821 + .field { 822 + margin-bottom: var(--space-5); 823 + } 824 + 825 + .field label { 826 + display: block; 827 + margin-bottom: var(--space-2); 828 + font-weight: var(--font-medium); 829 + } 830 + 831 + .field input, .field select { 832 + width: 100%; 833 + padding: var(--space-3); 834 + border: 1px solid var(--border); 835 + border-radius: var(--radius-md); 836 + background: var(--bg-primary); 837 + color: var(--text-primary); 838 + } 839 + 840 + .field input:focus, .field select:focus { 841 + outline: none; 842 + border-color: var(--accent); 843 + } 844 + 845 + .hint { 846 + font-size: var(--text-sm); 847 + color: var(--text-secondary); 848 + margin: var(--space-2) 0 0 0; 849 + } 850 + 851 + .hint.success { 852 + color: var(--success-text); 853 + } 854 + 855 + .hint.error { 856 + color: var(--error-text); 857 + } 858 + 859 + .handle-input-group { 860 + display: flex; 861 + gap: var(--space-2); 862 + } 863 + 864 + .handle-input-group input { 865 + flex: 1; 866 + } 867 + 868 + .handle-input-group select { 869 + width: auto; 870 + } 871 + 872 + .current-info { 873 + background: var(--bg-primary); 874 + border-radius: var(--radius-lg); 875 + padding: var(--space-4); 876 + margin-bottom: var(--space-5); 877 + display: flex; 878 + justify-content: space-between; 879 + } 880 + 881 + .current-info .label { 882 + color: var(--text-secondary); 883 + } 884 + 885 + .current-info .value { 886 + font-weight: var(--font-medium); 887 + } 888 + 889 + .review-card { 890 + background: var(--bg-primary); 891 + border-radius: var(--radius-lg); 892 + padding: var(--space-4); 893 + margin-bottom: var(--space-5); 894 + } 895 + 896 + .review-row { 897 + display: flex; 898 + justify-content: space-between; 899 + padding: var(--space-3) 0; 900 + border-bottom: 1px solid var(--border); 901 + } 902 + 903 + .review-row:last-child { 904 + border-bottom: none; 905 + } 906 + 907 + .review-row .label { 908 + color: var(--text-secondary); 909 + } 910 + 911 + .review-row .value { 912 + font-weight: var(--font-medium); 913 + text-align: right; 914 + word-break: break-all; 915 + } 916 + 917 + .review-row .value.mono { 918 + font-family: var(--font-mono); 919 + font-size: var(--text-sm); 920 + } 921 + 922 + .progress-section { 923 + margin-bottom: var(--space-5); 924 + } 925 + 926 + .progress-item { 927 + display: flex; 928 + align-items: center; 929 + gap: var(--space-3); 930 + padding: var(--space-3) 0; 931 + color: var(--text-secondary); 932 + } 933 + 934 + .progress-item.completed { 935 + color: var(--success-text); 936 + } 937 + 938 + .progress-item.active { 939 + color: var(--accent); 940 + } 941 + 942 + .progress-item .icon { 943 + width: 24px; 944 + text-align: center; 945 + } 946 + 947 + .progress-bar { 948 + height: 8px; 949 + background: var(--bg-primary); 950 + border-radius: 4px; 951 + overflow: hidden; 952 + margin-bottom: var(--space-4); 953 + } 954 + 955 + .progress-fill { 956 + height: 100%; 957 + background: var(--accent); 958 + transition: width 0.3s ease; 959 + } 960 + 961 + .status-text { 962 + text-align: center; 963 + color: var(--text-secondary); 964 + font-size: var(--text-sm); 965 + } 966 + 967 + .success-content { 968 + text-align: center; 969 + } 970 + 971 + .success-icon { 972 + width: 64px; 973 + height: 64px; 974 + background: var(--success-bg); 975 + color: var(--success-text); 976 + border-radius: 50%; 977 + display: flex; 978 + align-items: center; 979 + justify-content: center; 980 + font-size: var(--text-2xl); 981 + margin: 0 auto var(--space-5) auto; 982 + } 983 + 984 + .success-details { 985 + background: var(--bg-primary); 986 + border-radius: var(--radius-lg); 987 + padding: var(--space-4); 988 + margin: var(--space-5) 0; 989 + text-align: left; 990 + } 991 + 992 + .success-details .detail-row { 993 + display: flex; 994 + justify-content: space-between; 995 + padding: var(--space-2) 0; 996 + } 997 + 998 + .success-details .label { 999 + color: var(--text-secondary); 1000 + } 1001 + 1002 + .success-details .value { 1003 + font-weight: var(--font-medium); 1004 + } 1005 + 1006 + .success-details .value.mono { 1007 + font-family: var(--font-mono); 1008 + font-size: var(--text-sm); 1009 + } 1010 + 1011 + .redirect-text { 1012 + color: var(--text-secondary); 1013 + font-style: italic; 1014 + } 1015 + 1016 + .message.error { 1017 + background: var(--error-bg); 1018 + border: 1px solid var(--error-border); 1019 + color: var(--error-text); 1020 + padding: var(--space-4); 1021 + border-radius: var(--radius-lg); 1022 + margin-bottom: var(--space-5); 1023 + } 1024 + </style>
+992
frontend/src/components/migration/OutboundWizard.svelte
···
··· 1 + <script lang="ts"> 2 + import type { OutboundMigrationFlow } from '../../lib/migration' 3 + import type { ServerDescription } from '../../lib/migration/types' 4 + import { getAuthState, logout } from '../../lib/auth.svelte' 5 + 6 + interface Props { 7 + flow: OutboundMigrationFlow 8 + onBack: () => void 9 + onComplete: () => void 10 + } 11 + 12 + let { flow, onBack, onComplete }: Props = $props() 13 + 14 + const auth = getAuthState() 15 + 16 + let loading = $state(false) 17 + let understood = $state(false) 18 + let pdsUrlInput = $state('') 19 + let handleInput = $state('') 20 + let selectedDomain = $state('') 21 + let confirmFinal = $state(false) 22 + 23 + $effect(() => { 24 + if (flow.state.step === 'success') { 25 + setTimeout(async () => { 26 + await logout() 27 + onComplete() 28 + }, 3000) 29 + } 30 + }) 31 + 32 + $effect(() => { 33 + if (flow.state.targetServerInfo?.availableUserDomains?.length) { 34 + selectedDomain = flow.state.targetServerInfo.availableUserDomains[0] 35 + } 36 + }) 37 + 38 + async function validatePds(e: Event) { 39 + e.preventDefault() 40 + loading = true 41 + flow.updateField('error', null) 42 + 43 + try { 44 + let url = pdsUrlInput.trim() 45 + if (!url.startsWith('http://') && !url.startsWith('https://')) { 46 + url = `https://${url}` 47 + } 48 + await flow.validateTargetPds(url) 49 + flow.setStep('new-account') 50 + } catch (err) { 51 + flow.setError((err as Error).message) 52 + } finally { 53 + loading = false 54 + } 55 + } 56 + 57 + function proceedToReview() { 58 + const fullHandle = handleInput.includes('.') 59 + ? handleInput 60 + : `${handleInput}.${selectedDomain}` 61 + 62 + flow.updateField('targetHandle', fullHandle) 63 + flow.setStep('review') 64 + } 65 + 66 + async function startMigration() { 67 + if (!auth.session) return 68 + loading = true 69 + try { 70 + await flow.startMigration(auth.session.did) 71 + } catch (err) { 72 + flow.setError((err as Error).message) 73 + } finally { 74 + loading = false 75 + } 76 + } 77 + 78 + async function submitPlcToken(e: Event) { 79 + e.preventDefault() 80 + loading = true 81 + try { 82 + await flow.submitPlcToken(flow.state.plcToken) 83 + } catch (err) { 84 + flow.setError((err as Error).message) 85 + } finally { 86 + loading = false 87 + } 88 + } 89 + 90 + async function resendToken() { 91 + loading = true 92 + try { 93 + await flow.resendPlcToken() 94 + flow.setError(null) 95 + } catch (err) { 96 + flow.setError((err as Error).message) 97 + } finally { 98 + loading = false 99 + } 100 + } 101 + 102 + function isDidWeb(): boolean { 103 + return auth.session?.did?.startsWith('did:web:') ?? false 104 + } 105 + 106 + const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete'] 107 + function getCurrentStepIndex(): number { 108 + switch (flow.state.step) { 109 + case 'welcome': return -1 110 + case 'target-pds': return 0 111 + case 'new-account': return 1 112 + case 'review': return 2 113 + case 'migrating': return 3 114 + case 'plc-token': 115 + case 'finalizing': return 4 116 + case 'success': return 5 117 + default: return 0 118 + } 119 + } 120 + </script> 121 + 122 + <div class="outbound-wizard"> 123 + {#if flow.state.step !== 'welcome'} 124 + <div class="step-indicator"> 125 + {#each steps as stepName, i} 126 + <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 127 + <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 128 + <span class="step-label">{stepName}</span> 129 + </div> 130 + {#if i < steps.length - 1} 131 + <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 132 + {/if} 133 + {/each} 134 + </div> 135 + {/if} 136 + 137 + {#if flow.state.error} 138 + <div class="message error">{flow.state.error}</div> 139 + {/if} 140 + 141 + {#if flow.state.step === 'welcome'} 142 + <div class="step-content"> 143 + <h2>Migrate Your Account Away</h2> 144 + <p>This wizard will help you move your AT Protocol account from this PDS to another one.</p> 145 + 146 + <div class="current-account"> 147 + <span class="label">Current account:</span> 148 + <span class="value">@{auth.session?.handle}</span> 149 + </div> 150 + 151 + {#if isDidWeb()} 152 + <div class="warning-box"> 153 + <strong>did:web Migration Notice</strong> 154 + <p> 155 + Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will 156 + continue serving your DID document with an updated service endpoint pointing to your new PDS. 157 + </p> 158 + <p> 159 + You can return here anytime to update the forwarding if you migrate again in the future. 160 + </p> 161 + </div> 162 + {/if} 163 + 164 + <div class="info-box"> 165 + <h3>What will happen:</h3> 166 + <ol> 167 + <li>Choose your new PDS</li> 168 + <li>Set up your account on the new server</li> 169 + <li>Your repository and blobs will be transferred</li> 170 + <li>Verify the migration via email</li> 171 + <li>Your identity will be updated to point to the new PDS</li> 172 + <li>Your account here will be deactivated</li> 173 + </ol> 174 + </div> 175 + 176 + <div class="warning-box"> 177 + <strong>Before you proceed:</strong> 178 + <ul> 179 + <li>You need access to the email registered with this account</li> 180 + <li>You will lose access to this account on this PDS</li> 181 + <li>Make sure you trust the destination PDS</li> 182 + <li>Large accounts may take several minutes to transfer</li> 183 + </ul> 184 + </div> 185 + 186 + <label class="checkbox-label"> 187 + <input type="checkbox" bind:checked={understood} /> 188 + <span>I understand that my account will be moved and deactivated here</span> 189 + </label> 190 + 191 + <div class="button-row"> 192 + <button class="ghost" onclick={onBack}>Cancel</button> 193 + <button disabled={!understood} onclick={() => flow.setStep('target-pds')}> 194 + Continue 195 + </button> 196 + </div> 197 + </div> 198 + 199 + {:else if flow.state.step === 'target-pds'} 200 + <div class="step-content"> 201 + <h2>Choose Your New PDS</h2> 202 + <p>Enter the URL of the PDS you want to migrate to.</p> 203 + 204 + <form onsubmit={validatePds}> 205 + <div class="field"> 206 + <label for="pds-url">PDS URL</label> 207 + <input 208 + id="pds-url" 209 + type="text" 210 + placeholder="pds.example.com" 211 + bind:value={pdsUrlInput} 212 + disabled={loading} 213 + required 214 + /> 215 + <p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 216 + </div> 217 + 218 + <div class="button-row"> 219 + <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button> 220 + <button type="submit" disabled={loading || !pdsUrlInput.trim()}> 221 + {loading ? 'Checking...' : 'Connect'} 222 + </button> 223 + </div> 224 + </form> 225 + 226 + {#if flow.state.targetServerInfo} 227 + <div class="server-info"> 228 + <h3>Connected to PDS</h3> 229 + <div class="info-row"> 230 + <span class="label">Server:</span> 231 + <span class="value">{flow.state.targetPdsUrl}</span> 232 + </div> 233 + {#if flow.state.targetServerInfo.availableUserDomains.length > 0} 234 + <div class="info-row"> 235 + <span class="label">Available domains:</span> 236 + <span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span> 237 + </div> 238 + {/if} 239 + <div class="info-row"> 240 + <span class="label">Invite required:</span> 241 + <span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span> 242 + </div> 243 + {#if flow.state.targetServerInfo.links?.termsOfService} 244 + <a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener"> 245 + Terms of Service 246 + </a> 247 + {/if} 248 + {#if flow.state.targetServerInfo.links?.privacyPolicy} 249 + <a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener"> 250 + Privacy Policy 251 + </a> 252 + {/if} 253 + </div> 254 + {/if} 255 + </div> 256 + 257 + {:else if flow.state.step === 'new-account'} 258 + <div class="step-content"> 259 + <h2>Set Up Your New Account</h2> 260 + <p>Configure your account details on the new PDS.</p> 261 + 262 + <div class="current-info"> 263 + <span class="label">Migrating to:</span> 264 + <span class="value">{flow.state.targetPdsUrl}</span> 265 + </div> 266 + 267 + <div class="field"> 268 + <label for="new-handle">New Handle</label> 269 + <div class="handle-input-group"> 270 + <input 271 + id="new-handle" 272 + type="text" 273 + placeholder="username" 274 + bind:value={handleInput} 275 + /> 276 + {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 277 + <select bind:value={selectedDomain}> 278 + {#each flow.state.targetServerInfo.availableUserDomains as domain} 279 + <option value={domain}>.{domain}</option> 280 + {/each} 281 + </select> 282 + {/if} 283 + </div> 284 + <p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 285 + </div> 286 + 287 + <div class="field"> 288 + <label for="email">Email Address</label> 289 + <input 290 + id="email" 291 + type="email" 292 + placeholder="you@example.com" 293 + bind:value={flow.state.targetEmail} 294 + oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 295 + required 296 + /> 297 + </div> 298 + 299 + <div class="field"> 300 + <label for="new-password">Password</label> 301 + <input 302 + id="new-password" 303 + type="password" 304 + placeholder="Password for your new account" 305 + bind:value={flow.state.targetPassword} 306 + oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 307 + required 308 + minlength="8" 309 + /> 310 + <p class="hint">At least 8 characters. This will be your password on the new PDS.</p> 311 + </div> 312 + 313 + {#if flow.state.targetServerInfo?.inviteCodeRequired} 314 + <div class="field"> 315 + <label for="invite">Invite Code</label> 316 + <input 317 + id="invite" 318 + type="text" 319 + placeholder="Enter invite code" 320 + bind:value={flow.state.inviteCode} 321 + oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 322 + required 323 + /> 324 + <p class="hint">Required by this PDS to create an account</p> 325 + </div> 326 + {/if} 327 + 328 + <div class="button-row"> 329 + <button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button> 330 + <button 331 + disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword} 332 + onclick={proceedToReview} 333 + > 334 + Continue 335 + </button> 336 + </div> 337 + </div> 338 + 339 + {:else if flow.state.step === 'review'} 340 + <div class="step-content"> 341 + <h2>Review Migration</h2> 342 + <p>Please confirm the details of your migration.</p> 343 + 344 + <div class="review-card"> 345 + <div class="review-row"> 346 + <span class="label">Current Handle:</span> 347 + <span class="value">@{auth.session?.handle}</span> 348 + </div> 349 + <div class="review-row"> 350 + <span class="label">New Handle:</span> 351 + <span class="value">@{flow.state.targetHandle}</span> 352 + </div> 353 + <div class="review-row"> 354 + <span class="label">DID:</span> 355 + <span class="value mono">{auth.session?.did}</span> 356 + </div> 357 + <div class="review-row"> 358 + <span class="label">From PDS:</span> 359 + <span class="value">{window.location.origin}</span> 360 + </div> 361 + <div class="review-row"> 362 + <span class="label">To PDS:</span> 363 + <span class="value">{flow.state.targetPdsUrl}</span> 364 + </div> 365 + <div class="review-row"> 366 + <span class="label">New Email:</span> 367 + <span class="value">{flow.state.targetEmail}</span> 368 + </div> 369 + </div> 370 + 371 + <div class="warning-box final-warning"> 372 + <strong>This action cannot be easily undone!</strong> 373 + <p> 374 + After migration completes, your account on this PDS will be deactivated. 375 + To return, you would need to migrate back from the new PDS. 376 + </p> 377 + </div> 378 + 379 + <label class="checkbox-label"> 380 + <input type="checkbox" bind:checked={confirmFinal} /> 381 + <span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span> 382 + </label> 383 + 384 + <div class="button-row"> 385 + <button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button> 386 + <button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}> 387 + {loading ? 'Starting...' : 'Start Migration'} 388 + </button> 389 + </div> 390 + </div> 391 + 392 + {:else if flow.state.step === 'migrating'} 393 + <div class="step-content"> 394 + <h2>Migration in Progress</h2> 395 + <p>Please wait while your account is being transferred...</p> 396 + 397 + <div class="progress-section"> 398 + <div class="progress-item" class:completed={flow.state.progress.repoExported}> 399 + <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 400 + <span>Export repository</span> 401 + </div> 402 + <div class="progress-item" class:completed={flow.state.progress.repoImported}> 403 + <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 404 + <span>Import repository to new PDS</span> 405 + </div> 406 + <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 407 + <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 408 + <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 409 + </div> 410 + <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 411 + <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 412 + <span>Migrate preferences</span> 413 + </div> 414 + </div> 415 + 416 + {#if flow.state.progress.blobsTotal > 0} 417 + <div class="progress-bar"> 418 + <div 419 + class="progress-fill" 420 + style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 421 + ></div> 422 + </div> 423 + {/if} 424 + 425 + <p class="status-text">{flow.state.progress.currentOperation}</p> 426 + </div> 427 + 428 + {:else if flow.state.step === 'plc-token'} 429 + <div class="step-content"> 430 + <h2>Verify Migration</h2> 431 + <p>A verification code has been sent to your email ({auth.session?.email}).</p> 432 + 433 + <div class="info-box"> 434 + <p> 435 + This code confirms you have access to the account and authorizes updating your identity 436 + to point to the new PDS. 437 + </p> 438 + </div> 439 + 440 + <form onsubmit={submitPlcToken}> 441 + <div class="field"> 442 + <label for="plc-token">Verification Code</label> 443 + <input 444 + id="plc-token" 445 + type="text" 446 + placeholder="Enter code from email" 447 + bind:value={flow.state.plcToken} 448 + oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 449 + disabled={loading} 450 + required 451 + /> 452 + </div> 453 + 454 + <div class="button-row"> 455 + <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 456 + Resend Code 457 + </button> 458 + <button type="submit" disabled={loading || !flow.state.plcToken}> 459 + {loading ? 'Verifying...' : 'Complete Migration'} 460 + </button> 461 + </div> 462 + </form> 463 + </div> 464 + 465 + {:else if flow.state.step === 'finalizing'} 466 + <div class="step-content"> 467 + <h2>Finalizing Migration</h2> 468 + <p>Please wait while we complete the migration...</p> 469 + 470 + <div class="progress-section"> 471 + <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 472 + <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 473 + <span>Sign identity update</span> 474 + </div> 475 + <div class="progress-item" class:completed={flow.state.progress.activated}> 476 + <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 477 + <span>Activate account on new PDS</span> 478 + </div> 479 + <div class="progress-item" class:completed={flow.state.progress.deactivated}> 480 + <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 481 + <span>Deactivate account here</span> 482 + </div> 483 + </div> 484 + 485 + <p class="status-text">{flow.state.progress.currentOperation}</p> 486 + </div> 487 + 488 + {:else if flow.state.step === 'success'} 489 + <div class="step-content success-content"> 490 + <div class="success-icon">✓</div> 491 + <h2>Migration Complete!</h2> 492 + <p>Your account has been successfully migrated to your new PDS.</p> 493 + 494 + <div class="success-details"> 495 + <div class="detail-row"> 496 + <span class="label">Your new handle:</span> 497 + <span class="value">@{flow.state.targetHandle}</span> 498 + </div> 499 + <div class="detail-row"> 500 + <span class="label">New PDS:</span> 501 + <span class="value">{flow.state.targetPdsUrl}</span> 502 + </div> 503 + <div class="detail-row"> 504 + <span class="label">DID:</span> 505 + <span class="value mono">{auth.session?.did}</span> 506 + </div> 507 + </div> 508 + 509 + {#if flow.state.progress.blobsFailed.length > 0} 510 + <div class="warning-box"> 511 + <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 512 + These may be images or other media that are no longer available. 513 + </div> 514 + {/if} 515 + 516 + <div class="next-steps"> 517 + <h3>Next Steps</h3> 518 + <ol> 519 + <li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li> 520 + <li>Log in with your new credentials</li> 521 + <li>Your followers and following will continue to work</li> 522 + </ol> 523 + </div> 524 + 525 + <p class="redirect-text">Logging out in a moment...</p> 526 + </div> 527 + 528 + {:else if flow.state.step === 'error'} 529 + <div class="step-content"> 530 + <h2>Migration Error</h2> 531 + <p>An error occurred during migration.</p> 532 + 533 + <div class="error-box"> 534 + {flow.state.error} 535 + </div> 536 + 537 + <div class="button-row"> 538 + <button class="ghost" onclick={onBack}>Start Over</button> 539 + </div> 540 + </div> 541 + {/if} 542 + </div> 543 + 544 + <style> 545 + .outbound-wizard { 546 + max-width: 600px; 547 + margin: 0 auto; 548 + } 549 + 550 + .step-indicator { 551 + display: flex; 552 + align-items: center; 553 + justify-content: center; 554 + margin-bottom: var(--space-8); 555 + padding: 0 var(--space-4); 556 + } 557 + 558 + .step { 559 + display: flex; 560 + flex-direction: column; 561 + align-items: center; 562 + gap: var(--space-2); 563 + } 564 + 565 + .step-dot { 566 + width: 32px; 567 + height: 32px; 568 + border-radius: 50%; 569 + background: var(--bg-secondary); 570 + border: 2px solid var(--border); 571 + display: flex; 572 + align-items: center; 573 + justify-content: center; 574 + font-size: var(--text-sm); 575 + font-weight: var(--font-medium); 576 + color: var(--text-secondary); 577 + } 578 + 579 + .step.active .step-dot { 580 + background: var(--accent); 581 + border-color: var(--accent); 582 + color: var(--text-inverse); 583 + } 584 + 585 + .step.completed .step-dot { 586 + background: var(--success-bg); 587 + border-color: var(--success-text); 588 + color: var(--success-text); 589 + } 590 + 591 + .step-label { 592 + font-size: var(--text-xs); 593 + color: var(--text-secondary); 594 + } 595 + 596 + .step.active .step-label { 597 + color: var(--accent); 598 + font-weight: var(--font-medium); 599 + } 600 + 601 + .step-line { 602 + flex: 1; 603 + height: 2px; 604 + background: var(--border); 605 + margin: 0 var(--space-2); 606 + margin-bottom: var(--space-6); 607 + min-width: 20px; 608 + } 609 + 610 + .step-line.completed { 611 + background: var(--success-text); 612 + } 613 + 614 + .step-content { 615 + background: var(--bg-secondary); 616 + border-radius: var(--radius-xl); 617 + padding: var(--space-6); 618 + } 619 + 620 + .step-content h2 { 621 + margin: 0 0 var(--space-3) 0; 622 + } 623 + 624 + .step-content > p { 625 + color: var(--text-secondary); 626 + margin: 0 0 var(--space-5) 0; 627 + } 628 + 629 + .current-account { 630 + background: var(--bg-primary); 631 + border-radius: var(--radius-lg); 632 + padding: var(--space-4); 633 + margin-bottom: var(--space-5); 634 + display: flex; 635 + justify-content: space-between; 636 + align-items: center; 637 + } 638 + 639 + .current-account .label { 640 + color: var(--text-secondary); 641 + } 642 + 643 + .current-account .value { 644 + font-weight: var(--font-medium); 645 + font-size: var(--text-lg); 646 + } 647 + 648 + .info-box { 649 + background: var(--accent-muted); 650 + border: 1px solid var(--accent); 651 + border-radius: var(--radius-lg); 652 + padding: var(--space-5); 653 + margin-bottom: var(--space-5); 654 + } 655 + 656 + .info-box h3 { 657 + margin: 0 0 var(--space-3) 0; 658 + font-size: var(--text-base); 659 + } 660 + 661 + .info-box ol, .info-box ul { 662 + margin: 0; 663 + padding-left: var(--space-5); 664 + } 665 + 666 + .info-box li { 667 + margin-bottom: var(--space-2); 668 + color: var(--text-secondary); 669 + } 670 + 671 + .info-box p { 672 + margin: 0; 673 + color: var(--text-secondary); 674 + } 675 + 676 + .warning-box { 677 + background: var(--warning-bg); 678 + border: 1px solid var(--warning-border); 679 + border-radius: var(--radius-lg); 680 + padding: var(--space-5); 681 + margin-bottom: var(--space-5); 682 + font-size: var(--text-sm); 683 + } 684 + 685 + .warning-box strong { 686 + color: var(--warning-text); 687 + } 688 + 689 + .warning-box p { 690 + margin: var(--space-3) 0 0 0; 691 + color: var(--text-secondary); 692 + } 693 + 694 + .warning-box ul { 695 + margin: var(--space-3) 0 0 0; 696 + padding-left: var(--space-5); 697 + } 698 + 699 + .final-warning { 700 + background: var(--error-bg); 701 + border-color: var(--error-border); 702 + } 703 + 704 + .final-warning strong { 705 + color: var(--error-text); 706 + } 707 + 708 + .error-box { 709 + background: var(--error-bg); 710 + border: 1px solid var(--error-border); 711 + border-radius: var(--radius-lg); 712 + padding: var(--space-5); 713 + margin-bottom: var(--space-5); 714 + color: var(--error-text); 715 + } 716 + 717 + .checkbox-label { 718 + display: inline-flex; 719 + align-items: flex-start; 720 + gap: var(--space-3); 721 + cursor: pointer; 722 + margin-bottom: var(--space-5); 723 + text-align: left; 724 + } 725 + 726 + .checkbox-label input[type="checkbox"] { 727 + width: 18px; 728 + height: 18px; 729 + margin: 0; 730 + flex-shrink: 0; 731 + } 732 + 733 + .button-row { 734 + display: flex; 735 + gap: var(--space-3); 736 + justify-content: flex-end; 737 + margin-top: var(--space-5); 738 + } 739 + 740 + .field { 741 + margin-bottom: var(--space-5); 742 + } 743 + 744 + .field label { 745 + display: block; 746 + margin-bottom: var(--space-2); 747 + font-weight: var(--font-medium); 748 + } 749 + 750 + .field input, .field select { 751 + width: 100%; 752 + padding: var(--space-3); 753 + border: 1px solid var(--border); 754 + border-radius: var(--radius-md); 755 + background: var(--bg-primary); 756 + color: var(--text-primary); 757 + } 758 + 759 + .field input:focus, .field select:focus { 760 + outline: none; 761 + border-color: var(--accent); 762 + } 763 + 764 + .hint { 765 + font-size: var(--text-sm); 766 + color: var(--text-secondary); 767 + margin: var(--space-2) 0 0 0; 768 + } 769 + 770 + .handle-input-group { 771 + display: flex; 772 + gap: var(--space-2); 773 + } 774 + 775 + .handle-input-group input { 776 + flex: 1; 777 + } 778 + 779 + .handle-input-group select { 780 + width: auto; 781 + } 782 + 783 + .current-info { 784 + background: var(--bg-primary); 785 + border-radius: var(--radius-lg); 786 + padding: var(--space-4); 787 + margin-bottom: var(--space-5); 788 + display: flex; 789 + justify-content: space-between; 790 + } 791 + 792 + .current-info .label { 793 + color: var(--text-secondary); 794 + } 795 + 796 + .current-info .value { 797 + font-weight: var(--font-medium); 798 + } 799 + 800 + .server-info { 801 + background: var(--bg-primary); 802 + border-radius: var(--radius-lg); 803 + padding: var(--space-4); 804 + margin-top: var(--space-5); 805 + } 806 + 807 + .server-info h3 { 808 + margin: 0 0 var(--space-3) 0; 809 + font-size: var(--text-base); 810 + color: var(--success-text); 811 + } 812 + 813 + .server-info .info-row { 814 + display: flex; 815 + justify-content: space-between; 816 + padding: var(--space-2) 0; 817 + font-size: var(--text-sm); 818 + } 819 + 820 + .server-info .label { 821 + color: var(--text-secondary); 822 + } 823 + 824 + .server-info a { 825 + display: inline-block; 826 + margin-top: var(--space-2); 827 + margin-right: var(--space-3); 828 + color: var(--accent); 829 + font-size: var(--text-sm); 830 + } 831 + 832 + .review-card { 833 + background: var(--bg-primary); 834 + border-radius: var(--radius-lg); 835 + padding: var(--space-4); 836 + margin-bottom: var(--space-5); 837 + } 838 + 839 + .review-row { 840 + display: flex; 841 + justify-content: space-between; 842 + padding: var(--space-3) 0; 843 + border-bottom: 1px solid var(--border); 844 + } 845 + 846 + .review-row:last-child { 847 + border-bottom: none; 848 + } 849 + 850 + .review-row .label { 851 + color: var(--text-secondary); 852 + } 853 + 854 + .review-row .value { 855 + font-weight: var(--font-medium); 856 + text-align: right; 857 + word-break: break-all; 858 + } 859 + 860 + .review-row .value.mono { 861 + font-family: var(--font-mono); 862 + font-size: var(--text-sm); 863 + } 864 + 865 + .progress-section { 866 + margin-bottom: var(--space-5); 867 + } 868 + 869 + .progress-item { 870 + display: flex; 871 + align-items: center; 872 + gap: var(--space-3); 873 + padding: var(--space-3) 0; 874 + color: var(--text-secondary); 875 + } 876 + 877 + .progress-item.completed { 878 + color: var(--success-text); 879 + } 880 + 881 + .progress-item.active { 882 + color: var(--accent); 883 + } 884 + 885 + .progress-item .icon { 886 + width: 24px; 887 + text-align: center; 888 + } 889 + 890 + .progress-bar { 891 + height: 8px; 892 + background: var(--bg-primary); 893 + border-radius: 4px; 894 + overflow: hidden; 895 + margin-bottom: var(--space-4); 896 + } 897 + 898 + .progress-fill { 899 + height: 100%; 900 + background: var(--accent); 901 + transition: width 0.3s ease; 902 + } 903 + 904 + .status-text { 905 + text-align: center; 906 + color: var(--text-secondary); 907 + font-size: var(--text-sm); 908 + } 909 + 910 + .success-content { 911 + text-align: center; 912 + } 913 + 914 + .success-icon { 915 + width: 64px; 916 + height: 64px; 917 + background: var(--success-bg); 918 + color: var(--success-text); 919 + border-radius: 50%; 920 + display: flex; 921 + align-items: center; 922 + justify-content: center; 923 + font-size: var(--text-2xl); 924 + margin: 0 auto var(--space-5) auto; 925 + } 926 + 927 + .success-details { 928 + background: var(--bg-primary); 929 + border-radius: var(--radius-lg); 930 + padding: var(--space-4); 931 + margin: var(--space-5) 0; 932 + text-align: left; 933 + } 934 + 935 + .success-details .detail-row { 936 + display: flex; 937 + justify-content: space-between; 938 + padding: var(--space-2) 0; 939 + } 940 + 941 + .success-details .label { 942 + color: var(--text-secondary); 943 + } 944 + 945 + .success-details .value { 946 + font-weight: var(--font-medium); 947 + } 948 + 949 + .success-details .value.mono { 950 + font-family: var(--font-mono); 951 + font-size: var(--text-sm); 952 + } 953 + 954 + .next-steps { 955 + background: var(--accent-muted); 956 + border-radius: var(--radius-lg); 957 + padding: var(--space-5); 958 + margin: var(--space-5) 0; 959 + text-align: left; 960 + } 961 + 962 + .next-steps h3 { 963 + margin: 0 0 var(--space-3) 0; 964 + } 965 + 966 + .next-steps ol { 967 + margin: 0; 968 + padding-left: var(--space-5); 969 + } 970 + 971 + .next-steps li { 972 + margin-bottom: var(--space-2); 973 + } 974 + 975 + .next-steps a { 976 + color: var(--accent); 977 + } 978 + 979 + .redirect-text { 980 + color: var(--text-secondary); 981 + font-style: italic; 982 + } 983 + 984 + .message.error { 985 + background: var(--error-bg); 986 + border: 1px solid var(--error-border); 987 + color: var(--error-text); 988 + padding: var(--space-4); 989 + border-radius: var(--radius-lg); 990 + margin-bottom: var(--space-5); 991 + } 992 + </style>
+448
frontend/src/lib/migration/atproto-client.ts
···
··· 1 + import type { 2 + AccountStatus, 3 + BlobRef, 4 + CreateAccountParams, 5 + DidCredentials, 6 + DidDocument, 7 + MigrationError, 8 + PlcOperation, 9 + Preferences, 10 + ServerDescription, 11 + Session, 12 + } from "./types"; 13 + 14 + export class AtprotoClient { 15 + private baseUrl: string; 16 + private accessToken: string | null = null; 17 + 18 + constructor(pdsUrl: string) { 19 + this.baseUrl = pdsUrl.replace(/\/$/, ""); 20 + } 21 + 22 + setAccessToken(token: string | null) { 23 + this.accessToken = token; 24 + } 25 + 26 + getAccessToken(): string | null { 27 + return this.accessToken; 28 + } 29 + 30 + private async xrpc<T>( 31 + method: string, 32 + options?: { 33 + httpMethod?: "GET" | "POST"; 34 + params?: Record<string, string>; 35 + body?: unknown; 36 + authToken?: string; 37 + rawBody?: Uint8Array | Blob; 38 + contentType?: string; 39 + }, 40 + ): Promise<T> { 41 + const { 42 + httpMethod = "GET", 43 + params, 44 + body, 45 + authToken, 46 + rawBody, 47 + contentType, 48 + } = options ?? {}; 49 + 50 + let url = `${this.baseUrl}/xrpc/${method}`; 51 + if (params) { 52 + const searchParams = new URLSearchParams(params); 53 + url += `?${searchParams}`; 54 + } 55 + 56 + const headers: Record<string, string> = {}; 57 + const token = authToken ?? this.accessToken; 58 + if (token) { 59 + headers["Authorization"] = `Bearer ${token}`; 60 + } 61 + 62 + let requestBody: BodyInit | undefined; 63 + if (rawBody) { 64 + headers["Content-Type"] = contentType ?? "application/octet-stream"; 65 + requestBody = rawBody; 66 + } else if (body) { 67 + headers["Content-Type"] = "application/json"; 68 + requestBody = JSON.stringify(body); 69 + } else if (httpMethod === "POST") { 70 + headers["Content-Type"] = "application/json"; 71 + } 72 + 73 + const res = await fetch(url, { 74 + method: httpMethod, 75 + headers, 76 + body: requestBody, 77 + }); 78 + 79 + if (!res.ok) { 80 + const err = await res.json().catch(() => ({ 81 + error: "Unknown", 82 + message: res.statusText, 83 + })); 84 + const error = new Error(err.message) as Error & { 85 + status: number; 86 + error: string; 87 + }; 88 + error.status = res.status; 89 + error.error = err.error; 90 + throw error; 91 + } 92 + 93 + const responseContentType = res.headers.get("content-type") ?? ""; 94 + if (responseContentType.includes("application/json")) { 95 + return res.json(); 96 + } 97 + return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T; 98 + } 99 + 100 + async login( 101 + identifier: string, 102 + password: string, 103 + authFactorToken?: string, 104 + ): Promise<Session> { 105 + const body: Record<string, string> = { identifier, password }; 106 + if (authFactorToken) { 107 + body.authFactorToken = authFactorToken; 108 + } 109 + 110 + const session = await this.xrpc<Session>("com.atproto.server.createSession", { 111 + httpMethod: "POST", 112 + body, 113 + }); 114 + 115 + this.accessToken = session.accessJwt; 116 + return session; 117 + } 118 + 119 + async refreshSession(refreshJwt: string): Promise<Session> { 120 + const session = await this.xrpc<Session>( 121 + "com.atproto.server.refreshSession", 122 + { 123 + httpMethod: "POST", 124 + authToken: refreshJwt, 125 + }, 126 + ); 127 + this.accessToken = session.accessJwt; 128 + return session; 129 + } 130 + 131 + async describeServer(): Promise<ServerDescription> { 132 + return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 133 + } 134 + 135 + async getServiceAuth( 136 + aud: string, 137 + lxm?: string, 138 + ): Promise<{ token: string }> { 139 + const params: Record<string, string> = { aud }; 140 + if (lxm) { 141 + params.lxm = lxm; 142 + } 143 + return this.xrpc("com.atproto.server.getServiceAuth", { params }); 144 + } 145 + 146 + async getRepo(did: string): Promise<Uint8Array> { 147 + return this.xrpc("com.atproto.sync.getRepo", { 148 + params: { did }, 149 + }); 150 + } 151 + 152 + async listBlobs( 153 + did: string, 154 + cursor?: string, 155 + limit = 100, 156 + ): Promise<{ cids: string[]; cursor?: string }> { 157 + const params: Record<string, string> = { did, limit: String(limit) }; 158 + if (cursor) { 159 + params.cursor = cursor; 160 + } 161 + return this.xrpc("com.atproto.sync.listBlobs", { params }); 162 + } 163 + 164 + async getBlob(did: string, cid: string): Promise<Uint8Array> { 165 + return this.xrpc("com.atproto.sync.getBlob", { 166 + params: { did, cid }, 167 + }); 168 + } 169 + 170 + async uploadBlob( 171 + data: Uint8Array, 172 + mimeType: string, 173 + ): Promise<{ blob: BlobRef }> { 174 + return this.xrpc("com.atproto.repo.uploadBlob", { 175 + httpMethod: "POST", 176 + rawBody: data, 177 + contentType: mimeType, 178 + }); 179 + } 180 + 181 + async getPreferences(): Promise<Preferences> { 182 + return this.xrpc("app.bsky.actor.getPreferences"); 183 + } 184 + 185 + async putPreferences(preferences: Preferences): Promise<void> { 186 + await this.xrpc("app.bsky.actor.putPreferences", { 187 + httpMethod: "POST", 188 + body: preferences, 189 + }); 190 + } 191 + 192 + async createAccount( 193 + params: CreateAccountParams, 194 + serviceToken?: string, 195 + ): Promise<Session> { 196 + const headers: Record<string, string> = { 197 + "Content-Type": "application/json", 198 + }; 199 + if (serviceToken) { 200 + headers["Authorization"] = `Bearer ${serviceToken}`; 201 + } 202 + 203 + const res = await fetch( 204 + `${this.baseUrl}/xrpc/com.atproto.server.createAccount`, 205 + { 206 + method: "POST", 207 + headers, 208 + body: JSON.stringify(params), 209 + }, 210 + ); 211 + 212 + if (!res.ok) { 213 + const err = await res.json().catch(() => ({ 214 + error: "Unknown", 215 + message: res.statusText, 216 + })); 217 + const error = new Error(err.message) as Error & { 218 + status: number; 219 + error: string; 220 + }; 221 + error.status = res.status; 222 + error.error = err.error; 223 + throw error; 224 + } 225 + 226 + const session = (await res.json()) as Session; 227 + this.accessToken = session.accessJwt; 228 + return session; 229 + } 230 + 231 + async importRepo(car: Uint8Array): Promise<void> { 232 + await this.xrpc("com.atproto.repo.importRepo", { 233 + httpMethod: "POST", 234 + rawBody: car, 235 + contentType: "application/vnd.ipld.car", 236 + }); 237 + } 238 + 239 + async listMissingBlobs( 240 + cursor?: string, 241 + limit = 100, 242 + ): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> { 243 + const params: Record<string, string> = { limit: String(limit) }; 244 + if (cursor) { 245 + params.cursor = cursor; 246 + } 247 + return this.xrpc("com.atproto.repo.listMissingBlobs", { params }); 248 + } 249 + 250 + async requestPlcOperationSignature(): Promise<void> { 251 + await this.xrpc("com.atproto.identity.requestPlcOperationSignature", { 252 + httpMethod: "POST", 253 + }); 254 + } 255 + 256 + async signPlcOperation(params: { 257 + token?: string; 258 + rotationKeys?: string[]; 259 + alsoKnownAs?: string[]; 260 + verificationMethods?: { atproto?: string }; 261 + services?: { atproto_pds?: { type: string; endpoint: string } }; 262 + }): Promise<{ operation: PlcOperation }> { 263 + return this.xrpc("com.atproto.identity.signPlcOperation", { 264 + httpMethod: "POST", 265 + body: params, 266 + }); 267 + } 268 + 269 + async submitPlcOperation(operation: PlcOperation): Promise<void> { 270 + await this.xrpc("com.atproto.identity.submitPlcOperation", { 271 + httpMethod: "POST", 272 + body: { operation }, 273 + }); 274 + } 275 + 276 + async getRecommendedDidCredentials(): Promise<DidCredentials> { 277 + return this.xrpc("com.atproto.identity.getRecommendedDidCredentials"); 278 + } 279 + 280 + async activateAccount(): Promise<void> { 281 + await this.xrpc("com.atproto.server.activateAccount", { 282 + httpMethod: "POST", 283 + }); 284 + } 285 + 286 + async deactivateAccount(): Promise<void> { 287 + await this.xrpc("com.atproto.server.deactivateAccount", { 288 + httpMethod: "POST", 289 + }); 290 + } 291 + 292 + async checkAccountStatus(): Promise<AccountStatus> { 293 + return this.xrpc("com.atproto.server.checkAccountStatus"); 294 + } 295 + 296 + async getMigrationStatus(): Promise<{ 297 + did: string; 298 + didType: string; 299 + migrated: boolean; 300 + migratedToPds?: string; 301 + migratedAt?: string; 302 + }> { 303 + return this.xrpc("com.tranquil.account.getMigrationStatus"); 304 + } 305 + 306 + async updateMigrationForwarding(pdsUrl: string): Promise<{ 307 + success: boolean; 308 + migratedToPds: string; 309 + migratedAt: string; 310 + }> { 311 + return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 312 + httpMethod: "POST", 313 + body: { pdsUrl }, 314 + }); 315 + } 316 + 317 + async clearMigrationForwarding(): Promise<{ success: boolean }> { 318 + return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 319 + httpMethod: "POST", 320 + }); 321 + } 322 + 323 + async resolveHandle(handle: string): Promise<{ did: string }> { 324 + return this.xrpc("com.atproto.identity.resolveHandle", { 325 + params: { handle }, 326 + }); 327 + } 328 + 329 + async loginDeactivated( 330 + identifier: string, 331 + password: string, 332 + ): Promise<Session> { 333 + const session = await this.xrpc<Session>("com.atproto.server.createSession", { 334 + httpMethod: "POST", 335 + body: { identifier, password, allowDeactivated: true }, 336 + }); 337 + this.accessToken = session.accessJwt; 338 + return session; 339 + } 340 + 341 + async verifyToken( 342 + token: string, 343 + identifier: string, 344 + ): Promise<{ success: boolean; did: string; purpose: string; channel: string }> { 345 + return this.xrpc("com.tranquil.account.verifyToken", { 346 + httpMethod: "POST", 347 + body: { token, identifier }, 348 + }); 349 + } 350 + 351 + async resendMigrationVerification(): Promise<void> { 352 + await this.xrpc("com.atproto.server.resendMigrationVerification", { 353 + httpMethod: "POST", 354 + }); 355 + } 356 + } 357 + 358 + export async function resolveDidDocument(did: string): Promise<DidDocument> { 359 + if (did.startsWith("did:plc:")) { 360 + const res = await fetch(`https://plc.directory/${did}`); 361 + if (!res.ok) { 362 + throw new Error(`Failed to resolve DID: ${res.statusText}`); 363 + } 364 + return res.json(); 365 + } 366 + 367 + if (did.startsWith("did:web:")) { 368 + const domain = did.slice(8).replace(/%3A/g, ":"); 369 + const url = domain.includes("/") 370 + ? `https://${domain}/did.json` 371 + : `https://${domain}/.well-known/did.json`; 372 + 373 + const res = await fetch(url); 374 + if (!res.ok) { 375 + throw new Error(`Failed to resolve DID: ${res.statusText}`); 376 + } 377 + return res.json(); 378 + } 379 + 380 + throw new Error(`Unsupported DID method: ${did}`); 381 + } 382 + 383 + export async function resolvePdsUrl( 384 + handleOrDid: string, 385 + ): Promise<{ did: string; pdsUrl: string }> { 386 + let did: string; 387 + 388 + if (handleOrDid.startsWith("did:")) { 389 + did = handleOrDid; 390 + } else { 391 + const handle = handleOrDid.replace(/^@/, ""); 392 + 393 + if (handle.endsWith(".bsky.social")) { 394 + const res = await fetch( 395 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 396 + ); 397 + if (!res.ok) { 398 + throw new Error(`Failed to resolve handle: ${res.statusText}`); 399 + } 400 + const data = await res.json(); 401 + did = data.did; 402 + } else { 403 + const dnsRes = await fetch( 404 + `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`, 405 + ); 406 + if (dnsRes.ok) { 407 + const dnsData = await dnsRes.json(); 408 + const txtRecords = dnsData.Answer ?? []; 409 + for (const record of txtRecords) { 410 + const txt = record.data?.replace(/"/g, "") ?? ""; 411 + if (txt.startsWith("did=")) { 412 + did = txt.slice(4); 413 + break; 414 + } 415 + } 416 + } 417 + 418 + if (!did) { 419 + const wellKnownRes = await fetch( 420 + `https://${handle}/.well-known/atproto-did`, 421 + ); 422 + if (wellKnownRes.ok) { 423 + did = (await wellKnownRes.text()).trim(); 424 + } 425 + } 426 + 427 + if (!did) { 428 + throw new Error(`Could not resolve handle: ${handle}`); 429 + } 430 + } 431 + } 432 + 433 + const didDoc = await resolveDidDocument(did); 434 + 435 + const pdsService = didDoc.service?.find( 436 + (s: { type: string }) => s.type === "AtprotoPersonalDataServer", 437 + ); 438 + 439 + if (!pdsService) { 440 + throw new Error("No PDS service found in DID document"); 441 + } 442 + 443 + return { did, pdsUrl: pdsService.serviceEndpoint }; 444 + } 445 + 446 + export function createLocalClient(): AtprotoClient { 447 + return new AtprotoClient(window.location.origin); 448 + }
+734
frontend/src/lib/migration/flow.svelte.ts
···
··· 1 + import type { 2 + InboundMigrationState, 3 + InboundStep, 4 + MigrationProgress, 5 + OutboundMigrationState, 6 + OutboundStep, 7 + ServerDescription, 8 + StoredMigrationState, 9 + } from "./types"; 10 + import { 11 + AtprotoClient, 12 + createLocalClient, 13 + resolvePdsUrl, 14 + } from "./atproto-client"; 15 + import { 16 + clearMigrationState, 17 + loadMigrationState, 18 + saveMigrationState, 19 + updateProgress, 20 + updateStep, 21 + } from "./storage"; 22 + 23 + function createInitialProgress(): MigrationProgress { 24 + return { 25 + repoExported: false, 26 + repoImported: false, 27 + blobsTotal: 0, 28 + blobsMigrated: 0, 29 + blobsFailed: [], 30 + prefsMigrated: false, 31 + plcSigned: false, 32 + activated: false, 33 + deactivated: false, 34 + currentOperation: "", 35 + }; 36 + } 37 + 38 + export function createInboundMigrationFlow() { 39 + let state = $state<InboundMigrationState>({ 40 + direction: "inbound", 41 + step: "welcome", 42 + sourcePdsUrl: "", 43 + sourceDid: "", 44 + sourceHandle: "", 45 + targetHandle: "", 46 + targetEmail: "", 47 + targetPassword: "", 48 + inviteCode: "", 49 + sourceAccessToken: null, 50 + sourceRefreshToken: null, 51 + serviceAuthToken: null, 52 + emailVerifyToken: "", 53 + plcToken: "", 54 + progress: createInitialProgress(), 55 + error: null, 56 + requires2FA: false, 57 + twoFactorCode: "", 58 + }); 59 + 60 + let sourceClient: AtprotoClient | null = null; 61 + let localClient: AtprotoClient | null = null; 62 + let localServerInfo: ServerDescription | null = null; 63 + 64 + function setStep(step: InboundStep) { 65 + state.step = step; 66 + state.error = null; 67 + saveMigrationState(state); 68 + updateStep(step); 69 + } 70 + 71 + function setError(error: string) { 72 + state.error = error; 73 + saveMigrationState(state); 74 + } 75 + 76 + function setProgress(updates: Partial<MigrationProgress>) { 77 + state.progress = { ...state.progress, ...updates }; 78 + updateProgress(updates); 79 + } 80 + 81 + async function loadLocalServerInfo(): Promise<ServerDescription> { 82 + if (!localClient) { 83 + localClient = createLocalClient(); 84 + } 85 + if (!localServerInfo) { 86 + localServerInfo = await localClient.describeServer(); 87 + } 88 + return localServerInfo; 89 + } 90 + 91 + async function resolveSourcePds(handle: string): Promise<void> { 92 + try { 93 + const { did, pdsUrl } = await resolvePdsUrl(handle); 94 + state.sourcePdsUrl = pdsUrl; 95 + state.sourceDid = did; 96 + state.sourceHandle = handle; 97 + sourceClient = new AtprotoClient(pdsUrl); 98 + } catch (e) { 99 + throw new Error(`Could not resolve handle: ${(e as Error).message}`); 100 + } 101 + } 102 + 103 + async function loginToSource( 104 + handle: string, 105 + password: string, 106 + twoFactorCode?: string, 107 + ): Promise<void> { 108 + if (!state.sourcePdsUrl) { 109 + await resolveSourcePds(handle); 110 + } 111 + 112 + if (!sourceClient) { 113 + sourceClient = new AtprotoClient(state.sourcePdsUrl); 114 + } 115 + 116 + try { 117 + const session = await sourceClient.login(handle, password, twoFactorCode); 118 + state.sourceAccessToken = session.accessJwt; 119 + state.sourceRefreshToken = session.refreshJwt; 120 + state.sourceDid = session.did; 121 + state.sourceHandle = session.handle; 122 + state.requires2FA = false; 123 + saveMigrationState(state); 124 + } catch (e) { 125 + const err = e as Error & { error?: string }; 126 + if (err.error === "AuthFactorTokenRequired") { 127 + state.requires2FA = true; 128 + throw new Error("Two-factor authentication required. Please enter the code sent to your email."); 129 + } 130 + throw e; 131 + } 132 + } 133 + 134 + async function checkHandleAvailability(handle: string): Promise<boolean> { 135 + if (!localClient) { 136 + localClient = createLocalClient(); 137 + } 138 + try { 139 + await localClient.resolveHandle(handle); 140 + return false; 141 + } catch { 142 + return true; 143 + } 144 + } 145 + 146 + async function authenticateToLocal(email: string, password: string): Promise<void> { 147 + if (!localClient) { 148 + localClient = createLocalClient(); 149 + } 150 + await localClient.loginDeactivated(email, password); 151 + } 152 + 153 + async function startMigration(): Promise<void> { 154 + if (!sourceClient || !state.sourceAccessToken) { 155 + throw new Error("Not logged in to source PDS"); 156 + } 157 + 158 + if (!localClient) { 159 + localClient = createLocalClient(); 160 + } 161 + 162 + setStep("migrating"); 163 + setProgress({ currentOperation: "Getting service auth token..." }); 164 + 165 + try { 166 + const serverInfo = await loadLocalServerInfo(); 167 + const { token } = await sourceClient.getServiceAuth( 168 + serverInfo.did, 169 + "com.atproto.server.createAccount", 170 + ); 171 + state.serviceAuthToken = token; 172 + 173 + setProgress({ currentOperation: "Creating account on new PDS..." }); 174 + 175 + const accountParams = { 176 + did: state.sourceDid, 177 + handle: state.targetHandle, 178 + email: state.targetEmail, 179 + password: state.targetPassword, 180 + inviteCode: state.inviteCode || undefined, 181 + }; 182 + 183 + const session = await localClient.createAccount(accountParams, token); 184 + localClient.setAccessToken(session.accessJwt); 185 + 186 + setProgress({ currentOperation: "Exporting repository..." }); 187 + 188 + const car = await sourceClient.getRepo(state.sourceDid); 189 + setProgress({ repoExported: true, currentOperation: "Importing repository..." }); 190 + 191 + await localClient.importRepo(car); 192 + setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); 193 + 194 + const accountStatus = await localClient.checkAccountStatus(); 195 + setProgress({ 196 + blobsTotal: accountStatus.expectedBlobs, 197 + currentOperation: "Migrating blobs...", 198 + }); 199 + 200 + await migrateBlobs(); 201 + 202 + setProgress({ currentOperation: "Migrating preferences..." }); 203 + await migratePreferences(); 204 + 205 + setStep("email-verify"); 206 + } catch (e) { 207 + const err = e as Error & { error?: string; status?: number }; 208 + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 209 + setError(message); 210 + setStep("error"); 211 + } 212 + } 213 + 214 + async function migrateBlobs(): Promise<void> { 215 + if (!sourceClient || !localClient) return; 216 + 217 + let cursor: string | undefined; 218 + let migrated = 0; 219 + 220 + do { 221 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 222 + cursor, 223 + 100, 224 + ); 225 + 226 + for (const blob of blobs) { 227 + try { 228 + setProgress({ 229 + currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, 230 + }); 231 + 232 + const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid); 233 + await localClient.uploadBlob(blobData, "application/octet-stream"); 234 + migrated++; 235 + setProgress({ blobsMigrated: migrated }); 236 + } catch (e) { 237 + state.progress.blobsFailed.push(blob.cid); 238 + } 239 + } 240 + 241 + cursor = nextCursor; 242 + } while (cursor); 243 + } 244 + 245 + async function migratePreferences(): Promise<void> { 246 + if (!sourceClient || !localClient) return; 247 + 248 + try { 249 + const prefs = await sourceClient.getPreferences(); 250 + await localClient.putPreferences(prefs); 251 + setProgress({ prefsMigrated: true }); 252 + } catch { 253 + } 254 + } 255 + 256 + async function submitEmailVerifyToken(token: string, localPassword?: string): Promise<void> { 257 + if (!localClient) { 258 + localClient = createLocalClient(); 259 + } 260 + 261 + state.emailVerifyToken = token; 262 + setError(null); 263 + 264 + try { 265 + await localClient.verifyToken(token, state.targetEmail); 266 + 267 + if (!sourceClient) { 268 + setStep("source-login"); 269 + setError("Email verified! Please log in to your old account again to complete the migration."); 270 + return; 271 + } 272 + 273 + if (localPassword) { 274 + setProgress({ currentOperation: "Authenticating to new PDS..." }); 275 + await localClient.loginDeactivated(state.targetEmail, localPassword); 276 + } 277 + 278 + if (!localClient.getAccessToken()) { 279 + setError("Email verified! Please enter your password to continue."); 280 + return; 281 + } 282 + 283 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 284 + await sourceClient.requestPlcOperationSignature(); 285 + setStep("plc-token"); 286 + } catch (e) { 287 + const err = e as Error & { error?: string; status?: number }; 288 + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 289 + setError(message); 290 + } 291 + } 292 + 293 + async function resendEmailVerification(): Promise<void> { 294 + if (!localClient) { 295 + localClient = createLocalClient(); 296 + } 297 + await localClient.resendMigrationVerification(); 298 + } 299 + 300 + let checkingEmailVerification = false; 301 + 302 + async function checkEmailVerifiedAndProceed(): Promise<boolean> { 303 + if (checkingEmailVerification) return false; 304 + if (!sourceClient || !localClient) return false; 305 + 306 + checkingEmailVerification = true; 307 + try { 308 + await localClient.loginDeactivated(state.targetEmail, state.targetPassword); 309 + await sourceClient.requestPlcOperationSignature(); 310 + setStep("plc-token"); 311 + return true; 312 + } catch (e) { 313 + const err = e as Error & { error?: string }; 314 + if (err.error === "AccountNotVerified") { 315 + return false; 316 + } 317 + return false; 318 + } finally { 319 + checkingEmailVerification = false; 320 + } 321 + } 322 + 323 + async function submitPlcToken(token: string): Promise<void> { 324 + if (!sourceClient || !localClient) { 325 + throw new Error("Not connected to PDSes"); 326 + } 327 + 328 + state.plcToken = token; 329 + setStep("finalizing"); 330 + setProgress({ currentOperation: "Signing PLC operation..." }); 331 + 332 + try { 333 + const credentials = await localClient.getRecommendedDidCredentials(); 334 + 335 + const { operation } = await sourceClient.signPlcOperation({ 336 + token, 337 + ...credentials, 338 + }); 339 + 340 + setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); 341 + await localClient.submitPlcOperation(operation); 342 + 343 + setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." }); 344 + await localClient.activateAccount(); 345 + setProgress({ activated: true }); 346 + 347 + setProgress({ currentOperation: "Deactivating old account..." }); 348 + try { 349 + await sourceClient.deactivateAccount(); 350 + setProgress({ deactivated: true }); 351 + } catch { 352 + } 353 + 354 + setStep("success"); 355 + clearMigrationState(); 356 + } catch (e) { 357 + const err = e as Error & { error?: string; status?: number }; 358 + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 359 + state.step = "plc-token"; 360 + state.error = message; 361 + saveMigrationState(state); 362 + } 363 + } 364 + 365 + async function requestPlcToken(): Promise<void> { 366 + if (!sourceClient) { 367 + throw new Error("Not connected to source PDS"); 368 + } 369 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 370 + await sourceClient.requestPlcOperationSignature(); 371 + } 372 + 373 + async function resendPlcToken(): Promise<void> { 374 + if (!sourceClient) { 375 + throw new Error("Not connected to source PDS"); 376 + } 377 + await sourceClient.requestPlcOperationSignature(); 378 + } 379 + 380 + function reset(): void { 381 + state = { 382 + direction: "inbound", 383 + step: "welcome", 384 + sourcePdsUrl: "", 385 + sourceDid: "", 386 + sourceHandle: "", 387 + targetHandle: "", 388 + targetEmail: "", 389 + targetPassword: "", 390 + inviteCode: "", 391 + sourceAccessToken: null, 392 + sourceRefreshToken: null, 393 + serviceAuthToken: null, 394 + emailVerifyToken: "", 395 + plcToken: "", 396 + progress: createInitialProgress(), 397 + error: null, 398 + requires2FA: false, 399 + twoFactorCode: "", 400 + }; 401 + sourceClient = null; 402 + clearMigrationState(); 403 + } 404 + 405 + async function resumeFromState(stored: StoredMigrationState): Promise<void> { 406 + if (stored.direction !== "inbound") return; 407 + 408 + state.sourcePdsUrl = stored.sourcePdsUrl; 409 + state.sourceDid = stored.sourceDid; 410 + state.sourceHandle = stored.sourceHandle; 411 + state.targetHandle = stored.targetHandle; 412 + state.targetEmail = stored.targetEmail; 413 + state.progress = { 414 + ...createInitialProgress(), 415 + ...stored.progress, 416 + }; 417 + 418 + state.step = "source-login"; 419 + } 420 + 421 + function getLocalSession(): { accessJwt: string; did: string; handle: string } | null { 422 + if (!localClient) return null; 423 + const token = localClient.getAccessToken(); 424 + if (!token) return null; 425 + return { 426 + accessJwt: token, 427 + did: state.sourceDid, 428 + handle: state.targetHandle, 429 + }; 430 + } 431 + 432 + return { 433 + get state() { return state; }, 434 + setStep, 435 + setError, 436 + loadLocalServerInfo, 437 + loginToSource, 438 + authenticateToLocal, 439 + checkHandleAvailability, 440 + startMigration, 441 + submitEmailVerifyToken, 442 + resendEmailVerification, 443 + checkEmailVerifiedAndProceed, 444 + requestPlcToken, 445 + submitPlcToken, 446 + resendPlcToken, 447 + reset, 448 + resumeFromState, 449 + getLocalSession, 450 + 451 + updateField<K extends keyof InboundMigrationState>( 452 + field: K, 453 + value: InboundMigrationState[K], 454 + ) { 455 + state[field] = value; 456 + }, 457 + }; 458 + } 459 + 460 + export function createOutboundMigrationFlow() { 461 + let state = $state<OutboundMigrationState>({ 462 + direction: "outbound", 463 + step: "welcome", 464 + localDid: "", 465 + localHandle: "", 466 + targetPdsUrl: "", 467 + targetPdsDid: "", 468 + targetHandle: "", 469 + targetEmail: "", 470 + targetPassword: "", 471 + inviteCode: "", 472 + targetAccessToken: null, 473 + targetRefreshToken: null, 474 + serviceAuthToken: null, 475 + plcToken: "", 476 + progress: createInitialProgress(), 477 + error: null, 478 + targetServerInfo: null, 479 + }); 480 + 481 + let localClient: AtprotoClient | null = null; 482 + let targetClient: AtprotoClient | null = null; 483 + 484 + function setStep(step: OutboundStep) { 485 + state.step = step; 486 + state.error = null; 487 + saveMigrationState(state); 488 + updateStep(step); 489 + } 490 + 491 + function setError(error: string) { 492 + state.error = error; 493 + saveMigrationState(state); 494 + } 495 + 496 + function setProgress(updates: Partial<MigrationProgress>) { 497 + state.progress = { ...state.progress, ...updates }; 498 + updateProgress(updates); 499 + } 500 + 501 + async function validateTargetPds(url: string): Promise<ServerDescription> { 502 + const normalizedUrl = url.replace(/\/$/, ""); 503 + targetClient = new AtprotoClient(normalizedUrl); 504 + 505 + try { 506 + const serverInfo = await targetClient.describeServer(); 507 + state.targetPdsUrl = normalizedUrl; 508 + state.targetPdsDid = serverInfo.did; 509 + state.targetServerInfo = serverInfo; 510 + return serverInfo; 511 + } catch (e) { 512 + throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 513 + } 514 + } 515 + 516 + function initLocalClient(accessToken: string, did?: string, handle?: string): void { 517 + localClient = createLocalClient(); 518 + localClient.setAccessToken(accessToken); 519 + if (did) { 520 + state.localDid = did; 521 + } 522 + if (handle) { 523 + state.localHandle = handle; 524 + } 525 + } 526 + 527 + async function startMigration(currentDid: string): Promise<void> { 528 + if (!localClient || !targetClient) { 529 + throw new Error("Not connected to PDSes"); 530 + } 531 + 532 + setStep("migrating"); 533 + setProgress({ currentOperation: "Getting service auth token..." }); 534 + 535 + try { 536 + const { token } = await localClient.getServiceAuth( 537 + state.targetPdsDid, 538 + "com.atproto.server.createAccount", 539 + ); 540 + state.serviceAuthToken = token; 541 + 542 + setProgress({ currentOperation: "Creating account on new PDS..." }); 543 + 544 + const accountParams = { 545 + did: currentDid, 546 + handle: state.targetHandle, 547 + email: state.targetEmail, 548 + password: state.targetPassword, 549 + inviteCode: state.inviteCode || undefined, 550 + }; 551 + 552 + const session = await targetClient.createAccount(accountParams, token); 553 + state.targetAccessToken = session.accessJwt; 554 + state.targetRefreshToken = session.refreshJwt; 555 + targetClient.setAccessToken(session.accessJwt); 556 + 557 + setProgress({ currentOperation: "Exporting repository..." }); 558 + 559 + const car = await localClient.getRepo(currentDid); 560 + setProgress({ repoExported: true, currentOperation: "Importing repository..." }); 561 + 562 + await targetClient.importRepo(car); 563 + setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); 564 + 565 + const accountStatus = await targetClient.checkAccountStatus(); 566 + setProgress({ 567 + blobsTotal: accountStatus.expectedBlobs, 568 + currentOperation: "Migrating blobs...", 569 + }); 570 + 571 + await migrateBlobs(currentDid); 572 + 573 + setProgress({ currentOperation: "Migrating preferences..." }); 574 + await migratePreferences(); 575 + 576 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 577 + await localClient.requestPlcOperationSignature(); 578 + 579 + setStep("plc-token"); 580 + } catch (e) { 581 + const err = e as Error & { error?: string; status?: number }; 582 + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 583 + setError(message); 584 + setStep("error"); 585 + } 586 + } 587 + 588 + async function migrateBlobs(did: string): Promise<void> { 589 + if (!localClient || !targetClient) return; 590 + 591 + let cursor: string | undefined; 592 + let migrated = 0; 593 + 594 + do { 595 + const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 596 + cursor, 597 + 100, 598 + ); 599 + 600 + for (const blob of blobs) { 601 + try { 602 + setProgress({ 603 + currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, 604 + }); 605 + 606 + const blobData = await localClient.getBlob(did, blob.cid); 607 + await targetClient.uploadBlob(blobData, "application/octet-stream"); 608 + migrated++; 609 + setProgress({ blobsMigrated: migrated }); 610 + } catch (e) { 611 + state.progress.blobsFailed.push(blob.cid); 612 + } 613 + } 614 + 615 + cursor = nextCursor; 616 + } while (cursor); 617 + } 618 + 619 + async function migratePreferences(): Promise<void> { 620 + if (!localClient || !targetClient) return; 621 + 622 + try { 623 + const prefs = await localClient.getPreferences(); 624 + await targetClient.putPreferences(prefs); 625 + setProgress({ prefsMigrated: true }); 626 + } catch { 627 + } 628 + } 629 + 630 + async function submitPlcToken(token: string): Promise<void> { 631 + if (!localClient || !targetClient) { 632 + throw new Error("Not connected to PDSes"); 633 + } 634 + 635 + state.plcToken = token; 636 + setStep("finalizing"); 637 + setProgress({ currentOperation: "Signing PLC operation..." }); 638 + 639 + try { 640 + const credentials = await targetClient.getRecommendedDidCredentials(); 641 + 642 + const { operation } = await localClient.signPlcOperation({ 643 + token, 644 + ...credentials, 645 + }); 646 + 647 + setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); 648 + 649 + await targetClient.submitPlcOperation(operation); 650 + 651 + setProgress({ currentOperation: "Activating account on new PDS..." }); 652 + await targetClient.activateAccount(); 653 + setProgress({ activated: true }); 654 + 655 + setProgress({ currentOperation: "Deactivating old account..." }); 656 + try { 657 + await localClient.deactivateAccount(); 658 + setProgress({ deactivated: true }); 659 + } catch { 660 + } 661 + 662 + if (state.localDid.startsWith("did:web:")) { 663 + setProgress({ currentOperation: "Updating DID document forwarding..." }); 664 + try { 665 + await localClient.updateMigrationForwarding(state.targetPdsUrl); 666 + } catch (e) { 667 + console.warn("Failed to update migration forwarding:", e); 668 + } 669 + } 670 + 671 + setStep("success"); 672 + clearMigrationState(); 673 + } catch (e) { 674 + const err = e as Error & { error?: string; status?: number }; 675 + const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 676 + setError(message); 677 + setStep("plc-token"); 678 + } 679 + } 680 + 681 + async function resendPlcToken(): Promise<void> { 682 + if (!localClient) { 683 + throw new Error("Not connected to local PDS"); 684 + } 685 + await localClient.requestPlcOperationSignature(); 686 + } 687 + 688 + function reset(): void { 689 + state = { 690 + direction: "outbound", 691 + step: "welcome", 692 + localDid: "", 693 + localHandle: "", 694 + targetPdsUrl: "", 695 + targetPdsDid: "", 696 + targetHandle: "", 697 + targetEmail: "", 698 + targetPassword: "", 699 + inviteCode: "", 700 + targetAccessToken: null, 701 + targetRefreshToken: null, 702 + serviceAuthToken: null, 703 + plcToken: "", 704 + progress: createInitialProgress(), 705 + error: null, 706 + targetServerInfo: null, 707 + }; 708 + localClient = null; 709 + targetClient = null; 710 + clearMigrationState(); 711 + } 712 + 713 + return { 714 + get state() { return state; }, 715 + setStep, 716 + setError, 717 + validateTargetPds, 718 + initLocalClient, 719 + startMigration, 720 + submitPlcToken, 721 + resendPlcToken, 722 + reset, 723 + 724 + updateField<K extends keyof OutboundMigrationState>( 725 + field: K, 726 + value: OutboundMigrationState[K], 727 + ) { 728 + state[field] = value; 729 + }, 730 + }; 731 + } 732 + 733 + export type InboundMigrationFlow = ReturnType<typeof createInboundMigrationFlow>; 734 + export type OutboundMigrationFlow = ReturnType<typeof createOutboundMigrationFlow>;
+9
frontend/src/lib/migration/index.ts
···
··· 1 + export * from "./types"; 2 + export * from "./atproto-client"; 3 + export * from "./storage"; 4 + export { 5 + createInboundMigrationFlow, 6 + createOutboundMigrationFlow, 7 + type InboundMigrationFlow, 8 + type OutboundMigrationFlow, 9 + } from "./flow.svelte";
+138
frontend/src/lib/migration/storage.ts
···
··· 1 + import type { MigrationDirection, MigrationState, StoredMigrationState } from "./types"; 2 + 3 + const STORAGE_KEY = "tranquil_migration_state"; 4 + const MAX_AGE_MS = 24 * 60 * 60 * 1000; 5 + 6 + export function saveMigrationState(state: MigrationState): void { 7 + const storedState: StoredMigrationState = { 8 + version: 1, 9 + direction: state.direction, 10 + step: state.direction === "inbound" ? state.step : state.step, 11 + startedAt: new Date().toISOString(), 12 + sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl : window.location.origin, 13 + targetPdsUrl: state.direction === "inbound" ? window.location.origin : state.targetPdsUrl, 14 + sourceDid: state.direction === "inbound" ? state.sourceDid : "", 15 + sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", 16 + targetHandle: state.targetHandle, 17 + targetEmail: state.targetEmail, 18 + progress: { 19 + repoExported: state.progress.repoExported, 20 + repoImported: state.progress.repoImported, 21 + blobsTotal: state.progress.blobsTotal, 22 + blobsMigrated: state.progress.blobsMigrated, 23 + prefsMigrated: state.progress.prefsMigrated, 24 + plcSigned: state.progress.plcSigned, 25 + }, 26 + lastError: state.error ?? undefined, 27 + lastErrorStep: state.error ? state.step : undefined, 28 + }; 29 + 30 + try { 31 + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState)); 32 + } catch { 33 + } 34 + } 35 + 36 + export function loadMigrationState(): StoredMigrationState | null { 37 + try { 38 + const stored = sessionStorage.getItem(STORAGE_KEY); 39 + if (!stored) return null; 40 + 41 + const state = JSON.parse(stored) as StoredMigrationState; 42 + 43 + if (state.version !== 1) return null; 44 + 45 + const startedAt = new Date(state.startedAt).getTime(); 46 + if (Date.now() - startedAt > MAX_AGE_MS) { 47 + clearMigrationState(); 48 + return null; 49 + } 50 + 51 + return state; 52 + } catch { 53 + return null; 54 + } 55 + } 56 + 57 + export function clearMigrationState(): void { 58 + try { 59 + sessionStorage.removeItem(STORAGE_KEY); 60 + } catch { 61 + } 62 + } 63 + 64 + export function hasPendingMigration(): boolean { 65 + return loadMigrationState() !== null; 66 + } 67 + 68 + export function getResumeInfo(): { 69 + direction: MigrationDirection; 70 + sourceHandle: string; 71 + targetHandle: string; 72 + sourcePdsUrl: string; 73 + targetPdsUrl: string; 74 + progressSummary: string; 75 + step: string; 76 + } | null { 77 + const state = loadMigrationState(); 78 + if (!state) return null; 79 + 80 + const progressParts: string[] = []; 81 + if (state.progress.repoExported) progressParts.push("repo exported"); 82 + if (state.progress.repoImported) progressParts.push("repo imported"); 83 + if (state.progress.blobsMigrated > 0) { 84 + progressParts.push( 85 + `${state.progress.blobsMigrated}/${state.progress.blobsTotal} blobs`, 86 + ); 87 + } 88 + if (state.progress.prefsMigrated) progressParts.push("preferences migrated"); 89 + if (state.progress.plcSigned) progressParts.push("PLC signed"); 90 + 91 + return { 92 + direction: state.direction, 93 + sourceHandle: state.sourceHandle, 94 + targetHandle: state.targetHandle, 95 + sourcePdsUrl: state.sourcePdsUrl, 96 + targetPdsUrl: state.targetPdsUrl, 97 + progressSummary: progressParts.length > 0 98 + ? progressParts.join(", ") 99 + : "just started", 100 + step: state.step, 101 + }; 102 + } 103 + 104 + export function updateProgress( 105 + updates: Partial<StoredMigrationState["progress"]>, 106 + ): void { 107 + const state = loadMigrationState(); 108 + if (!state) return; 109 + 110 + state.progress = { ...state.progress, ...updates }; 111 + try { 112 + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 113 + } catch { 114 + } 115 + } 116 + 117 + export function updateStep(step: string): void { 118 + const state = loadMigrationState(); 119 + if (!state) return; 120 + 121 + state.step = step; 122 + try { 123 + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 124 + } catch { 125 + } 126 + } 127 + 128 + export function setError(error: string, step: string): void { 129 + const state = loadMigrationState(); 130 + if (!state) return; 131 + 132 + state.lastError = error; 133 + state.lastErrorStep = step; 134 + try { 135 + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 136 + } catch { 137 + } 138 + }
+214
frontend/src/lib/migration/types.ts
···
··· 1 + export type InboundStep = 2 + | "welcome" 3 + | "source-login" 4 + | "choose-handle" 5 + | "review" 6 + | "migrating" 7 + | "email-verify" 8 + | "plc-token" 9 + | "finalizing" 10 + | "success" 11 + | "error"; 12 + 13 + export type OutboundStep = 14 + | "welcome" 15 + | "target-pds" 16 + | "new-account" 17 + | "review" 18 + | "migrating" 19 + | "plc-token" 20 + | "finalizing" 21 + | "success" 22 + | "error"; 23 + 24 + export type MigrationDirection = "inbound" | "outbound"; 25 + 26 + export interface MigrationProgress { 27 + repoExported: boolean; 28 + repoImported: boolean; 29 + blobsTotal: number; 30 + blobsMigrated: number; 31 + blobsFailed: string[]; 32 + prefsMigrated: boolean; 33 + plcSigned: boolean; 34 + activated: boolean; 35 + deactivated: boolean; 36 + currentOperation: string; 37 + } 38 + 39 + export interface InboundMigrationState { 40 + direction: "inbound"; 41 + step: InboundStep; 42 + sourcePdsUrl: string; 43 + sourceDid: string; 44 + sourceHandle: string; 45 + targetHandle: string; 46 + targetEmail: string; 47 + targetPassword: string; 48 + inviteCode: string; 49 + sourceAccessToken: string | null; 50 + sourceRefreshToken: string | null; 51 + serviceAuthToken: string | null; 52 + emailVerifyToken: string; 53 + plcToken: string; 54 + progress: MigrationProgress; 55 + error: string | null; 56 + requires2FA: boolean; 57 + twoFactorCode: string; 58 + } 59 + 60 + export interface OutboundMigrationState { 61 + direction: "outbound"; 62 + step: OutboundStep; 63 + localDid: string; 64 + localHandle: string; 65 + targetPdsUrl: string; 66 + targetPdsDid: string; 67 + targetHandle: string; 68 + targetEmail: string; 69 + targetPassword: string; 70 + inviteCode: string; 71 + targetAccessToken: string | null; 72 + targetRefreshToken: string | null; 73 + serviceAuthToken: string | null; 74 + plcToken: string; 75 + progress: MigrationProgress; 76 + error: string | null; 77 + targetServerInfo: ServerDescription | null; 78 + } 79 + 80 + export type MigrationState = InboundMigrationState | OutboundMigrationState; 81 + 82 + export interface StoredMigrationState { 83 + version: 1; 84 + direction: MigrationDirection; 85 + step: string; 86 + startedAt: string; 87 + sourcePdsUrl: string; 88 + targetPdsUrl: string; 89 + sourceDid: string; 90 + sourceHandle: string; 91 + targetHandle: string; 92 + targetEmail: string; 93 + progress: { 94 + repoExported: boolean; 95 + repoImported: boolean; 96 + blobsTotal: number; 97 + blobsMigrated: number; 98 + prefsMigrated: boolean; 99 + plcSigned: boolean; 100 + }; 101 + lastErrorStep?: string; 102 + lastError?: string; 103 + } 104 + 105 + export interface ServerDescription { 106 + did: string; 107 + availableUserDomains: string[]; 108 + inviteCodeRequired: boolean; 109 + phoneVerificationRequired?: boolean; 110 + links?: { 111 + privacyPolicy?: string; 112 + termsOfService?: string; 113 + }; 114 + } 115 + 116 + export interface Session { 117 + did: string; 118 + handle: string; 119 + email?: string; 120 + accessJwt: string; 121 + refreshJwt: string; 122 + active?: boolean; 123 + } 124 + 125 + export interface DidDocument { 126 + id: string; 127 + alsoKnownAs?: string[]; 128 + verificationMethod?: Array<{ 129 + id: string; 130 + type: string; 131 + controller: string; 132 + publicKeyMultibase?: string; 133 + }>; 134 + service?: Array<{ 135 + id: string; 136 + type: string; 137 + serviceEndpoint: string; 138 + }>; 139 + } 140 + 141 + export interface DidCredentials { 142 + rotationKeys?: string[]; 143 + alsoKnownAs?: string[]; 144 + verificationMethods?: { 145 + atproto?: string; 146 + }; 147 + services?: { 148 + atproto_pds?: { 149 + type: string; 150 + endpoint: string; 151 + }; 152 + }; 153 + } 154 + 155 + export interface PlcOperation { 156 + type: "plc_operation"; 157 + prev: string | null; 158 + sig: string; 159 + rotationKeys: string[]; 160 + verificationMethods: { 161 + atproto: string; 162 + }; 163 + alsoKnownAs: string[]; 164 + services: { 165 + atproto_pds: { 166 + type: string; 167 + endpoint: string; 168 + }; 169 + }; 170 + } 171 + 172 + export interface AccountStatus { 173 + activated: boolean; 174 + validDid: boolean; 175 + repoCommit: string; 176 + repoRev: string; 177 + repoBlocks: number; 178 + indexedRecords: number; 179 + privateStateValues: number; 180 + expectedBlobs: number; 181 + importedBlobs: number; 182 + } 183 + 184 + export interface BlobRef { 185 + $type: "blob"; 186 + ref: { $link: string }; 187 + mimeType: string; 188 + size: number; 189 + } 190 + 191 + export interface CreateAccountParams { 192 + did?: string; 193 + handle: string; 194 + email: string; 195 + password: string; 196 + inviteCode?: string; 197 + recoveryKey?: string; 198 + } 199 + 200 + export interface Preferences { 201 + preferences: unknown[]; 202 + } 203 + 204 + export class MigrationError extends Error { 205 + constructor( 206 + message: string, 207 + public code: string, 208 + public recoverable: boolean = false, 209 + public details?: unknown, 210 + ) { 211 + super(message); 212 + this.name = "MigrationError"; 213 + } 214 + }
+202 -1
frontend/src/locales/en.json
··· 71 "infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.", 72 "migrateTitle": "Already have a Bluesky account?", 73 "migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.", 74 - "migrateLink": "Migrate with PDS Moover", 75 "handle": "Handle", 76 "handlePlaceholder": "yourname", 77 "handleHint": "Your full handle will be: @{handle}", ··· 991 "codeLabel": "Verification Code", 992 "codeHelp": "Copy the entire code from your message, including dashes.", 993 "verifyButton": "Verify" 994 } 995 }
··· 71 "infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.", 72 "migrateTitle": "Already have a Bluesky account?", 73 "migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.", 74 + "migrateLink": "Migrate your account", 75 "handle": "Handle", 76 "handlePlaceholder": "yourname", 77 "handleHint": "Your full handle will be: @{handle}", ··· 991 "codeLabel": "Verification Code", 992 "codeHelp": "Copy the entire code from your message, including dashes.", 993 "verifyButton": "Verify" 994 + }, 995 + "migration": { 996 + "title": "Account Migration", 997 + "subtitle": "Move your AT Protocol identity between servers", 998 + "navTitle": "Migration", 999 + "navDesc": "Move your account to or from another PDS", 1000 + "migrateHere": "Migrate Here", 1001 + "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", 1002 + "migrateAway": "Migrate Away", 1003 + "migrateAwayDesc": "Move your account from this PDS to another server.", 1004 + "loginRequired": "Login required", 1005 + "bringDid": "Bring your DID and identity", 1006 + "transferData": "Transfer all your data", 1007 + "keepFollowers": "Keep your followers", 1008 + "exportRepo": "Export your repository", 1009 + "transferToPds": "Transfer to new PDS", 1010 + "updateIdentity": "Update your identity", 1011 + "whatIsMigration": "What is account migration?", 1012 + "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.", 1013 + "beforeMigrate": "Before you migrate", 1014 + "beforeMigrate1": "You will need your current account credentials", 1015 + "beforeMigrate2": "Migration requires email verification for security", 1016 + "beforeMigrate3": "Large accounts with many images may take several minutes", 1017 + "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1018 + "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.", 1019 + "learnMore": "Learn more about migration risks", 1020 + "resume": { 1021 + "title": "Resume Migration?", 1022 + "incomplete": "You have an incomplete migration in progress:", 1023 + "direction": "Direction", 1024 + "migratingHere": "Migrating here", 1025 + "migratingAway": "Migrating away", 1026 + "from": "From", 1027 + "to": "To", 1028 + "progress": "Progress", 1029 + "reenterCredentials": "You will need to re-enter your credentials to continue.", 1030 + "startOver": "Start Over", 1031 + "resumeButton": "Resume" 1032 + }, 1033 + "inbound": { 1034 + "welcome": { 1035 + "title": "Migrate to This PDS", 1036 + "desc": "Move your existing AT Protocol account to this server.", 1037 + "understand": "I understand the risks and want to proceed" 1038 + }, 1039 + "sourceLogin": { 1040 + "title": "Sign In to Your Current PDS", 1041 + "desc": "Enter your credentials for the account you want to migrate.", 1042 + "handle": "Handle", 1043 + "handlePlaceholder": "you.bsky.social", 1044 + "password": "Password", 1045 + "twoFactorCode": "Two-Factor Code", 1046 + "twoFactorRequired": "Two-factor authentication required", 1047 + "signIn": "Sign In & Continue" 1048 + }, 1049 + "chooseHandle": { 1050 + "title": "Choose Your New Handle", 1051 + "desc": "Select a handle for your account on this PDS.", 1052 + "handleHint": "Your full handle will be: @{handle}" 1053 + }, 1054 + "review": { 1055 + "title": "Review Migration", 1056 + "desc": "Please review and confirm your migration details.", 1057 + "currentHandle": "Current Handle", 1058 + "newHandle": "New Handle", 1059 + "sourcePds": "Source PDS", 1060 + "targetPds": "This PDS", 1061 + "email": "Email", 1062 + "inviteCode": "Invite Code", 1063 + "confirm": "I confirm I want to migrate my account", 1064 + "startMigration": "Start Migration" 1065 + }, 1066 + "migrating": { 1067 + "title": "Migrating Your Account", 1068 + "desc": "Please wait while we transfer your data...", 1069 + "gettingServiceAuth": "Getting service authorization...", 1070 + "creatingAccount": "Creating account on new PDS...", 1071 + "exportingRepo": "Exporting repository...", 1072 + "importingRepo": "Importing repository...", 1073 + "countingBlobs": "Counting blobs...", 1074 + "migratingBlobs": "Migrating blobs ({current}/{total})...", 1075 + "migratingPrefs": "Migrating preferences...", 1076 + "requestingPlc": "Requesting PLC operation..." 1077 + }, 1078 + "emailVerify": { 1079 + "title": "Verify Your Email", 1080 + "desc": "A verification code has been sent to {email}.", 1081 + "hint": "Enter the code below, or click the link in the email to continue automatically.", 1082 + "tokenLabel": "Verification Code", 1083 + "tokenPlaceholder": "Enter code from email", 1084 + "resend": "Resend Code", 1085 + "verify": "Verify Email", 1086 + "verifying": "Verifying..." 1087 + }, 1088 + "plcToken": { 1089 + "title": "Verify Your Identity", 1090 + "desc": "A verification code has been sent to your email on your current PDS.", 1091 + "tokenLabel": "Verification Token", 1092 + "tokenPlaceholder": "Enter the token from your email", 1093 + "resend": "Resend Token", 1094 + "resending": "Resending..." 1095 + }, 1096 + "finalizing": { 1097 + "title": "Finalizing Migration", 1098 + "desc": "Please wait while we complete the migration...", 1099 + "signingPlc": "Sign identity update", 1100 + "activating": "Activate account on new PDS", 1101 + "deactivating": "Deactivate account on old PDS" 1102 + }, 1103 + "success": { 1104 + "title": "Migration Complete!", 1105 + "desc": "Your account has been successfully migrated to this PDS.", 1106 + "newHandle": "New Handle", 1107 + "did": "DID", 1108 + "goToDashboard": "Go to Dashboard" 1109 + } 1110 + }, 1111 + "outbound": { 1112 + "welcome": { 1113 + "title": "Migrate Away from This PDS", 1114 + "desc": "Move your account to another Personal Data Server.", 1115 + "warning": "After migration, your account here will be deactivated.", 1116 + "didWebNotice": "did:web Migration Notice", 1117 + "didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.", 1118 + "understand": "I understand the risks and want to proceed" 1119 + }, 1120 + "targetPds": { 1121 + "title": "Choose Target PDS", 1122 + "desc": "Enter the URL of the PDS you want to migrate to.", 1123 + "url": "PDS URL", 1124 + "urlPlaceholder": "https://pds.example.com", 1125 + "validate": "Validate & Continue", 1126 + "validating": "Validating...", 1127 + "connected": "Connected to {name}", 1128 + "inviteRequired": "Invite code required", 1129 + "privacyPolicy": "Privacy Policy", 1130 + "termsOfService": "Terms of Service" 1131 + }, 1132 + "newAccount": { 1133 + "title": "New Account Details", 1134 + "desc": "Set up your account on the new PDS.", 1135 + "handle": "Handle", 1136 + "availableDomains": "Available domains", 1137 + "email": "Email", 1138 + "password": "Password", 1139 + "confirmPassword": "Confirm Password", 1140 + "inviteCode": "Invite Code" 1141 + }, 1142 + "review": { 1143 + "title": "Review Migration", 1144 + "desc": "Please review and confirm your migration details.", 1145 + "currentHandle": "Current Handle", 1146 + "newHandle": "New Handle", 1147 + "sourcePds": "This PDS", 1148 + "targetPds": "Target PDS", 1149 + "confirm": "I confirm I want to migrate my account", 1150 + "startMigration": "Start Migration" 1151 + }, 1152 + "migrating": { 1153 + "title": "Migrating Your Account", 1154 + "desc": "Please wait while we transfer your data..." 1155 + }, 1156 + "plcToken": { 1157 + "title": "Verify Your Identity", 1158 + "desc": "A verification code has been sent to your email." 1159 + }, 1160 + "finalizing": { 1161 + "title": "Finalizing Migration", 1162 + "desc": "Please wait while we complete the migration...", 1163 + "updatingForwarding": "Updating DID document forwarding..." 1164 + }, 1165 + "success": { 1166 + "title": "Migration Complete!", 1167 + "desc": "Your account has been successfully migrated to your new PDS.", 1168 + "newHandle": "New Handle", 1169 + "newPds": "New PDS", 1170 + "nextSteps": "Next Steps", 1171 + "nextSteps1": "Sign in to your new PDS", 1172 + "nextSteps2": "Update any apps with your new credentials", 1173 + "nextSteps3": "Your followers will automatically see your new location", 1174 + "loggingOut": "Logging you out in {seconds} seconds..." 1175 + } 1176 + }, 1177 + "progress": { 1178 + "repoExported": "Repository exported", 1179 + "repoImported": "Repository imported", 1180 + "blobsMigrated": "{count} blobs migrated", 1181 + "prefsMigrated": "Preferences migrated", 1182 + "plcSigned": "Identity updated", 1183 + "activated": "Account activated", 1184 + "deactivated": "Old account deactivated" 1185 + }, 1186 + "errors": { 1187 + "connectionFailed": "Could not connect to PDS", 1188 + "invalidCredentials": "Invalid credentials", 1189 + "twoFactorRequired": "Two-factor authentication required", 1190 + "accountExists": "Account already exists on target PDS", 1191 + "plcFailed": "PLC operation failed", 1192 + "blobFailed": "Failed to migrate blob: {cid}", 1193 + "networkError": "Network error. Please try again." 1194 + } 1195 } 1196 }
+201
frontend/src/locales/fi.json
··· 1007 "permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.", 1008 "viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.", 1009 "editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa." 1010 } 1011 }
··· 1007 "permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.", 1008 "viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.", 1009 "editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa." 1010 + }, 1011 + "migration": { 1012 + "title": "Tilin siirto", 1013 + "subtitle": "Siirrä AT Protocol -identiteettisi palvelimien välillä", 1014 + "navTitle": "Siirto", 1015 + "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", 1016 + "migrateHere": "Siirrä tänne", 1017 + "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.", 1018 + "migrateAway": "Siirrä pois", 1019 + "migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.", 1020 + "loginRequired": "Kirjautuminen vaaditaan", 1021 + "bringDid": "Tuo DID ja identiteettisi", 1022 + "transferData": "Siirrä kaikki tietosi", 1023 + "keepFollowers": "Säilytä seuraajasi", 1024 + "exportRepo": "Vie tietovarastosi", 1025 + "transferToPds": "Siirrä uuteen PDS:ään", 1026 + "updateIdentity": "Päivitä identiteettisi", 1027 + "whatIsMigration": "Mikä on tilin siirto?", 1028 + "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.", 1029 + "beforeMigrate": "Ennen siirtoa", 1030 + "beforeMigrate1": "Tarvitset nykyisen tilisi tunnukset", 1031 + "beforeMigrate2": "Siirto vaatii sähköpostivahvistuksen turvallisuussyistä", 1032 + "beforeMigrate3": "Suuret tilit, joissa on paljon kuvia, voivat kestää useita minuutteja", 1033 + "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1034 + "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ä.", 1035 + "learnMore": "Lue lisää siirron riskeistä", 1036 + "resume": { 1037 + "title": "Jatka siirtoa?", 1038 + "incomplete": "Sinulla on keskeneräinen siirto:", 1039 + "direction": "Suunta", 1040 + "migratingHere": "Siirretään tänne", 1041 + "migratingAway": "Siirretään pois", 1042 + "from": "Mistä", 1043 + "to": "Minne", 1044 + "progress": "Edistyminen", 1045 + "reenterCredentials": "Sinun täytyy syöttää tunnuksesi uudelleen jatkaaksesi.", 1046 + "startOver": "Aloita alusta", 1047 + "resumeButton": "Jatka" 1048 + }, 1049 + "inbound": { 1050 + "welcome": { 1051 + "title": "Siirrä tähän PDS:ään", 1052 + "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.", 1053 + "understand": "Ymmärrän riskit ja haluan jatkaa" 1054 + }, 1055 + "sourceLogin": { 1056 + "title": "Kirjaudu nykyiseen PDS:ääsi", 1057 + "desc": "Syötä siirrettävän tilin tunnukset.", 1058 + "handle": "Käyttäjätunnus", 1059 + "handlePlaceholder": "sinä.bsky.social", 1060 + "password": "Salasana", 1061 + "twoFactorCode": "Kaksivaiheinen koodi", 1062 + "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", 1063 + "signIn": "Kirjaudu ja jatka" 1064 + }, 1065 + "chooseHandle": { 1066 + "title": "Valitse uusi käyttäjätunnuksesi", 1067 + "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.", 1068 + "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}" 1069 + }, 1070 + "review": { 1071 + "title": "Tarkista siirto", 1072 + "desc": "Tarkista ja vahvista siirtotietosi.", 1073 + "currentHandle": "Nykyinen käyttäjätunnus", 1074 + "newHandle": "Uusi käyttäjätunnus", 1075 + "sourcePds": "Lähde-PDS", 1076 + "targetPds": "Tämä PDS", 1077 + "email": "Sähköposti", 1078 + "inviteCode": "Kutsukoodi", 1079 + "confirm": "Vahvistan haluavani siirtää tilini", 1080 + "startMigration": "Aloita siirto" 1081 + }, 1082 + "migrating": { 1083 + "title": "Siirretään tiliäsi", 1084 + "desc": "Odota, kun siirrämme tietojasi...", 1085 + "gettingServiceAuth": "Haetaan palveluvaltuutusta...", 1086 + "creatingAccount": "Luodaan tiliä uuteen PDS:ään...", 1087 + "exportingRepo": "Viedään tietovarastoa...", 1088 + "importingRepo": "Tuodaan tietovarastoa...", 1089 + "countingBlobs": "Lasketaan blob-tiedostoja...", 1090 + "migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...", 1091 + "migratingPrefs": "Siirretään asetuksia...", 1092 + "requestingPlc": "Pyydetään PLC-toimintoa..." 1093 + }, 1094 + "emailVerify": { 1095 + "title": "Vahvista sähköpostisi", 1096 + "desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.", 1097 + "hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.", 1098 + "tokenLabel": "Vahvistuskoodi", 1099 + "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1100 + "resend": "Lähetä koodi uudelleen", 1101 + "verify": "Vahvista sähköposti", 1102 + "verifying": "Vahvistetaan..." 1103 + }, 1104 + "plcToken": { 1105 + "title": "Vahvista henkilöllisyytesi", 1106 + "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.", 1107 + "tokenLabel": "Vahvistuskoodi", 1108 + "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1109 + "resend": "Lähetä uudelleen", 1110 + "resending": "Lähetetään..." 1111 + }, 1112 + "finalizing": { 1113 + "title": "Viimeistellään siirtoa", 1114 + "desc": "Odota, kun viimeistelemme siirtoa...", 1115 + "signingPlc": "Allekirjoita identiteettipäivitys", 1116 + "activating": "Aktivoi tili uudessa PDS:ssä", 1117 + "deactivating": "Deaktivoi tili vanhassa PDS:ssä" 1118 + }, 1119 + "success": { 1120 + "title": "Siirto valmis!", 1121 + "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.", 1122 + "newHandle": "Uusi käyttäjätunnus", 1123 + "did": "DID", 1124 + "goToDashboard": "Siirry hallintapaneeliin" 1125 + } 1126 + }, 1127 + "outbound": { 1128 + "welcome": { 1129 + "title": "Siirrä pois tästä PDS:stä", 1130 + "desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.", 1131 + "warning": "Siirron jälkeen tilisi täällä deaktivoidaan.", 1132 + "didWebNotice": "did:web-siirtoilmoitus", 1133 + "didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.", 1134 + "understand": "Ymmärrän riskit ja haluan jatkaa" 1135 + }, 1136 + "targetPds": { 1137 + "title": "Valitse kohde-PDS", 1138 + "desc": "Syötä sen PDS:n URL, johon haluat siirtyä.", 1139 + "url": "PDS URL", 1140 + "urlPlaceholder": "https://pds.example.com", 1141 + "validate": "Vahvista ja jatka", 1142 + "validating": "Vahvistetaan...", 1143 + "connected": "Yhdistetty: {name}", 1144 + "inviteRequired": "Kutsukoodi vaaditaan", 1145 + "privacyPolicy": "Tietosuojakäytäntö", 1146 + "termsOfService": "Käyttöehdot" 1147 + }, 1148 + "newAccount": { 1149 + "title": "Uuden tilin tiedot", 1150 + "desc": "Määritä tilisi uudessa PDS:ssä.", 1151 + "handle": "Käyttäjätunnus", 1152 + "availableDomains": "Käytettävissä olevat verkkotunnukset", 1153 + "email": "Sähköposti", 1154 + "password": "Salasana", 1155 + "confirmPassword": "Vahvista salasana", 1156 + "inviteCode": "Kutsukoodi" 1157 + }, 1158 + "review": { 1159 + "title": "Tarkista siirto", 1160 + "desc": "Tarkista ja vahvista siirtotietosi.", 1161 + "currentHandle": "Nykyinen käyttäjätunnus", 1162 + "newHandle": "Uusi käyttäjätunnus", 1163 + "sourcePds": "Tämä PDS", 1164 + "targetPds": "Kohde-PDS", 1165 + "confirm": "Vahvistan haluavani siirtää tilini", 1166 + "startMigration": "Aloita siirto" 1167 + }, 1168 + "migrating": { 1169 + "title": "Siirretään tiliäsi", 1170 + "desc": "Odota, kun siirrämme tietojasi..." 1171 + }, 1172 + "plcToken": { 1173 + "title": "Vahvista henkilöllisyytesi", 1174 + "desc": "Vahvistuskoodi on lähetetty sähköpostiisi." 1175 + }, 1176 + "finalizing": { 1177 + "title": "Viimeistellään siirtoa", 1178 + "desc": "Odota, kun viimeistelemme siirtoa...", 1179 + "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..." 1180 + }, 1181 + "success": { 1182 + "title": "Siirto valmis!", 1183 + "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.", 1184 + "newHandle": "Uusi käyttäjätunnus", 1185 + "newPds": "Uusi PDS", 1186 + "nextSteps": "Seuraavat vaiheet", 1187 + "nextSteps1": "Kirjaudu uuteen PDS:ääsi", 1188 + "nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi", 1189 + "nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi", 1190 + "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..." 1191 + } 1192 + }, 1193 + "progress": { 1194 + "repoExported": "Tietovarasto viety", 1195 + "repoImported": "Tietovarasto tuotu", 1196 + "blobsMigrated": "{count} blob-tiedostoa siirretty", 1197 + "prefsMigrated": "Asetukset siirretty", 1198 + "plcSigned": "Identiteetti päivitetty", 1199 + "activated": "Tili aktivoitu", 1200 + "deactivated": "Vanha tili deaktivoitu" 1201 + }, 1202 + "errors": { 1203 + "connectionFailed": "Yhteys PDS:ään epäonnistui", 1204 + "invalidCredentials": "Virheelliset tunnukset", 1205 + "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan", 1206 + "accountExists": "Tili on jo olemassa kohde-PDS:ssä", 1207 + "plcFailed": "PLC-toiminto epäonnistui", 1208 + "blobFailed": "Blob-tiedoston siirto epäonnistui: {cid}", 1209 + "networkError": "Verkkovirhe. Yritä uudelleen." 1210 + } 1211 } 1212 }
+201
frontend/src/locales/ja.json
··· 1029 "permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。", 1030 "viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。", 1031 "editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。" 1032 } 1033 }
··· 1029 "permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。", 1030 "viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。", 1031 "editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。" 1032 + }, 1033 + "migration": { 1034 + "title": "アカウント移行", 1035 + "subtitle": "AT Protocolアイデンティティをサーバー間で移動", 1036 + "navTitle": "移行", 1037 + "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", 1038 + "migrateHere": "ここに移行", 1039 + "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", 1040 + "migrateAway": "別の場所に移行", 1041 + "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。", 1042 + "loginRequired": "ログインが必要です", 1043 + "bringDid": "DIDとアイデンティティを持ち込む", 1044 + "transferData": "すべてのデータを転送", 1045 + "keepFollowers": "フォロワーを維持", 1046 + "exportRepo": "リポジトリをエクスポート", 1047 + "transferToPds": "新しいPDSに転送", 1048 + "updateIdentity": "アイデンティティを更新", 1049 + "whatIsMigration": "アカウント移行とは?", 1050 + "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", 1051 + "beforeMigrate": "移行前の確認事項", 1052 + "beforeMigrate1": "現在のアカウント認証情報が必要です", 1053 + "beforeMigrate2": "セキュリティのためメール認証が必要です", 1054 + "beforeMigrate3": "画像が多い大きなアカウントは数分かかる場合があります", 1055 + "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1056 + "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1057 + "learnMore": "移行のリスクについて詳しく", 1058 + "resume": { 1059 + "title": "移行を再開しますか?", 1060 + "incomplete": "未完了の移行があります:", 1061 + "direction": "方向", 1062 + "migratingHere": "ここに移行中", 1063 + "migratingAway": "別の場所に移行中", 1064 + "from": "移行元", 1065 + "to": "移行先", 1066 + "progress": "進行状況", 1067 + "reenterCredentials": "続行するには認証情報を再入力する必要があります。", 1068 + "startOver": "最初からやり直す", 1069 + "resumeButton": "再開" 1070 + }, 1071 + "inbound": { 1072 + "welcome": { 1073 + "title": "このPDSに移行", 1074 + "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。", 1075 + "understand": "リスクを理解し、続行します" 1076 + }, 1077 + "sourceLogin": { 1078 + "title": "現在のPDSにサインイン", 1079 + "desc": "移行するアカウントの認証情報を入力してください。", 1080 + "handle": "ハンドル", 1081 + "handlePlaceholder": "you.bsky.social", 1082 + "password": "パスワード", 1083 + "twoFactorCode": "2要素認証コード", 1084 + "twoFactorRequired": "2要素認証が必要です", 1085 + "signIn": "サインインして続行" 1086 + }, 1087 + "chooseHandle": { 1088 + "title": "新しいハンドルを選択", 1089 + "desc": "このPDSでのアカウントのハンドルを選択してください。", 1090 + "handleHint": "完全なハンドル: @{handle}" 1091 + }, 1092 + "review": { 1093 + "title": "移行の確認", 1094 + "desc": "移行の詳細を確認してください。", 1095 + "currentHandle": "現在のハンドル", 1096 + "newHandle": "新しいハンドル", 1097 + "sourcePds": "移行元PDS", 1098 + "targetPds": "このPDS", 1099 + "email": "メール", 1100 + "inviteCode": "招待コード", 1101 + "confirm": "アカウントを移行することを確認します", 1102 + "startMigration": "移行を開始" 1103 + }, 1104 + "migrating": { 1105 + "title": "アカウントを移行中", 1106 + "desc": "データを転送しています...", 1107 + "gettingServiceAuth": "サービス認証を取得中...", 1108 + "creatingAccount": "新しいPDSにアカウントを作成中...", 1109 + "exportingRepo": "リポジトリをエクスポート中...", 1110 + "importingRepo": "リポジトリをインポート中...", 1111 + "countingBlobs": "blobをカウント中...", 1112 + "migratingBlobs": "blobを移行中 ({current}/{total})...", 1113 + "migratingPrefs": "設定を移行中...", 1114 + "requestingPlc": "PLC操作をリクエスト中..." 1115 + }, 1116 + "emailVerify": { 1117 + "title": "メールアドレスを確認", 1118 + "desc": "確認コードが {email} に送信されました。", 1119 + "hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。", 1120 + "tokenLabel": "確認コード", 1121 + "tokenPlaceholder": "メールに記載されたコードを入力", 1122 + "resend": "コードを再送信", 1123 + "verify": "メールを確認", 1124 + "verifying": "確認中..." 1125 + }, 1126 + "plcToken": { 1127 + "title": "本人確認", 1128 + "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。", 1129 + "tokenLabel": "確認トークン", 1130 + "tokenPlaceholder": "メールに記載されたトークンを入力", 1131 + "resend": "再送信", 1132 + "resending": "送信中..." 1133 + }, 1134 + "finalizing": { 1135 + "title": "移行を完了中", 1136 + "desc": "移行を完了しています...", 1137 + "signingPlc": "アイデンティティ更新に署名", 1138 + "activating": "新しいPDSでアカウントを有効化", 1139 + "deactivating": "古いPDSでアカウントを無効化" 1140 + }, 1141 + "success": { 1142 + "title": "移行完了!", 1143 + "desc": "アカウントはこのPDSに正常に移行されました。", 1144 + "newHandle": "新しいハンドル", 1145 + "did": "DID", 1146 + "goToDashboard": "ダッシュボードへ" 1147 + } 1148 + }, 1149 + "outbound": { 1150 + "welcome": { 1151 + "title": "このPDSから移行", 1152 + "desc": "アカウントを別のパーソナルデータサーバーに移動します。", 1153 + "warning": "移行後、ここでのアカウントは無効化されます。", 1154 + "didWebNotice": "did:web移行のお知らせ", 1155 + "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。", 1156 + "understand": "リスクを理解し、続行します" 1157 + }, 1158 + "targetPds": { 1159 + "title": "移行先PDSを選択", 1160 + "desc": "移行先のPDSのURLを入力してください。", 1161 + "url": "PDS URL", 1162 + "urlPlaceholder": "https://pds.example.com", 1163 + "validate": "検証して続行", 1164 + "validating": "検証中...", 1165 + "connected": "{name}に接続しました", 1166 + "inviteRequired": "招待コードが必要です", 1167 + "privacyPolicy": "プライバシーポリシー", 1168 + "termsOfService": "利用規約" 1169 + }, 1170 + "newAccount": { 1171 + "title": "新しいアカウントの詳細", 1172 + "desc": "新しいPDSでアカウントを設定します。", 1173 + "handle": "ハンドル", 1174 + "availableDomains": "利用可能なドメイン", 1175 + "email": "メール", 1176 + "password": "パスワード", 1177 + "confirmPassword": "パスワードを確認", 1178 + "inviteCode": "招待コード" 1179 + }, 1180 + "review": { 1181 + "title": "移行の確認", 1182 + "desc": "移行の詳細を確認してください。", 1183 + "currentHandle": "現在のハンドル", 1184 + "newHandle": "新しいハンドル", 1185 + "sourcePds": "このPDS", 1186 + "targetPds": "移行先PDS", 1187 + "confirm": "アカウントを移行することを確認します", 1188 + "startMigration": "移行を開始" 1189 + }, 1190 + "migrating": { 1191 + "title": "アカウントを移行中", 1192 + "desc": "データを転送しています..." 1193 + }, 1194 + "plcToken": { 1195 + "title": "本人確認", 1196 + "desc": "確認コードがメールに送信されました。" 1197 + }, 1198 + "finalizing": { 1199 + "title": "移行を完了中", 1200 + "desc": "移行を完了しています...", 1201 + "updatingForwarding": "DIDドキュメントの転送先を更新中..." 1202 + }, 1203 + "success": { 1204 + "title": "移行完了!", 1205 + "desc": "アカウントは新しいPDSに正常に移行されました。", 1206 + "newHandle": "新しいハンドル", 1207 + "newPds": "新しいPDS", 1208 + "nextSteps": "次のステップ", 1209 + "nextSteps1": "新しいPDSにサインイン", 1210 + "nextSteps2": "アプリの認証情報を更新", 1211 + "nextSteps3": "フォロワーは自動的に新しい場所を確認できます", 1212 + "loggingOut": "{seconds}秒後にログアウトします..." 1213 + } 1214 + }, 1215 + "progress": { 1216 + "repoExported": "リポジトリをエクスポートしました", 1217 + "repoImported": "リポジトリをインポートしました", 1218 + "blobsMigrated": "{count}個のblobを移行しました", 1219 + "prefsMigrated": "設定を移行しました", 1220 + "plcSigned": "アイデンティティを更新しました", 1221 + "activated": "アカウントを有効化しました", 1222 + "deactivated": "古いアカウントを無効化しました" 1223 + }, 1224 + "errors": { 1225 + "connectionFailed": "PDSに接続できませんでした", 1226 + "invalidCredentials": "認証情報が無効です", 1227 + "twoFactorRequired": "2要素認証が必要です", 1228 + "accountExists": "移行先PDSにアカウントが既に存在します", 1229 + "plcFailed": "PLC操作に失敗しました", 1230 + "blobFailed": "blobの移行に失敗しました: {cid}", 1231 + "networkError": "ネットワークエラー。再試行してください。" 1232 + } 1233 } 1234 }
+201
frontend/src/locales/ko.json
··· 1029 "permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.", 1030 "viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.", 1031 "editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다." 1032 } 1033 }
··· 1029 "permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.", 1030 "viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.", 1031 "editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다." 1032 + }, 1033 + "migration": { 1034 + "title": "계정 마이그레이션", 1035 + "subtitle": "AT Protocol 아이덴티티를 서버 간에 이동", 1036 + "navTitle": "마이그레이션", 1037 + "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", 1038 + "migrateHere": "여기로 마이그레이션", 1039 + "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", 1040 + "migrateAway": "다른 곳으로 마이그레이션", 1041 + "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.", 1042 + "loginRequired": "로그인 필요", 1043 + "bringDid": "DID와 아이덴티티 가져오기", 1044 + "transferData": "모든 데이터 전송", 1045 + "keepFollowers": "팔로워 유지", 1046 + "exportRepo": "저장소 내보내기", 1047 + "transferToPds": "새 PDS로 전송", 1048 + "updateIdentity": "아이덴티티 업데이트", 1049 + "whatIsMigration": "계정 마이그레이션이란?", 1050 + "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", 1051 + "beforeMigrate": "마이그레이션 전 확인사항", 1052 + "beforeMigrate1": "현재 계정 인증 정보가 필요합니다", 1053 + "beforeMigrate2": "보안을 위해 이메일 인증이 필요합니다", 1054 + "beforeMigrate3": "이미지가 많은 대용량 계정은 몇 분이 걸릴 수 있습니다", 1055 + "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1056 + "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1057 + "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1058 + "resume": { 1059 + "title": "마이그레이션을 재개하시겠습니까?", 1060 + "incomplete": "완료되지 않은 마이그레이션이 있습니다:", 1061 + "direction": "방향", 1062 + "migratingHere": "여기로 마이그레이션 중", 1063 + "migratingAway": "다른 곳으로 마이그레이션 중", 1064 + "from": "출발지", 1065 + "to": "목적지", 1066 + "progress": "진행 상황", 1067 + "reenterCredentials": "계속하려면 인증 정보를 다시 입력해야 합니다.", 1068 + "startOver": "처음부터 다시 시작", 1069 + "resumeButton": "재개" 1070 + }, 1071 + "inbound": { 1072 + "welcome": { 1073 + "title": "이 PDS로 마이그레이션", 1074 + "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.", 1075 + "understand": "위험을 이해하고 계속 진행합니다" 1076 + }, 1077 + "sourceLogin": { 1078 + "title": "현재 PDS에 로그인", 1079 + "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.", 1080 + "handle": "핸들", 1081 + "handlePlaceholder": "you.bsky.social", 1082 + "password": "비밀번호", 1083 + "twoFactorCode": "2단계 인증 코드", 1084 + "twoFactorRequired": "2단계 인증이 필요합니다", 1085 + "signIn": "로그인 및 계속" 1086 + }, 1087 + "chooseHandle": { 1088 + "title": "새 핸들 선택", 1089 + "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.", 1090 + "handleHint": "전체 핸들: @{handle}" 1091 + }, 1092 + "review": { 1093 + "title": "마이그레이션 검토", 1094 + "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1095 + "currentHandle": "현재 핸들", 1096 + "newHandle": "새 핸들", 1097 + "sourcePds": "소스 PDS", 1098 + "targetPds": "이 PDS", 1099 + "email": "이메일", 1100 + "inviteCode": "초대 코드", 1101 + "confirm": "계정 마이그레이션을 확인합니다", 1102 + "startMigration": "마이그레이션 시작" 1103 + }, 1104 + "migrating": { 1105 + "title": "계정 마이그레이션 중", 1106 + "desc": "데이터를 전송하는 중입니다...", 1107 + "gettingServiceAuth": "서비스 인증 획득 중...", 1108 + "creatingAccount": "새 PDS에 계정 생성 중...", 1109 + "exportingRepo": "저장소 내보내기 중...", 1110 + "importingRepo": "저장소 가져오기 중...", 1111 + "countingBlobs": "blob 개수 세는 중...", 1112 + "migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...", 1113 + "migratingPrefs": "환경설정 마이그레이션 중...", 1114 + "requestingPlc": "PLC 작업 요청 중..." 1115 + }, 1116 + "emailVerify": { 1117 + "title": "이메일 인증", 1118 + "desc": "인증 코드가 {email}(으)로 전송되었습니다.", 1119 + "hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.", 1120 + "tokenLabel": "인증 코드", 1121 + "tokenPlaceholder": "이메일에서 받은 코드 입력", 1122 + "resend": "코드 재전송", 1123 + "verify": "이메일 인증", 1124 + "verifying": "인증 중..." 1125 + }, 1126 + "plcToken": { 1127 + "title": "신원 확인", 1128 + "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.", 1129 + "tokenLabel": "인증 토큰", 1130 + "tokenPlaceholder": "이메일에서 받은 토큰 입력", 1131 + "resend": "재전송", 1132 + "resending": "전송 중..." 1133 + }, 1134 + "finalizing": { 1135 + "title": "마이그레이션 완료 중", 1136 + "desc": "마이그레이션을 완료하는 중입니다...", 1137 + "signingPlc": "아이덴티티 업데이트 서명", 1138 + "activating": "새 PDS에서 계정 활성화", 1139 + "deactivating": "이전 PDS에서 계정 비활성화" 1140 + }, 1141 + "success": { 1142 + "title": "마이그레이션 완료!", 1143 + "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.", 1144 + "newHandle": "새 핸들", 1145 + "did": "DID", 1146 + "goToDashboard": "대시보드로 이동" 1147 + } 1148 + }, 1149 + "outbound": { 1150 + "welcome": { 1151 + "title": "이 PDS에서 마이그레이션", 1152 + "desc": "계정을 다른 개인 데이터 서버로 이동합니다.", 1153 + "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.", 1154 + "didWebNotice": "did:web 마이그레이션 알림", 1155 + "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.", 1156 + "understand": "위험을 이해하고 계속 진행합니다" 1157 + }, 1158 + "targetPds": { 1159 + "title": "대상 PDS 선택", 1160 + "desc": "마이그레이션할 PDS의 URL을 입력하세요.", 1161 + "url": "PDS URL", 1162 + "urlPlaceholder": "https://pds.example.com", 1163 + "validate": "확인 및 계속", 1164 + "validating": "확인 중...", 1165 + "connected": "{name}에 연결됨", 1166 + "inviteRequired": "초대 코드 필요", 1167 + "privacyPolicy": "개인정보 처리방침", 1168 + "termsOfService": "서비스 약관" 1169 + }, 1170 + "newAccount": { 1171 + "title": "새 계정 세부 정보", 1172 + "desc": "새 PDS에서 계정을 설정합니다.", 1173 + "handle": "핸들", 1174 + "availableDomains": "사용 가능한 도메인", 1175 + "email": "이메일", 1176 + "password": "비밀번호", 1177 + "confirmPassword": "비밀번호 확인", 1178 + "inviteCode": "초대 코드" 1179 + }, 1180 + "review": { 1181 + "title": "마이그레이션 검토", 1182 + "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1183 + "currentHandle": "현재 핸들", 1184 + "newHandle": "새 핸들", 1185 + "sourcePds": "이 PDS", 1186 + "targetPds": "대상 PDS", 1187 + "confirm": "계정 마이그레이션을 확인합니다", 1188 + "startMigration": "마이그레이션 시작" 1189 + }, 1190 + "migrating": { 1191 + "title": "계정 마이그레이션 중", 1192 + "desc": "데이터를 전송하는 중입니다..." 1193 + }, 1194 + "plcToken": { 1195 + "title": "신원 확인", 1196 + "desc": "이메일로 인증 코드가 전송되었습니다." 1197 + }, 1198 + "finalizing": { 1199 + "title": "마이그레이션 완료 중", 1200 + "desc": "마이그레이션을 완료하는 중입니다...", 1201 + "updatingForwarding": "DID 문서 포워딩 업데이트 중..." 1202 + }, 1203 + "success": { 1204 + "title": "마이그레이션 완료!", 1205 + "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.", 1206 + "newHandle": "새 핸들", 1207 + "newPds": "새 PDS", 1208 + "nextSteps": "다음 단계", 1209 + "nextSteps1": "새 PDS에 로그인", 1210 + "nextSteps2": "새 인증 정보로 앱 업데이트", 1211 + "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다", 1212 + "loggingOut": "{seconds}초 후 로그아웃됩니다..." 1213 + } 1214 + }, 1215 + "progress": { 1216 + "repoExported": "저장소 내보내기 완료", 1217 + "repoImported": "저장소 가져오기 완료", 1218 + "blobsMigrated": "{count}개 blob 마이그레이션됨", 1219 + "prefsMigrated": "환경설정 마이그레이션됨", 1220 + "plcSigned": "아이덴티티 업데이트됨", 1221 + "activated": "계정 활성화됨", 1222 + "deactivated": "이전 계정 비활성화됨" 1223 + }, 1224 + "errors": { 1225 + "connectionFailed": "PDS에 연결할 수 없습니다", 1226 + "invalidCredentials": "잘못된 인증 정보", 1227 + "twoFactorRequired": "2단계 인증이 필요합니다", 1228 + "accountExists": "대상 PDS에 계정이 이미 존재합니다", 1229 + "plcFailed": "PLC 작업 실패", 1230 + "blobFailed": "blob 마이그레이션 실패: {cid}", 1231 + "networkError": "네트워크 오류. 다시 시도하세요." 1232 + } 1233 } 1234 }
+201
frontend/src/locales/sv.json
··· 1029 "permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.", 1030 "viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.", 1031 "editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet." 1032 } 1033 }
··· 1029 "permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.", 1030 "viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.", 1031 "editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet." 1032 + }, 1033 + "migration": { 1034 + "title": "Kontoflyttning", 1035 + "subtitle": "Flytta din AT Protocol-identitet mellan servrar", 1036 + "navTitle": "Flytta", 1037 + "navDesc": "Flytta ditt konto till eller från en annan PDS", 1038 + "migrateHere": "Flytta hit", 1039 + "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.", 1040 + "migrateAway": "Flytta bort", 1041 + "migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.", 1042 + "loginRequired": "Inloggning krävs", 1043 + "bringDid": "Ta med din DID och identitet", 1044 + "transferData": "Överför all din data", 1045 + "keepFollowers": "Behåll dina följare", 1046 + "exportRepo": "Exportera ditt arkiv", 1047 + "transferToPds": "Överför till ny PDS", 1048 + "updateIdentity": "Uppdatera din identitet", 1049 + "whatIsMigration": "Vad är kontoflyttning?", 1050 + "whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.", 1051 + "beforeMigrate": "Innan du flyttar", 1052 + "beforeMigrate1": "Du behöver dina nuvarande kontouppgifter", 1053 + "beforeMigrate2": "Flytt kräver e-postverifiering för säkerhet", 1054 + "beforeMigrate3": "Stora konton med många bilder kan ta flera minuter", 1055 + "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1056 + "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.", 1057 + "learnMore": "Läs mer om flyttningsrisker", 1058 + "resume": { 1059 + "title": "Återuppta flytt?", 1060 + "incomplete": "Du har en ofullständig flytt pågående:", 1061 + "direction": "Riktning", 1062 + "migratingHere": "Flyttar hit", 1063 + "migratingAway": "Flyttar bort", 1064 + "from": "Från", 1065 + "to": "Till", 1066 + "progress": "Framsteg", 1067 + "reenterCredentials": "Du måste ange dina uppgifter igen för att fortsätta.", 1068 + "startOver": "Börja om", 1069 + "resumeButton": "Återuppta" 1070 + }, 1071 + "inbound": { 1072 + "welcome": { 1073 + "title": "Flytta till denna PDS", 1074 + "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.", 1075 + "understand": "Jag förstår riskerna och vill fortsätta" 1076 + }, 1077 + "sourceLogin": { 1078 + "title": "Logga in på din nuvarande PDS", 1079 + "desc": "Ange uppgifterna för kontot du vill flytta.", 1080 + "handle": "Användarnamn", 1081 + "handlePlaceholder": "du.bsky.social", 1082 + "password": "Lösenord", 1083 + "twoFactorCode": "Tvåfaktorkod", 1084 + "twoFactorRequired": "Tvåfaktorautentisering krävs", 1085 + "signIn": "Logga in och fortsätt" 1086 + }, 1087 + "chooseHandle": { 1088 + "title": "Välj ditt nya användarnamn", 1089 + "desc": "Välj ett användarnamn för ditt konto på denna PDS.", 1090 + "handleHint": "Ditt fullständiga användarnamn blir: @{handle}" 1091 + }, 1092 + "review": { 1093 + "title": "Granska flytt", 1094 + "desc": "Granska och bekräfta dina flyttdetaljer.", 1095 + "currentHandle": "Nuvarande användarnamn", 1096 + "newHandle": "Nytt användarnamn", 1097 + "sourcePds": "Käll-PDS", 1098 + "targetPds": "Denna PDS", 1099 + "email": "E-post", 1100 + "inviteCode": "Inbjudningskod", 1101 + "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1102 + "startMigration": "Starta flytt" 1103 + }, 1104 + "migrating": { 1105 + "title": "Flyttar ditt konto", 1106 + "desc": "Vänta medan vi överför din data...", 1107 + "gettingServiceAuth": "Hämtar tjänstauktorisering...", 1108 + "creatingAccount": "Skapar konto på ny PDS...", 1109 + "exportingRepo": "Exporterar arkiv...", 1110 + "importingRepo": "Importerar arkiv...", 1111 + "countingBlobs": "Räknar blobbar...", 1112 + "migratingBlobs": "Flyttar blobbar ({current}/{total})...", 1113 + "migratingPrefs": "Flyttar inställningar...", 1114 + "requestingPlc": "Begär PLC-operation..." 1115 + }, 1116 + "emailVerify": { 1117 + "title": "Verifiera din e-post", 1118 + "desc": "En verifieringskod har skickats till {email}.", 1119 + "hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.", 1120 + "tokenLabel": "Verifieringskod", 1121 + "tokenPlaceholder": "Ange kod från e-post", 1122 + "resend": "Skicka kod igen", 1123 + "verify": "Verifiera e-post", 1124 + "verifying": "Verifierar..." 1125 + }, 1126 + "plcToken": { 1127 + "title": "Verifiera din identitet", 1128 + "desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.", 1129 + "tokenLabel": "Verifieringstoken", 1130 + "tokenPlaceholder": "Ange token från din e-post", 1131 + "resend": "Skicka igen", 1132 + "resending": "Skickar..." 1133 + }, 1134 + "finalizing": { 1135 + "title": "Slutför flytt", 1136 + "desc": "Vänta medan vi slutför flytten...", 1137 + "signingPlc": "Signera identitetsuppdatering", 1138 + "activating": "Aktivera konto på ny PDS", 1139 + "deactivating": "Inaktivera konto på gammal PDS" 1140 + }, 1141 + "success": { 1142 + "title": "Flytt klar!", 1143 + "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.", 1144 + "newHandle": "Nytt användarnamn", 1145 + "did": "DID", 1146 + "goToDashboard": "Gå till instrumentpanel" 1147 + } 1148 + }, 1149 + "outbound": { 1150 + "welcome": { 1151 + "title": "Flytta från denna PDS", 1152 + "desc": "Flytta ditt konto till en annan personlig dataserver.", 1153 + "warning": "Efter flytten kommer ditt konto här att inaktiveras.", 1154 + "didWebNotice": "did:web-flyttmeddelande", 1155 + "didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.", 1156 + "understand": "Jag förstår riskerna och vill fortsätta" 1157 + }, 1158 + "targetPds": { 1159 + "title": "Välj mål-PDS", 1160 + "desc": "Ange URL:en för PDS du vill flytta till.", 1161 + "url": "PDS URL", 1162 + "urlPlaceholder": "https://pds.example.com", 1163 + "validate": "Validera och fortsätt", 1164 + "validating": "Validerar...", 1165 + "connected": "Ansluten till {name}", 1166 + "inviteRequired": "Inbjudningskod krävs", 1167 + "privacyPolicy": "Integritetspolicy", 1168 + "termsOfService": "Användarvillkor" 1169 + }, 1170 + "newAccount": { 1171 + "title": "Nya kontouppgifter", 1172 + "desc": "Konfigurera ditt konto på den nya PDS.", 1173 + "handle": "Användarnamn", 1174 + "availableDomains": "Tillgängliga domäner", 1175 + "email": "E-post", 1176 + "password": "Lösenord", 1177 + "confirmPassword": "Bekräfta lösenord", 1178 + "inviteCode": "Inbjudningskod" 1179 + }, 1180 + "review": { 1181 + "title": "Granska flytt", 1182 + "desc": "Granska och bekräfta dina flyttdetaljer.", 1183 + "currentHandle": "Nuvarande användarnamn", 1184 + "newHandle": "Nytt användarnamn", 1185 + "sourcePds": "Denna PDS", 1186 + "targetPds": "Mål-PDS", 1187 + "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1188 + "startMigration": "Starta flytt" 1189 + }, 1190 + "migrating": { 1191 + "title": "Flyttar ditt konto", 1192 + "desc": "Vänta medan vi överför din data..." 1193 + }, 1194 + "plcToken": { 1195 + "title": "Verifiera din identitet", 1196 + "desc": "En verifieringskod har skickats till din e-post." 1197 + }, 1198 + "finalizing": { 1199 + "title": "Slutför flytt", 1200 + "desc": "Vänta medan vi slutför flytten...", 1201 + "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." 1202 + }, 1203 + "success": { 1204 + "title": "Flytt klar!", 1205 + "desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.", 1206 + "newHandle": "Nytt användarnamn", 1207 + "newPds": "Ny PDS", 1208 + "nextSteps": "Nästa steg", 1209 + "nextSteps1": "Logga in på din nya PDS", 1210 + "nextSteps2": "Uppdatera dina appar med nya uppgifter", 1211 + "nextSteps3": "Dina följare kommer automatiskt se din nya plats", 1212 + "loggingOut": "Loggar ut om {seconds} sekunder..." 1213 + } 1214 + }, 1215 + "progress": { 1216 + "repoExported": "Arkiv exporterat", 1217 + "repoImported": "Arkiv importerat", 1218 + "blobsMigrated": "{count} blobbar flyttade", 1219 + "prefsMigrated": "Inställningar flyttade", 1220 + "plcSigned": "Identitet uppdaterad", 1221 + "activated": "Konto aktiverat", 1222 + "deactivated": "Gammalt konto inaktiverat" 1223 + }, 1224 + "errors": { 1225 + "connectionFailed": "Kunde inte ansluta till PDS", 1226 + "invalidCredentials": "Ogiltiga uppgifter", 1227 + "twoFactorRequired": "Tvåfaktorautentisering krävs", 1228 + "accountExists": "Konto finns redan på mål-PDS", 1229 + "plcFailed": "PLC-operation misslyckades", 1230 + "blobFailed": "Kunde inte flytta blob: {cid}", 1231 + "networkError": "Nätverksfel. Försök igen." 1232 + } 1233 } 1234 }
+201
frontend/src/locales/zh.json
··· 1013 "permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。", 1014 "viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。", 1015 "editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。" 1016 } 1017 }
··· 1013 "permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。", 1014 "viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。", 1015 "editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。" 1016 + }, 1017 + "migration": { 1018 + "title": "账户迁移", 1019 + "subtitle": "在服务器之间移动您的AT Protocol身份", 1020 + "navTitle": "迁移", 1021 + "navDesc": "将您的账户移至其他PDS或从其他PDS移入", 1022 + "migrateHere": "迁移到此处", 1023 + "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", 1024 + "migrateAway": "迁移离开", 1025 + "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。", 1026 + "loginRequired": "需要登录", 1027 + "bringDid": "携带您的DID和身份", 1028 + "transferData": "转移所有数据", 1029 + "keepFollowers": "保留您的关注者", 1030 + "exportRepo": "导出您的存储库", 1031 + "transferToPds": "转移到新PDS", 1032 + "updateIdentity": "更新您的身份", 1033 + "whatIsMigration": "什么是账户迁移?", 1034 + "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", 1035 + "beforeMigrate": "迁移前须知", 1036 + "beforeMigrate1": "您需要当前账户的凭据", 1037 + "beforeMigrate2": "为确保安全,迁移需要邮箱验证", 1038 + "beforeMigrate3": "包含大量图片的大型账户可能需要几分钟", 1039 + "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1040 + "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1041 + "learnMore": "了解更多迁移风险", 1042 + "resume": { 1043 + "title": "恢复迁移?", 1044 + "incomplete": "您有一个未完成的迁移:", 1045 + "direction": "方向", 1046 + "migratingHere": "正在迁移到此处", 1047 + "migratingAway": "正在迁移离开", 1048 + "from": "从", 1049 + "to": "到", 1050 + "progress": "进度", 1051 + "reenterCredentials": "您需要重新输入凭据以继续。", 1052 + "startOver": "重新开始", 1053 + "resumeButton": "恢复" 1054 + }, 1055 + "inbound": { 1056 + "welcome": { 1057 + "title": "迁移到此PDS", 1058 + "desc": "将您现有的AT Protocol账户移至此服务器。", 1059 + "understand": "我了解风险并希望继续" 1060 + }, 1061 + "sourceLogin": { 1062 + "title": "登录到您当前的PDS", 1063 + "desc": "输入您要迁移的账户凭据。", 1064 + "handle": "用户名", 1065 + "handlePlaceholder": "you.bsky.social", 1066 + "password": "密码", 1067 + "twoFactorCode": "双因素验证码", 1068 + "twoFactorRequired": "需要双因素认证", 1069 + "signIn": "登录并继续" 1070 + }, 1071 + "chooseHandle": { 1072 + "title": "选择新用户名", 1073 + "desc": "为您在此PDS上的账户选择用户名。", 1074 + "handleHint": "您的完整用户名将是:@{handle}" 1075 + }, 1076 + "review": { 1077 + "title": "检查迁移", 1078 + "desc": "请检查并确认您的迁移详情。", 1079 + "currentHandle": "当前用户名", 1080 + "newHandle": "新用户名", 1081 + "sourcePds": "源PDS", 1082 + "targetPds": "此PDS", 1083 + "email": "邮箱", 1084 + "inviteCode": "邀请码", 1085 + "confirm": "我确认要迁移我的账户", 1086 + "startMigration": "开始迁移" 1087 + }, 1088 + "migrating": { 1089 + "title": "正在迁移您的账户", 1090 + "desc": "请稍候,正在转移您的数据...", 1091 + "gettingServiceAuth": "正在获取服务授权...", 1092 + "creatingAccount": "正在新PDS上创建账户...", 1093 + "exportingRepo": "正在导出存储库...", 1094 + "importingRepo": "正在导入存储库...", 1095 + "countingBlobs": "正在统计blob...", 1096 + "migratingBlobs": "正在迁移blob ({current}/{total})...", 1097 + "migratingPrefs": "正在迁移偏好设置...", 1098 + "requestingPlc": "正在请求PLC操作..." 1099 + }, 1100 + "emailVerify": { 1101 + "title": "验证您的邮箱", 1102 + "desc": "验证码已发送至 {email}。", 1103 + "hint": "在下方输入验证码,或点击邮件中的链接自动继续。", 1104 + "tokenLabel": "验证码", 1105 + "tokenPlaceholder": "输入邮件中的验证码", 1106 + "resend": "重新发送", 1107 + "verify": "验证邮箱", 1108 + "verifying": "验证中..." 1109 + }, 1110 + "plcToken": { 1111 + "title": "验证您的身份", 1112 + "desc": "验证码已发送到您在当前PDS注册的邮箱。", 1113 + "tokenLabel": "验证令牌", 1114 + "tokenPlaceholder": "输入邮件中的令牌", 1115 + "resend": "重新发送", 1116 + "resending": "发送中..." 1117 + }, 1118 + "finalizing": { 1119 + "title": "正在完成迁移", 1120 + "desc": "请稍候,正在完成迁移...", 1121 + "signingPlc": "签署身份更新", 1122 + "activating": "在新PDS上激活账户", 1123 + "deactivating": "在旧PDS上停用账户" 1124 + }, 1125 + "success": { 1126 + "title": "迁移完成!", 1127 + "desc": "您的账户已成功迁移到此PDS。", 1128 + "newHandle": "新用户名", 1129 + "did": "DID", 1130 + "goToDashboard": "前往仪表板" 1131 + } 1132 + }, 1133 + "outbound": { 1134 + "welcome": { 1135 + "title": "从此PDS迁移离开", 1136 + "desc": "将您的账户移至另一个个人数据服务器。", 1137 + "warning": "迁移后,您在此处的账户将被停用。", 1138 + "didWebNotice": "did:web迁移通知", 1139 + "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。", 1140 + "understand": "我了解风险并希望继续" 1141 + }, 1142 + "targetPds": { 1143 + "title": "选择目标PDS", 1144 + "desc": "输入您要迁移到的PDS的URL。", 1145 + "url": "PDS URL", 1146 + "urlPlaceholder": "https://pds.example.com", 1147 + "validate": "验证并继续", 1148 + "validating": "验证中...", 1149 + "connected": "已连接到 {name}", 1150 + "inviteRequired": "需要邀请码", 1151 + "privacyPolicy": "隐私政策", 1152 + "termsOfService": "服务条款" 1153 + }, 1154 + "newAccount": { 1155 + "title": "新账户详情", 1156 + "desc": "在新PDS上设置您的账户。", 1157 + "handle": "用户名", 1158 + "availableDomains": "可用域名", 1159 + "email": "邮箱", 1160 + "password": "密码", 1161 + "confirmPassword": "确认密码", 1162 + "inviteCode": "邀请码" 1163 + }, 1164 + "review": { 1165 + "title": "检查迁移", 1166 + "desc": "请检查并确认您的迁移详情。", 1167 + "currentHandle": "当前用户名", 1168 + "newHandle": "新用户名", 1169 + "sourcePds": "此PDS", 1170 + "targetPds": "目标PDS", 1171 + "confirm": "我确认要迁移我的账户", 1172 + "startMigration": "开始迁移" 1173 + }, 1174 + "migrating": { 1175 + "title": "正在迁移您的账户", 1176 + "desc": "请稍候,正在转移您的数据..." 1177 + }, 1178 + "plcToken": { 1179 + "title": "验证您的身份", 1180 + "desc": "验证码已发送到您的邮箱。" 1181 + }, 1182 + "finalizing": { 1183 + "title": "正在完成迁移", 1184 + "desc": "请稍候,正在完成迁移...", 1185 + "updatingForwarding": "正在更新DID文档转发..." 1186 + }, 1187 + "success": { 1188 + "title": "迁移完成!", 1189 + "desc": "您的账户已成功迁移到新PDS。", 1190 + "newHandle": "新用户名", 1191 + "newPds": "新PDS", 1192 + "nextSteps": "后续步骤", 1193 + "nextSteps1": "登录到您的新PDS", 1194 + "nextSteps2": "使用新凭据更新您的应用", 1195 + "nextSteps3": "您的关注者将自动看到您的新位置", 1196 + "loggingOut": "{seconds}秒后退出登录..." 1197 + } 1198 + }, 1199 + "progress": { 1200 + "repoExported": "存储库已导出", 1201 + "repoImported": "存储库已导入", 1202 + "blobsMigrated": "已迁移{count}个blob", 1203 + "prefsMigrated": "偏好设置已迁移", 1204 + "plcSigned": "身份已更新", 1205 + "activated": "账户已激活", 1206 + "deactivated": "旧账户已停用" 1207 + }, 1208 + "errors": { 1209 + "connectionFailed": "无法连接到PDS", 1210 + "invalidCredentials": "凭据无效", 1211 + "twoFactorRequired": "需要双因素认证", 1212 + "accountExists": "目标PDS上已存在账户", 1213 + "plcFailed": "PLC操作失败", 1214 + "blobFailed": "blob迁移失败:{cid}", 1215 + "networkError": "网络错误,请重试。" 1216 + } 1217 } 1218 }
+4
frontend/src/routes/Dashboard.svelte
··· 190 <h3>{$_('dashboard.navDelegation')}</h3> 191 <p>{$_('dashboard.navDelegationDesc')}</p> 192 </a> 193 {#if auth.session.isAdmin} 194 <a href="#/admin" class="nav-card admin-card"> 195 <h3>{$_('dashboard.navAdmin')}</h3>
··· 190 <h3>{$_('dashboard.navDelegation')}</h3> 191 <p>{$_('dashboard.navDelegationDesc')}</p> 192 </a> 193 + <a href="#/migrate" class="nav-card"> 194 + <h3>{$_('migration.navTitle')}</h3> 195 + <p>{$_('migration.navDesc')}</p> 196 + </a> 197 {#if auth.session.isAdmin} 198 <a href="#/admin" class="nav-card admin-card"> 199 <h3>{$_('dashboard.navAdmin')}</h3>
+413
frontend/src/routes/Migration.svelte
···
··· 1 + <script lang="ts"> 2 + import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { 5 + createInboundMigrationFlow, 6 + createOutboundMigrationFlow, 7 + hasPendingMigration, 8 + getResumeInfo, 9 + clearMigrationState, 10 + loadMigrationState, 11 + } from '../lib/migration' 12 + import InboundWizard from '../components/migration/InboundWizard.svelte' 13 + import OutboundWizard from '../components/migration/OutboundWizard.svelte' 14 + 15 + const auth = getAuthState() 16 + 17 + type Direction = 'select' | 'inbound' | 'outbound' 18 + let direction = $state<Direction>('select') 19 + let showResumeModal = $state(false) 20 + let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) 21 + 22 + let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 23 + let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 24 + 25 + if (hasPendingMigration()) { 26 + resumeInfo = getResumeInfo() 27 + if (resumeInfo) { 28 + showResumeModal = true 29 + } 30 + } 31 + 32 + function selectInbound() { 33 + direction = 'inbound' 34 + inboundFlow = createInboundMigrationFlow() 35 + } 36 + 37 + function selectOutbound() { 38 + if (!auth.session) { 39 + navigate('/login') 40 + return 41 + } 42 + direction = 'outbound' 43 + outboundFlow = createOutboundMigrationFlow() 44 + outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 45 + } 46 + 47 + function handleResume() { 48 + const stored = loadMigrationState() 49 + if (!stored) return 50 + 51 + showResumeModal = false 52 + 53 + if (stored.direction === 'inbound') { 54 + direction = 'inbound' 55 + inboundFlow = createInboundMigrationFlow() 56 + inboundFlow.resumeFromState(stored) 57 + } else { 58 + if (!auth.session) { 59 + navigate('/login') 60 + return 61 + } 62 + direction = 'outbound' 63 + outboundFlow = createOutboundMigrationFlow() 64 + outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 65 + } 66 + } 67 + 68 + function handleStartOver() { 69 + showResumeModal = false 70 + clearMigrationState() 71 + resumeInfo = null 72 + } 73 + 74 + function handleBack() { 75 + if (inboundFlow) { 76 + inboundFlow.reset() 77 + inboundFlow = null 78 + } 79 + if (outboundFlow) { 80 + outboundFlow.reset() 81 + outboundFlow = null 82 + } 83 + direction = 'select' 84 + } 85 + 86 + function handleInboundComplete() { 87 + const session = inboundFlow?.getLocalSession() 88 + if (session) { 89 + setSession({ 90 + did: session.did, 91 + handle: session.handle, 92 + accessJwt: session.accessJwt, 93 + refreshJwt: '', 94 + }) 95 + } 96 + navigate('/dashboard') 97 + } 98 + 99 + async function handleOutboundComplete() { 100 + await logout() 101 + navigate('/login') 102 + } 103 + </script> 104 + 105 + <div class="migration-page"> 106 + {#if showResumeModal && resumeInfo} 107 + <div class="modal-overlay"> 108 + <div class="modal"> 109 + <h2>Resume Migration?</h2> 110 + <p>You have an incomplete migration in progress:</p> 111 + <div class="resume-details"> 112 + <div class="detail-row"> 113 + <span class="label">Direction:</span> 114 + <span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span> 115 + </div> 116 + {#if resumeInfo.sourceHandle} 117 + <div class="detail-row"> 118 + <span class="label">From:</span> 119 + <span class="value">{resumeInfo.sourceHandle}</span> 120 + </div> 121 + {/if} 122 + {#if resumeInfo.targetHandle} 123 + <div class="detail-row"> 124 + <span class="label">To:</span> 125 + <span class="value">{resumeInfo.targetHandle}</span> 126 + </div> 127 + {/if} 128 + <div class="detail-row"> 129 + <span class="label">Progress:</span> 130 + <span class="value">{resumeInfo.progressSummary}</span> 131 + </div> 132 + </div> 133 + <p class="note">You will need to re-enter your credentials to continue.</p> 134 + <div class="modal-actions"> 135 + <button class="ghost" onclick={handleStartOver}>Start Over</button> 136 + <button onclick={handleResume}>Resume</button> 137 + </div> 138 + </div> 139 + </div> 140 + {/if} 141 + 142 + {#if direction === 'select'} 143 + <header class="page-header"> 144 + <h1>Account Migration</h1> 145 + <p class="subtitle">Move your AT Protocol identity between servers</p> 146 + </header> 147 + 148 + <div class="direction-cards"> 149 + <button class="direction-card ghost" onclick={selectInbound}> 150 + <div class="card-icon">↓</div> 151 + <h2>Migrate Here</h2> 152 + <p>Move your existing AT Protocol account to this PDS from another server.</p> 153 + <ul class="features"> 154 + <li>Bring your DID and identity</li> 155 + <li>Transfer all your data</li> 156 + <li>Keep your followers</li> 157 + </ul> 158 + </button> 159 + 160 + <button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}> 161 + <div class="card-icon">↑</div> 162 + <h2>Migrate Away</h2> 163 + <p>Move your account from this PDS to another server.</p> 164 + <ul class="features"> 165 + <li>Export your repository</li> 166 + <li>Transfer to new PDS</li> 167 + <li>Update your identity</li> 168 + </ul> 169 + {#if !auth.session} 170 + <p class="login-required">Login required</p> 171 + {/if} 172 + </button> 173 + </div> 174 + 175 + <div class="info-section"> 176 + <h3>What is account migration?</h3> 177 + <p> 178 + Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). 179 + Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved. 180 + </p> 181 + 182 + <h3>Before you migrate</h3> 183 + <ul> 184 + <li>You will need your current account credentials</li> 185 + <li>Migration requires email verification for security</li> 186 + <li>Large accounts with many images may take several minutes</li> 187 + <li>Your old PDS will be notified to deactivate your account</li> 188 + </ul> 189 + 190 + <div class="warning-box"> 191 + <strong>Important:</strong> Account migration is a significant action. Make sure you trust the destination PDS 192 + and understand that your data will be moved. If something goes wrong, recovery may require manual intervention. 193 + <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener"> 194 + Learn more about migration risks 195 + </a> 196 + </div> 197 + </div> 198 + 199 + {:else if direction === 'inbound' && inboundFlow} 200 + <InboundWizard 201 + flow={inboundFlow} 202 + onBack={handleBack} 203 + onComplete={handleInboundComplete} 204 + /> 205 + 206 + {:else if direction === 'outbound' && outboundFlow} 207 + <OutboundWizard 208 + flow={outboundFlow} 209 + onBack={handleBack} 210 + onComplete={handleOutboundComplete} 211 + /> 212 + {/if} 213 + </div> 214 + 215 + <style> 216 + .migration-page { 217 + max-width: var(--width-lg); 218 + margin: var(--space-9) auto; 219 + padding: var(--space-7); 220 + } 221 + 222 + .page-header { 223 + text-align: center; 224 + margin-bottom: var(--space-8); 225 + } 226 + 227 + .page-header h1 { 228 + margin: 0 0 var(--space-3) 0; 229 + } 230 + 231 + .subtitle { 232 + color: var(--text-secondary); 233 + margin: 0; 234 + font-size: var(--text-lg); 235 + } 236 + 237 + .direction-cards { 238 + display: grid; 239 + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 240 + gap: var(--space-6); 241 + margin-bottom: var(--space-8); 242 + } 243 + 244 + .direction-card { 245 + background: var(--bg-secondary); 246 + border: 1px solid var(--border); 247 + border-radius: var(--radius-xl); 248 + padding: var(--space-6); 249 + text-align: left; 250 + cursor: pointer; 251 + transition: all 0.2s ease; 252 + } 253 + 254 + .direction-card:hover:not(:disabled) { 255 + border-color: var(--accent); 256 + transform: translateY(-2px); 257 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 258 + } 259 + 260 + .direction-card:disabled { 261 + opacity: 0.6; 262 + cursor: not-allowed; 263 + } 264 + 265 + .card-icon { 266 + font-size: var(--text-3xl); 267 + margin-bottom: var(--space-4); 268 + color: var(--accent); 269 + } 270 + 271 + .direction-card h2 { 272 + margin: 0 0 var(--space-3) 0; 273 + font-size: var(--text-xl); 274 + color: var(--text-primary); 275 + } 276 + 277 + .direction-card p { 278 + color: var(--text-secondary); 279 + margin: 0 0 var(--space-4) 0; 280 + font-size: var(--text-sm); 281 + } 282 + 283 + .features { 284 + margin: 0; 285 + padding-left: var(--space-5); 286 + color: var(--text-secondary); 287 + font-size: var(--text-sm); 288 + } 289 + 290 + .features li { 291 + margin-bottom: var(--space-2); 292 + } 293 + 294 + .login-required { 295 + color: var(--warning-text); 296 + font-weight: var(--font-medium); 297 + margin-top: var(--space-4); 298 + } 299 + 300 + .info-section { 301 + background: var(--bg-secondary); 302 + border-radius: var(--radius-xl); 303 + padding: var(--space-6); 304 + } 305 + 306 + .info-section h3 { 307 + margin: 0 0 var(--space-3) 0; 308 + font-size: var(--text-lg); 309 + } 310 + 311 + .info-section h3:not(:first-child) { 312 + margin-top: var(--space-6); 313 + } 314 + 315 + .info-section p { 316 + color: var(--text-secondary); 317 + line-height: var(--leading-relaxed); 318 + margin: 0; 319 + } 320 + 321 + .info-section ul { 322 + color: var(--text-secondary); 323 + padding-left: var(--space-5); 324 + margin: var(--space-3) 0 0 0; 325 + } 326 + 327 + .info-section li { 328 + margin-bottom: var(--space-2); 329 + } 330 + 331 + .warning-box { 332 + margin-top: var(--space-6); 333 + padding: var(--space-5); 334 + background: var(--warning-bg); 335 + border: 1px solid var(--warning-border); 336 + border-radius: var(--radius-lg); 337 + font-size: var(--text-sm); 338 + } 339 + 340 + .warning-box strong { 341 + color: var(--warning-text); 342 + } 343 + 344 + .warning-box a { 345 + display: block; 346 + margin-top: var(--space-3); 347 + color: var(--accent); 348 + } 349 + 350 + .modal-overlay { 351 + position: fixed; 352 + inset: 0; 353 + background: rgba(0, 0, 0, 0.5); 354 + display: flex; 355 + align-items: center; 356 + justify-content: center; 357 + z-index: 1000; 358 + } 359 + 360 + .modal { 361 + background: var(--bg-primary); 362 + border-radius: var(--radius-xl); 363 + padding: var(--space-6); 364 + max-width: 400px; 365 + width: 90%; 366 + } 367 + 368 + .modal h2 { 369 + margin: 0 0 var(--space-4) 0; 370 + } 371 + 372 + .modal p { 373 + color: var(--text-secondary); 374 + margin: 0 0 var(--space-4) 0; 375 + } 376 + 377 + .resume-details { 378 + background: var(--bg-secondary); 379 + border-radius: var(--radius-lg); 380 + padding: var(--space-4); 381 + margin-bottom: var(--space-4); 382 + } 383 + 384 + .detail-row { 385 + display: flex; 386 + justify-content: space-between; 387 + padding: var(--space-2) 0; 388 + font-size: var(--text-sm); 389 + } 390 + 391 + .detail-row:not(:last-child) { 392 + border-bottom: 1px solid var(--border); 393 + } 394 + 395 + .detail-row .label { 396 + color: var(--text-secondary); 397 + } 398 + 399 + .detail-row .value { 400 + font-weight: var(--font-medium); 401 + } 402 + 403 + .note { 404 + font-size: var(--text-sm); 405 + font-style: italic; 406 + } 407 + 408 + .modal-actions { 409 + display: flex; 410 + gap: var(--space-3); 411 + justify-content: flex-end; 412 + } 413 + </style>
+1 -1
frontend/src/routes/Register.svelte
··· 173 <div class="migrate-content"> 174 <strong>{$_('register.migrateTitle')}</strong> 175 <p>{$_('register.migrateDescription')}</p> 176 - <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 177 {$_('register.migrateLink')} → 178 </a> 179 </div>
··· 173 <div class="migrate-content"> 174 <strong>{$_('register.migrateTitle')}</strong> 175 <p>{$_('register.migrateDescription')}</p> 176 + <a href="#/migrate" class="migrate-link"> 177 {$_('register.migrateLink')} → 178 </a> 179 </div>
+1
src/api/identity/did.rs
··· 449 #[derive(serde::Serialize)] 450 #[serde(rename_all = "camelCase")] 451 pub struct Services { 452 pub atproto_pds: AtprotoPds, 453 } 454
··· 449 #[derive(serde::Serialize)] 450 #[serde(rename_all = "camelCase")] 451 pub struct Services { 452 + #[serde(rename = "atproto_pds")] 453 pub atproto_pds: AtprotoPds, 454 } 455
+1 -1
src/api/repo/blob.rs
··· 295 .into_response(); 296 } 297 }; 298 - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 299 Ok(user) => user, 300 Err(_) => { 301 return (
··· 295 .into_response(); 296 } 297 }; 298 + let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { 299 Ok(user) => user, 300 Err(_) => { 301 return (
+4 -2
src/api/repo/import.rs
··· 318 records.len(), 319 did 320 ); 321 - if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 322 - warn!("Failed to sequence import event: {:?}", e); 323 } 324 (StatusCode::OK, Json(json!({}))).into_response() 325 }
··· 318 records.len(), 319 did 320 ); 321 + if !is_migration { 322 + if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 323 + warn!("Failed to sequence import event: {:?}", e); 324 + } 325 } 326 (StatusCode::OK, Json(json!({}))).into_response() 327 }
+5 -1
src/api/repo/record/batch.rs
··· 1 use super::validation::validate_record; 2 use super::write::has_verified_comms_channel; 3 - use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::repo::tracking::TrackingBlockStore; 6 use crate::state::AppState; ··· 295 let mut results: Vec<WriteResult> = Vec::new(); 296 let mut ops: Vec<RecordOp> = Vec::new(); 297 let mut modified_keys: Vec<String> = Vec::new(); 298 for write in &input.writes { 299 match write { 300 WriteOp::Create { ··· 307 { 308 return *err_response; 309 } 310 let rkey = rkey 311 .clone() 312 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); ··· 359 { 360 return *err_response; 361 } 362 let mut record_bytes = Vec::new(); 363 if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 364 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); ··· 468 new_mst_root, 469 ops, 470 blocks_cids: &written_cids_str, 471 }, 472 ) 473 .await
··· 1 use super::validation::validate_record; 2 use super::write::has_verified_comms_channel; 3 + use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 use crate::delegation::{self, DelegationActionType}; 5 use crate::repo::tracking::TrackingBlockStore; 6 use crate::state::AppState; ··· 295 let mut results: Vec<WriteResult> = Vec::new(); 296 let mut ops: Vec<RecordOp> = Vec::new(); 297 let mut modified_keys: Vec<String> = Vec::new(); 298 + let mut all_blob_cids: Vec<String> = Vec::new(); 299 for write in &input.writes { 300 match write { 301 WriteOp::Create { ··· 308 { 309 return *err_response; 310 } 311 + all_blob_cids.extend(extract_blob_cids(value)); 312 let rkey = rkey 313 .clone() 314 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); ··· 361 { 362 return *err_response; 363 } 364 + all_blob_cids.extend(extract_blob_cids(value)); 365 let mut record_bytes = Vec::new(); 366 if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 367 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); ··· 471 new_mst_root, 472 ops, 473 blocks_cids: &written_cids_str, 474 + blobs: &all_blob_cids, 475 }, 476 ) 477 .await
+1
src/api/repo/record/delete.rs
··· 168 new_mst_root, 169 ops: vec![op], 170 blocks_cids: &written_cids_str, 171 }, 172 ) 173 .await
··· 168 new_mst_root, 169 ops: vec![op], 170 blocks_cids: &written_cids_str, 171 + blobs: &[], 172 }, 173 ) 174 .await
+38 -3
src/api/repo/record/read.rs
··· 6 http::{HeaderMap, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 use cid::Cid; 10 use jacquard_repo::storage::BlockStore; 11 use serde::{Deserialize, Serialize}; 12 - use serde_json::json; 13 use std::collections::HashMap; 14 use std::str::FromStr; 15 use tracing::{error, info}; 16 17 #[derive(Deserialize)] 18 pub struct GetRecordInput { 19 pub repo: String, ··· 163 .into_response(); 164 } 165 }; 166 - let value: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block) { 167 Ok(v) => v, 168 Err(e) => { 169 error!("Failed to deserialize record: {:?}", e); ··· 174 .into_response(); 175 } 176 }; 177 Json(json!({ 178 "uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey), 179 "cid": record_cid_str, ··· 323 for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) { 324 if let Some(block) = block_opt 325 && let Some((rkey, cid_str)) = cid_to_rkey.get(cid) 326 - && let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) 327 { 328 records.push(json!({ 329 "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), 330 "cid": cid_str,
··· 6 http::{HeaderMap, StatusCode}, 7 response::{IntoResponse, Response}, 8 }; 9 + use base64::Engine; 10 use cid::Cid; 11 + use ipld_core::ipld::Ipld; 12 use jacquard_repo::storage::BlockStore; 13 use serde::{Deserialize, Serialize}; 14 + use serde_json::{json, Map, Value}; 15 use std::collections::HashMap; 16 use std::str::FromStr; 17 use tracing::{error, info}; 18 19 + fn ipld_to_json(ipld: Ipld) -> Value { 20 + match ipld { 21 + Ipld::Null => Value::Null, 22 + Ipld::Bool(b) => Value::Bool(b), 23 + Ipld::Integer(i) => { 24 + if let Ok(n) = i64::try_from(i) { 25 + Value::Number(n.into()) 26 + } else { 27 + Value::String(i.to_string()) 28 + } 29 + } 30 + Ipld::Float(f) => serde_json::Number::from_f64(f) 31 + .map(Value::Number) 32 + .unwrap_or(Value::Null), 33 + Ipld::String(s) => Value::String(s), 34 + Ipld::Bytes(b) => { 35 + let encoded = base64::engine::general_purpose::STANDARD.encode(&b); 36 + json!({ "$bytes": encoded }) 37 + } 38 + Ipld::List(arr) => Value::Array(arr.into_iter().map(ipld_to_json).collect()), 39 + Ipld::Map(map) => { 40 + let obj: Map<String, Value> = map 41 + .into_iter() 42 + .map(|(k, v)| (k, ipld_to_json(v))) 43 + .collect(); 44 + Value::Object(obj) 45 + } 46 + Ipld::Link(cid) => json!({ "$link": cid.to_string() }), 47 + } 48 + } 49 + 50 #[derive(Deserialize)] 51 pub struct GetRecordInput { 52 pub repo: String, ··· 196 .into_response(); 197 } 198 }; 199 + let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) { 200 Ok(v) => v, 201 Err(e) => { 202 error!("Failed to deserialize record: {:?}", e); ··· 207 .into_response(); 208 } 209 }; 210 + let value = ipld_to_json(ipld); 211 Json(json!({ 212 "uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey), 213 "cid": record_cid_str, ··· 357 for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) { 358 if let Some(block) = block_opt 359 && let Some((rkey, cid_str)) = cid_to_rkey.get(cid) 360 + && let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) 361 { 362 + let value = ipld_to_json(ipld); 363 records.push(json!({ 364 "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey), 365 "cid": cid_str,
+35 -2
src/api/repo/record/utils.rs
··· 5 use jacquard_repo::commit::Commit; 6 use jacquard_repo::storage::BlockStore; 7 use k256::ecdsa::SigningKey; 8 - use serde_json::json; 9 use std::str::FromStr; 10 use uuid::Uuid; 11 12 pub fn create_signed_commit( 13 did: &str, 14 data: Cid, ··· 63 pub new_mst_root: Cid, 64 pub ops: Vec<RecordOp>, 65 pub blocks_cids: &'a [String], 66 } 67 68 pub async fn commit_and_log( ··· 77 new_mst_root, 78 ops, 79 blocks_cids, 80 } = params; 81 let key_row = sqlx::query!( 82 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", ··· 274 new_root_cid.to_string(), 275 prev_cid_str, 276 json!(ops_json), 277 - &[] as &[String], 278 blocks_cids, 279 prev_data_cid_str, 280 ) ··· 368 } 369 } 370 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 371 let result = commit_and_log( 372 state, 373 CommitParams { ··· 378 new_mst_root, 379 ops: vec![op], 380 blocks_cids: &written_cids_str, 381 }, 382 ) 383 .await?;
··· 5 use jacquard_repo::commit::Commit; 6 use jacquard_repo::storage::BlockStore; 7 use k256::ecdsa::SigningKey; 8 + use serde_json::{json, Value}; 9 use std::str::FromStr; 10 use uuid::Uuid; 11 12 + pub fn extract_blob_cids(record: &Value) -> Vec<String> { 13 + let mut blobs = Vec::new(); 14 + extract_blob_cids_recursive(record, &mut blobs); 15 + blobs 16 + } 17 + 18 + fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec<String>) { 19 + match value { 20 + Value::Object(map) => { 21 + if map.get("$type").and_then(|v| v.as_str()) == Some("blob") { 22 + if let Some(ref_obj) = map.get("ref") { 23 + if let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) { 24 + blobs.push(link.to_string()); 25 + } 26 + } 27 + } 28 + for v in map.values() { 29 + extract_blob_cids_recursive(v, blobs); 30 + } 31 + } 32 + Value::Array(arr) => { 33 + for v in arr { 34 + extract_blob_cids_recursive(v, blobs); 35 + } 36 + } 37 + _ => {} 38 + } 39 + } 40 + 41 pub fn create_signed_commit( 42 did: &str, 43 data: Cid, ··· 92 pub new_mst_root: Cid, 93 pub ops: Vec<RecordOp>, 94 pub blocks_cids: &'a [String], 95 + pub blobs: &'a [String], 96 } 97 98 pub async fn commit_and_log( ··· 107 new_mst_root, 108 ops, 109 blocks_cids, 110 + blobs, 111 } = params; 112 let key_row = sqlx::query!( 113 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", ··· 305 new_root_cid.to_string(), 306 prev_cid_str, 307 json!(ops_json), 308 + blobs, 309 blocks_cids, 310 prev_data_cid_str, 311 ) ··· 399 } 400 } 401 let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect(); 402 + let blob_cids = extract_blob_cids(record); 403 let result = commit_and_log( 404 state, 405 CommitParams { ··· 410 new_mst_root, 411 ops: vec![op], 412 blocks_cids: &written_cids_str, 413 + blobs: &blob_cids, 414 }, 415 ) 416 .await?;
+5 -1
src/api/repo/record/write.rs
··· 1 use super::validation::validate_record; 2 - use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 use crate::delegation::{self, DelegationActionType}; 4 use crate::repo::tracking::TrackingBlockStore; 5 use crate::state::AppState; ··· 334 .iter() 335 .map(|c| c.to_string()) 336 .collect::<Vec<_>>(); 337 if let Err(e) = commit_and_log( 338 &state, 339 CommitParams { ··· 344 new_mst_root, 345 ops: vec![op], 346 blocks_cids: &written_cids_str, 347 }, 348 ) 349 .await ··· 582 .map(|c| c.to_string()) 583 .collect::<Vec<_>>(); 584 let is_update = existing_cid.is_some(); 585 if let Err(e) = commit_and_log( 586 &state, 587 CommitParams { ··· 592 new_mst_root, 593 ops: vec![op], 594 blocks_cids: &written_cids_str, 595 }, 596 ) 597 .await
··· 1 use super::validation::validate_record; 2 + use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 3 use crate::delegation::{self, DelegationActionType}; 4 use crate::repo::tracking::TrackingBlockStore; 5 use crate::state::AppState; ··· 334 .iter() 335 .map(|c| c.to_string()) 336 .collect::<Vec<_>>(); 337 + let blob_cids = extract_blob_cids(&input.record); 338 if let Err(e) = commit_and_log( 339 &state, 340 CommitParams { ··· 345 new_mst_root, 346 ops: vec![op], 347 blocks_cids: &written_cids_str, 348 + blobs: &blob_cids, 349 }, 350 ) 351 .await ··· 584 .map(|c| c.to_string()) 585 .collect::<Vec<_>>(); 586 let is_update = existing_cid.is_some(); 587 + let blob_cids = extract_blob_cids(&input.record); 588 if let Err(e) = commit_and_log( 589 &state, 590 CommitParams { ··· 595 new_mst_root, 596 ops: vec![op], 597 blocks_cids: &written_cids_str, 598 + blobs: &blob_cids, 599 }, 600 ) 601 .await
+204 -7
src/api/server/account_status.rs
··· 1 use crate::api::ApiError; 2 use crate::state::AppState; 3 use axum::{ 4 Json, ··· 8 }; 9 use bcrypt::verify; 10 use chrono::{Duration, Utc}; 11 use serde::{Deserialize, Serialize}; 12 use serde_json::json; 13 use tracing::{error, info, warn}; ··· 118 .into_response() 119 } 120 121 pub async fn activate_account( 122 State(state): State<AppState>, 123 headers: axum::http::HeaderMap, ··· 158 } 159 160 let did = auth_user.did; 161 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 162 .fetch_optional(&state.db) 163 .await ··· 182 { 183 warn!("Failed to sequence identity event for activation: {}", e); 184 } 185 - if let Err(e) = 186 - crate::api::repo::record::sequence_empty_commit_event(&state, &did).await 187 - { 188 - warn!( 189 - "Failed to sequence empty commit event for activation: {}", 190 - e 191 - ); 192 } 193 (StatusCode::OK, Json(json!({}))).into_response() 194 }
··· 1 use crate::api::ApiError; 2 + use crate::plc::PlcClient; 3 use crate::state::AppState; 4 use axum::{ 5 Json, ··· 9 }; 10 use bcrypt::verify; 11 use chrono::{Duration, Utc}; 12 + use k256::ecdsa::SigningKey; 13 use serde::{Deserialize, Serialize}; 14 use serde_json::json; 15 use tracing::{error, info, warn}; ··· 120 .into_response() 121 } 122 123 + async fn assert_valid_did_document_for_service( 124 + db: &sqlx::PgPool, 125 + did: &str, 126 + ) -> Result<(), (StatusCode, Json<serde_json::Value>)> { 127 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 128 + let expected_endpoint = format!("https://{}", hostname); 129 + 130 + if did.starts_with("did:plc:") { 131 + let plc_client = PlcClient::new(None); 132 + 133 + let mut last_error = None; 134 + let mut doc_data = None; 135 + for attempt in 0..5 { 136 + if attempt > 0 { 137 + let delay_ms = 500 * (1 << (attempt - 1)); 138 + info!( 139 + "Waiting {}ms before retry {} for DID document validation ({})", 140 + delay_ms, attempt, did 141 + ); 142 + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; 143 + } 144 + 145 + match plc_client.get_document_data(did).await { 146 + Ok(data) => { 147 + let pds_endpoint = data 148 + .get("services") 149 + .and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds"))) 150 + .and_then(|p| p.get("endpoint")) 151 + .and_then(|e| e.as_str()); 152 + 153 + if pds_endpoint == Some(&expected_endpoint) { 154 + doc_data = Some(data); 155 + break; 156 + } else { 157 + info!( 158 + "Attempt {}: DID {} has endpoint {:?}, expected {} - retrying", 159 + attempt + 1, 160 + did, 161 + pds_endpoint, 162 + expected_endpoint 163 + ); 164 + last_error = Some(format!( 165 + "DID document endpoint {:?} does not match expected {}", 166 + pds_endpoint, expected_endpoint 167 + )); 168 + } 169 + } 170 + Err(e) => { 171 + warn!( 172 + "Attempt {}: Failed to fetch PLC document for {}: {:?}", 173 + attempt + 1, 174 + did, 175 + e 176 + ); 177 + last_error = Some(format!("Could not resolve DID document: {}", e)); 178 + } 179 + } 180 + } 181 + 182 + let doc_data = match doc_data { 183 + Some(d) => d, 184 + None => { 185 + return Err(( 186 + StatusCode::BAD_REQUEST, 187 + Json(json!({ 188 + "error": "InvalidRequest", 189 + "message": last_error.unwrap_or_else(|| "DID document validation failed".to_string()) 190 + })), 191 + )); 192 + } 193 + }; 194 + 195 + let doc_signing_key = doc_data 196 + .get("verificationMethods") 197 + .and_then(|v| v.get("atproto")) 198 + .and_then(|k| k.as_str()); 199 + 200 + let user_row = sqlx::query!( 201 + "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1", 202 + did 203 + ) 204 + .fetch_optional(db) 205 + .await 206 + .map_err(|e| { 207 + error!("Failed to fetch user key: {:?}", e); 208 + ( 209 + StatusCode::INTERNAL_SERVER_ERROR, 210 + Json(json!({"error": "InternalError"})), 211 + ) 212 + })?; 213 + 214 + if let Some(row) = user_row { 215 + let key_bytes = 216 + crate::config::decrypt_key(&row.key_bytes, row.encryption_version).map_err(|e| { 217 + error!("Failed to decrypt user key: {}", e); 218 + ( 219 + StatusCode::INTERNAL_SERVER_ERROR, 220 + Json(json!({"error": "InternalError"})), 221 + ) 222 + })?; 223 + let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| { 224 + error!("Failed to create signing key: {:?}", e); 225 + ( 226 + StatusCode::INTERNAL_SERVER_ERROR, 227 + Json(json!({"error": "InternalError"})), 228 + ) 229 + })?; 230 + let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key); 231 + 232 + if doc_signing_key != Some(&expected_did_key) { 233 + warn!( 234 + "DID {} has signing key {:?}, expected {}", 235 + did, doc_signing_key, expected_did_key 236 + ); 237 + return Err(( 238 + StatusCode::BAD_REQUEST, 239 + Json(json!({ 240 + "error": "InvalidRequest", 241 + "message": "DID document verification method does not match expected signing key" 242 + })), 243 + )); 244 + } 245 + } 246 + } else if did.starts_with("did:web:") { 247 + let client = reqwest::Client::new(); 248 + let did_path = &did[8..]; 249 + let url = format!("https://{}/.well-known/did.json", did_path.replace(':', "/")); 250 + let resp = client.get(&url).send().await.map_err(|e| { 251 + warn!("Failed to fetch did:web document for {}: {:?}", did, e); 252 + ( 253 + StatusCode::BAD_REQUEST, 254 + Json(json!({ 255 + "error": "InvalidRequest", 256 + "message": format!("Could not resolve DID document: {}", e) 257 + })), 258 + ) 259 + })?; 260 + let doc: serde_json::Value = resp.json().await.map_err(|e| { 261 + warn!("Failed to parse did:web document for {}: {:?}", did, e); 262 + ( 263 + StatusCode::BAD_REQUEST, 264 + Json(json!({ 265 + "error": "InvalidRequest", 266 + "message": format!("Could not parse DID document: {}", e) 267 + })), 268 + ) 269 + })?; 270 + 271 + let pds_endpoint = doc 272 + .get("service") 273 + .and_then(|s| s.as_array()) 274 + .and_then(|arr| { 275 + arr.iter().find(|svc| { 276 + svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") 277 + || svc.get("type").and_then(|t| t.as_str()) 278 + == Some("AtprotoPersonalDataServer") 279 + }) 280 + }) 281 + .and_then(|svc| svc.get("serviceEndpoint")) 282 + .and_then(|e| e.as_str()); 283 + 284 + if pds_endpoint != Some(&expected_endpoint) { 285 + warn!( 286 + "DID {} has endpoint {:?}, expected {}", 287 + did, pds_endpoint, expected_endpoint 288 + ); 289 + return Err(( 290 + StatusCode::BAD_REQUEST, 291 + Json(json!({ 292 + "error": "InvalidRequest", 293 + "message": "DID document atproto_pds service endpoint does not match PDS public url" 294 + })), 295 + )); 296 + } 297 + } 298 + 299 + Ok(()) 300 + } 301 + 302 pub async fn activate_account( 303 State(state): State<AppState>, 304 headers: axum::http::HeaderMap, ··· 339 } 340 341 let did = auth_user.did; 342 + 343 + if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did).await { 344 + info!( 345 + "activateAccount rejected for {}: DID document validation failed", 346 + did 347 + ); 348 + return (status, json).into_response(); 349 + } 350 + 351 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 352 .fetch_optional(&state.db) 353 .await ··· 372 { 373 warn!("Failed to sequence identity event for activation: {}", e); 374 } 375 + let repo_root = sqlx::query_scalar!( 376 + "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", 377 + did 378 + ) 379 + .fetch_optional(&state.db) 380 + .await 381 + .ok() 382 + .flatten(); 383 + if let Some(root_cid) = repo_root { 384 + if let Err(e) = 385 + crate::api::repo::record::sequence_sync_event(&state, &did, &root_cid).await 386 + { 387 + warn!("Failed to sequence sync event for activation: {}", e); 388 + } 389 } 390 (StatusCode::OK, Json(json!({}))).into_response() 391 }
+239
src/api/server/migration.rs
···
··· 1 + use crate::api::ApiError; 2 + use crate::state::AppState; 3 + use axum::{ 4 + Json, 5 + extract::State, 6 + http::StatusCode, 7 + response::{IntoResponse, Response}, 8 + }; 9 + use chrono::{DateTime, Utc}; 10 + use serde::{Deserialize, Serialize}; 11 + use serde_json::json; 12 + 13 + #[derive(Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct GetMigrationStatusOutput { 16 + pub did: String, 17 + pub did_type: String, 18 + pub migrated: bool, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub migrated_to_pds: Option<String>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub migrated_at: Option<DateTime<Utc>>, 23 + } 24 + 25 + pub async fn get_migration_status( 26 + State(state): State<AppState>, 27 + headers: axum::http::HeaderMap, 28 + ) -> Response { 29 + let extracted = match crate::auth::extract_auth_token_from_header( 30 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 31 + ) { 32 + Some(t) => t, 33 + None => return ApiError::AuthenticationRequired.into_response(), 34 + }; 35 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 36 + let http_uri = format!( 37 + "https://{}/xrpc/com.tranquil.account.getMigrationStatus", 38 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 39 + ); 40 + let auth_user = match crate::auth::validate_token_with_dpop( 41 + &state.db, 42 + &extracted.token, 43 + extracted.is_dpop, 44 + dpop_proof, 45 + "GET", 46 + &http_uri, 47 + true, 48 + ) 49 + .await 50 + { 51 + Ok(user) => user, 52 + Err(e) => return ApiError::from(e).into_response(), 53 + }; 54 + let user = match sqlx::query!( 55 + "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 56 + auth_user.did 57 + ) 58 + .fetch_optional(&state.db) 59 + .await 60 + { 61 + Ok(Some(row)) => row, 62 + Ok(None) => return ApiError::AccountNotFound.into_response(), 63 + Err(e) => { 64 + tracing::error!("DB error getting migration status: {:?}", e); 65 + return ApiError::InternalError.into_response(); 66 + } 67 + }; 68 + let did_type = if user.did.starts_with("did:plc:") { 69 + "plc" 70 + } else if user.did.starts_with("did:web:") { 71 + "web" 72 + } else { 73 + "unknown" 74 + }; 75 + let migrated = user.migrated_to_pds.is_some(); 76 + ( 77 + StatusCode::OK, 78 + Json(GetMigrationStatusOutput { 79 + did: user.did, 80 + did_type: did_type.to_string(), 81 + migrated, 82 + migrated_to_pds: user.migrated_to_pds, 83 + migrated_at: user.migrated_at, 84 + }), 85 + ) 86 + .into_response() 87 + } 88 + 89 + #[derive(Deserialize)] 90 + #[serde(rename_all = "camelCase")] 91 + pub struct UpdateMigrationForwardingInput { 92 + pub pds_url: String, 93 + } 94 + 95 + #[derive(Serialize)] 96 + #[serde(rename_all = "camelCase")] 97 + pub struct UpdateMigrationForwardingOutput { 98 + pub success: bool, 99 + pub migrated_to_pds: String, 100 + pub migrated_at: DateTime<Utc>, 101 + } 102 + 103 + pub async fn update_migration_forwarding( 104 + State(state): State<AppState>, 105 + headers: axum::http::HeaderMap, 106 + Json(input): Json<UpdateMigrationForwardingInput>, 107 + ) -> Response { 108 + let extracted = match crate::auth::extract_auth_token_from_header( 109 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 110 + ) { 111 + Some(t) => t, 112 + None => return ApiError::AuthenticationRequired.into_response(), 113 + }; 114 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 115 + let http_uri = format!( 116 + "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", 117 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 118 + ); 119 + let auth_user = match crate::auth::validate_token_with_dpop( 120 + &state.db, 121 + &extracted.token, 122 + extracted.is_dpop, 123 + dpop_proof, 124 + "POST", 125 + &http_uri, 126 + true, 127 + ) 128 + .await 129 + { 130 + Ok(user) => user, 131 + Err(e) => return ApiError::from(e).into_response(), 132 + }; 133 + if !auth_user.did.starts_with("did:web:") { 134 + return ( 135 + StatusCode::BAD_REQUEST, 136 + Json(json!({ 137 + "error": "InvalidRequest", 138 + "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." 139 + })), 140 + ) 141 + .into_response(); 142 + } 143 + let pds_url = input.pds_url.trim(); 144 + if pds_url.is_empty() { 145 + return ApiError::InvalidRequest("pds_url is required".into()).into_response(); 146 + } 147 + if !pds_url.starts_with("https://") { 148 + return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); 149 + } 150 + let pds_url_clean = pds_url.trim_end_matches('/'); 151 + let now = Utc::now(); 152 + let result = sqlx::query!( 153 + "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 154 + pds_url_clean, 155 + now, 156 + auth_user.did 157 + ) 158 + .execute(&state.db) 159 + .await; 160 + match result { 161 + Ok(_) => { 162 + tracing::info!( 163 + "Updated migration forwarding for {} to {}", 164 + auth_user.did, 165 + pds_url_clean 166 + ); 167 + ( 168 + StatusCode::OK, 169 + Json(UpdateMigrationForwardingOutput { 170 + success: true, 171 + migrated_to_pds: pds_url_clean.to_string(), 172 + migrated_at: now, 173 + }), 174 + ) 175 + .into_response() 176 + } 177 + Err(e) => { 178 + tracing::error!("DB error updating migration forwarding: {:?}", e); 179 + ApiError::InternalError.into_response() 180 + } 181 + } 182 + } 183 + 184 + pub async fn clear_migration_forwarding( 185 + State(state): State<AppState>, 186 + headers: axum::http::HeaderMap, 187 + ) -> Response { 188 + let extracted = match crate::auth::extract_auth_token_from_header( 189 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 190 + ) { 191 + Some(t) => t, 192 + None => return ApiError::AuthenticationRequired.into_response(), 193 + }; 194 + let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 195 + let http_uri = format!( 196 + "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", 197 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 198 + ); 199 + let auth_user = match crate::auth::validate_token_with_dpop( 200 + &state.db, 201 + &extracted.token, 202 + extracted.is_dpop, 203 + dpop_proof, 204 + "POST", 205 + &http_uri, 206 + true, 207 + ) 208 + .await 209 + { 210 + Ok(user) => user, 211 + Err(e) => return ApiError::from(e).into_response(), 212 + }; 213 + if !auth_user.did.starts_with("did:web:") { 214 + return ( 215 + StatusCode::BAD_REQUEST, 216 + Json(json!({ 217 + "error": "InvalidRequest", 218 + "message": "Migration forwarding is only available for did:web accounts" 219 + })), 220 + ) 221 + .into_response(); 222 + } 223 + let result = sqlx::query!( 224 + "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 225 + auth_user.did 226 + ) 227 + .execute(&state.db) 228 + .await; 229 + match result { 230 + Ok(_) => { 231 + tracing::info!("Cleared migration forwarding for {}", auth_user.did); 232 + (StatusCode::OK, Json(json!({ "success": true }))).into_response() 233 + } 234 + Err(e) => { 235 + tracing::error!("DB error clearing migration forwarding: {:?}", e); 236 + ApiError::InternalError.into_response() 237 + } 238 + } 239 + }
+4
src/api/server/mod.rs
··· 4 pub mod invite; 5 pub mod logo; 6 pub mod meta; 7 pub mod passkey_account; 8 pub mod passkeys; 9 pub mod password; ··· 55 pub use trusted_devices::{ 56 extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, 57 trust_device, update_trusted_device, 58 }; 59 pub use verify_email::{resend_migration_verification, verify_migration_email}; 60 pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
··· 4 pub mod invite; 5 pub mod logo; 6 pub mod meta; 7 + pub mod migration; 8 pub mod passkey_account; 9 pub mod passkeys; 10 pub mod password; ··· 56 pub use trusted_devices::{ 57 extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, 58 trust_device, update_trusted_device, 59 + }; 60 + pub use migration::{ 61 + clear_migration_forwarding, get_migration_status, update_migration_forwarding, 62 }; 63 pub use verify_email::{resend_migration_verification, verify_migration_email}; 64 pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
+12
src/lib.rs
··· 281 post(api::server::recover_passkey_account), 282 ) 283 .route( 284 "/xrpc/com.atproto.server.requestEmailUpdate", 285 post(api::server::request_email_update), 286 )
··· 281 post(api::server::recover_passkey_account), 282 ) 283 .route( 284 + "/xrpc/com.tranquil.account.getMigrationStatus", 285 + get(api::server::get_migration_status), 286 + ) 287 + .route( 288 + "/xrpc/com.tranquil.account.updateMigrationForwarding", 289 + post(api::server::update_migration_forwarding), 290 + ) 291 + .route( 292 + "/xrpc/com.tranquil.account.clearMigrationForwarding", 293 + post(api::server::clear_migration_forwarding), 294 + ) 295 + .route( 296 "/xrpc/com.atproto.server.requestEmailUpdate", 297 post(api::server::request_email_update), 298 )
+77 -54
src/sync/import.rs
··· 163 root_cid: &Cid, 164 ) -> Result<Vec<ImportedRecord>, ImportError> { 165 let mut records = Vec::new(); 166 - let mut stack = vec![*root_cid]; 167 - let mut visited = std::collections::HashSet::new(); 168 - while let Some(cid) = stack.pop() { 169 - if visited.contains(&cid) { 170 - continue; 171 } 172 - visited.insert(cid); 173 - let block = blocks 174 - .get(&cid) 175 - .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?; 176 - let value: Ipld = serde_ipld_dagcbor::from_slice(block) 177 - .map_err(|e| ImportError::InvalidCbor(e.to_string()))?; 178 - if let Ipld::Map(ref obj) = value { 179 - if let Some(Ipld::List(entries)) = obj.get("e") { 180 - for entry in entries { 181 - if let Ipld::Map(entry_obj) = entry { 182 - let key = entry_obj.get("k").and_then(|k| { 183 - if let Ipld::Bytes(b) = k { 184 - String::from_utf8(b.clone()).ok() 185 - } else if let Ipld::String(s) = k { 186 - Some(s.clone()) 187 - } else { 188 - None 189 } 190 - }); 191 - let record_cid = entry_obj.get("v").and_then(|v| { 192 - if let Ipld::Link(cid) = v { 193 - Some(*cid) 194 - } else { 195 - None 196 - } 197 - }); 198 - if let (Some(key), Some(record_cid)) = (key, record_cid) 199 - && let Some(record_block) = blocks.get(&record_cid) 200 - && let Ok(record_value) = 201 - serde_ipld_dagcbor::from_slice::<Ipld>(record_block) 202 - { 203 - let blob_refs = find_blob_refs_ipld(&record_value, 0); 204 - let parts: Vec<&str> = key.split('/').collect(); 205 - if parts.len() >= 2 { 206 - let collection = parts[..parts.len() - 1].join("/"); 207 - let rkey = parts[parts.len() - 1].to_string(); 208 - records.push(ImportedRecord { 209 - collection, 210 - rkey, 211 - cid: record_cid, 212 - blob_refs, 213 - }); 214 - } 215 - } 216 - if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 217 - stack.push(*tree_cid); 218 } 219 } 220 } 221 } 222 - if let Some(Ipld::Link(left_cid)) = obj.get("l") { 223 - stack.push(*left_cid); 224 - } 225 } 226 } 227 - Ok(records) 228 } 229 230 pub struct CommitInfo {
··· 163 root_cid: &Cid, 164 ) -> Result<Vec<ImportedRecord>, ImportError> { 165 let mut records = Vec::new(); 166 + walk_mst_node(blocks, root_cid, &[], &mut records)?; 167 + Ok(records) 168 + } 169 + 170 + fn walk_mst_node( 171 + blocks: &HashMap<Cid, Bytes>, 172 + cid: &Cid, 173 + prev_key: &[u8], 174 + records: &mut Vec<ImportedRecord>, 175 + ) -> Result<(), ImportError> { 176 + let block = blocks 177 + .get(cid) 178 + .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?; 179 + let value: Ipld = serde_ipld_dagcbor::from_slice(block) 180 + .map_err(|e| ImportError::InvalidCbor(e.to_string()))?; 181 + 182 + if let Ipld::Map(ref obj) = value { 183 + if let Some(Ipld::Link(left_cid)) = obj.get("l") { 184 + walk_mst_node(blocks, left_cid, prev_key, records)?; 185 } 186 + 187 + let mut current_key = prev_key.to_vec(); 188 + 189 + if let Some(Ipld::List(entries)) = obj.get("e") { 190 + for entry in entries { 191 + if let Ipld::Map(entry_obj) = entry { 192 + let prefix_len = entry_obj.get("p").and_then(|p| { 193 + if let Ipld::Integer(n) = p { 194 + Some(*n as usize) 195 + } else { 196 + None 197 + } 198 + }).unwrap_or(0); 199 + 200 + let key_suffix = entry_obj.get("k").and_then(|k| { 201 + if let Ipld::Bytes(b) = k { 202 + Some(b.clone()) 203 + } else { 204 + None 205 + } 206 + }); 207 + 208 + if let Some(suffix) = key_suffix { 209 + current_key.truncate(prefix_len); 210 + current_key.extend_from_slice(&suffix); 211 + } 212 + 213 + if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") { 214 + walk_mst_node(blocks, tree_cid, &current_key, records)?; 215 + } 216 + 217 + let record_cid = entry_obj.get("v").and_then(|v| { 218 + if let Ipld::Link(cid) = v { 219 + Some(*cid) 220 + } else { 221 + None 222 + } 223 + }); 224 + 225 + if let Some(record_cid) = record_cid { 226 + if let Ok(full_key) = String::from_utf8(current_key.clone()) { 227 + if let Some(record_block) = blocks.get(&record_cid) 228 + && let Ok(record_value) = 229 + serde_ipld_dagcbor::from_slice::<Ipld>(record_block) 230 + { 231 + let blob_refs = find_blob_refs_ipld(&record_value, 0); 232 + let parts: Vec<&str> = full_key.split('/').collect(); 233 + if parts.len() >= 2 { 234 + let collection = parts[..parts.len() - 1].join("/"); 235 + let rkey = parts[parts.len() - 1].to_string(); 236 + records.push(ImportedRecord { 237 + collection, 238 + rkey, 239 + cid: record_cid, 240 + blob_refs, 241 + }); 242 + } 243 } 244 } 245 } 246 } 247 } 248 } 249 } 250 + Ok(()) 251 } 252 253 pub struct CommitInfo {
+9
src/sync/util.rs
··· 86 let mut bytes = Vec::new(); 87 serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; 88 serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; 89 Ok(bytes) 90 } 91
··· 86 let mut bytes = Vec::new(); 87 serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; 88 serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?; 89 + let hex_str: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); 90 + tracing::info!( 91 + did = %frame.did, 92 + active = frame.active, 93 + status = ?frame.status, 94 + cbor_len = bytes.len(), 95 + cbor_hex = %hex_str, 96 + "Sending account event to firehose" 97 + ); 98 Ok(bytes) 99 } 100