+9
-1
frontend/deno.json
+9
-1
frontend/deno.json
+8
frontend/src/App.svelte
+8
frontend/src/App.svelte
···
36
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
import Home from './routes/Home.svelte'
38
39
initI18n()
40
41
const auth = getAuthState()
···
43
let oauthCallbackPending = $state(hasOAuthCallback())
44
45
function hasOAuthCallback(): boolean {
46
const params = new URLSearchParams(window.location.search)
47
return !!(params.get('code') && params.get('state'))
48
}
···
36
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
import Home from './routes/Home.svelte'
38
39
+
if (window.location.pathname === '/migrate') {
40
+
const newUrl = `${window.location.origin}/${window.location.search}#/migrate`
41
+
window.location.replace(newUrl)
42
+
}
43
+
44
initI18n()
45
46
const auth = getAuthState()
···
48
let oauthCallbackPending = $state(hasOAuthCallback())
49
50
function hasOAuthCallback(): boolean {
51
+
if (window.location.hash === '#/migrate') {
52
+
return false
53
+
}
54
const params = new URLSearchParams(window.location.search)
55
return !!(params.get('code') && params.get('state'))
56
}
+12
-12
frontend/src/components/ReauthModal.svelte
+12
-12
frontend/src/components/ReauthModal.svelte
···
170
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
171
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
172
<div class="modal-header">
173
-
<h2>Re-authentication Required</h2>
174
<button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
175
</div>
176
177
<p class="modal-description">
178
-
This action requires you to verify your identity.
179
</p>
180
181
{#if error}
···
190
class:active={activeMethod === 'password'}
191
onclick={() => activeMethod = 'password'}
192
>
193
-
Password
194
</button>
195
{/if}
196
{#if availableMethods.includes('totp')}
···
199
class:active={activeMethod === 'totp'}
200
onclick={() => activeMethod = 'totp'}
201
>
202
-
TOTP
203
</button>
204
{/if}
205
{#if availableMethods.includes('passkey')}
···
208
class:active={activeMethod === 'passkey'}
209
onclick={() => activeMethod = 'passkey'}
210
>
211
-
Passkey
212
</button>
213
{/if}
214
</div>
···
218
{#if activeMethod === 'password'}
219
<form onsubmit={handlePasswordSubmit}>
220
<div class="form-group">
221
-
<label for="reauth-password">Password</label>
222
<input
223
id="reauth-password"
224
type="password"
···
228
/>
229
</div>
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
-
{loading ? 'Verifying...' : 'Verify'}
232
</button>
233
</form>
234
{:else if activeMethod === 'totp'}
235
<form onsubmit={handleTotpSubmit}>
236
<div class="form-group">
237
-
<label for="reauth-totp">Authenticator Code</label>
238
<input
239
id="reauth-totp"
240
type="text"
···
247
/>
248
</div>
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
-
{loading ? 'Verifying...' : 'Verify'}
251
</button>
252
</form>
253
{:else if activeMethod === 'passkey'}
254
<div class="passkey-auth">
255
-
<p>Click the button below to authenticate with your passkey.</p>
256
<button
257
class="btn-primary"
258
onclick={handlePasskeyAuth}
259
disabled={loading}
260
>
261
-
{loading ? 'Authenticating...' : 'Use Passkey'}
262
</button>
263
</div>
264
{/if}
···
266
267
<div class="modal-footer">
268
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
269
-
Cancel
270
</button>
271
</div>
272
</div>
···
170
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
171
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
172
<div class="modal-header">
173
+
<h2>{$_('reauth.title')}</h2>
174
<button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
175
</div>
176
177
<p class="modal-description">
178
+
{$_('reauth.subtitle')}
179
</p>
180
181
{#if error}
···
190
class:active={activeMethod === 'password'}
191
onclick={() => activeMethod = 'password'}
192
>
193
+
{$_('reauth.password')}
194
</button>
195
{/if}
196
{#if availableMethods.includes('totp')}
···
199
class:active={activeMethod === 'totp'}
200
onclick={() => activeMethod = 'totp'}
201
>
202
+
{$_('reauth.totp')}
203
</button>
204
{/if}
205
{#if availableMethods.includes('passkey')}
···
208
class:active={activeMethod === 'passkey'}
209
onclick={() => activeMethod = 'passkey'}
210
>
211
+
{$_('reauth.passkey')}
212
</button>
213
{/if}
214
</div>
···
218
{#if activeMethod === 'password'}
219
<form onsubmit={handlePasswordSubmit}>
220
<div class="form-group">
221
+
<label for="reauth-password">{$_('reauth.password')}</label>
222
<input
223
id="reauth-password"
224
type="password"
···
228
/>
229
</div>
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
+
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
232
</button>
233
</form>
234
{:else if activeMethod === 'totp'}
235
<form onsubmit={handleTotpSubmit}>
236
<div class="form-group">
237
+
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
238
<input
239
id="reauth-totp"
240
type="text"
···
247
/>
248
</div>
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
+
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
251
</button>
252
</form>
253
{:else if activeMethod === 'passkey'}
254
<div class="passkey-auth">
255
+
<p>{$_('reauth.passkeyPrompt')}</p>
256
<button
257
class="btn-primary"
258
onclick={handlePasskeyAuth}
259
disabled={loading}
260
>
261
+
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
262
</button>
263
</div>
264
{/if}
···
266
267
<div class="modal-footer">
268
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
269
+
{$_('reauth.cancel')}
270
</button>
271
</div>
272
</div>
+394
-569
frontend/src/components/migration/InboundWizard.svelte
+394
-569
frontend/src/components/migration/InboundWizard.svelte
···
1
<script lang="ts">
2
import type { InboundMigrationFlow } from '../../lib/migration'
3
-
import type { ServerDescription } from '../../lib/migration/types'
4
import { _ } from '../../lib/i18n'
5
6
interface Props {
7
flow: InboundMigrationFlow
8
onBack: () => void
9
onComplete: () => void
10
}
11
12
-
let { flow, onBack, onComplete }: Props = $props()
13
14
let serverInfo = $state<ServerDescription | null>(null)
15
let loading = $state(false)
16
let handleInput = $state('')
17
-
let passwordInput = $state('')
18
let localPasswordInput = $state('')
19
let understood = $state(false)
20
let selectedDomain = $state('')
21
let handleAvailable = $state<boolean | null>(null)
22
let checkingHandle = $state(false)
23
24
-
const isResumedMigration = $derived(flow.state.progress.repoImported)
25
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
26
27
$effect(() => {
28
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
29
loadServerInfo()
30
}
31
})
32
33
···
61
}
62
}
63
64
-
async function handleLogin(e: Event) {
65
-
e.preventDefault()
66
-
loading = true
67
-
flow.updateField('error', null)
68
-
69
-
try {
70
-
await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined)
71
-
const username = flow.state.sourceHandle.split('.')[0]
72
-
handleInput = username
73
-
flow.updateField('targetPassword', passwordInput)
74
-
75
-
if (flow.state.progress.repoImported) {
76
-
if (!localPasswordInput) {
77
-
flow.setError('Please enter your password for your new account on this PDS')
78
-
return
79
-
}
80
-
await flow.loadLocalServerInfo()
81
-
82
-
try {
83
-
await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput)
84
-
await flow.requestPlcToken()
85
-
flow.setStep('plc-token')
86
-
} catch (err) {
87
-
const error = err as Error & { error?: string }
88
-
if (error.error === 'AccountNotVerified') {
89
-
flow.setStep('email-verify')
90
-
} else {
91
-
throw err
92
-
}
93
-
}
94
-
} else {
95
-
flow.setStep('choose-handle')
96
-
}
97
-
} catch (err) {
98
-
flow.setError((err as Error).message)
99
-
} finally {
100
-
loading = false
101
-
}
102
-
}
103
-
104
async function checkHandle() {
105
if (!handleInput.trim()) return
106
···
134
try {
135
await flow.startMigration()
136
} catch (err) {
137
-
flow.setError((err as Error).message)
138
} finally {
139
loading = false
140
}
···
146
try {
147
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined)
148
} catch (err) {
149
-
flow.setError((err as Error).message)
150
} finally {
151
loading = false
152
}
···
158
await flow.resendEmailVerification()
159
flow.setError(null)
160
} catch (err) {
161
-
flow.setError((err as Error).message)
162
} finally {
163
loading = false
164
}
···
170
try {
171
await flow.submitPlcToken(flow.state.plcToken)
172
} catch (err) {
173
-
flow.setError((err as Error).message)
174
} finally {
175
loading = false
176
}
···
182
await flow.resendPlcToken()
183
flow.setError(null)
184
} catch (err) {
185
-
flow.setError((err as Error).message)
186
} finally {
187
loading = false
188
}
···
193
try {
194
await flow.completeDidWebMigration()
195
} catch (err) {
196
-
flow.setError((err as Error).message)
197
} finally {
198
loading = false
199
}
200
}
201
202
const steps = $derived(isDidWeb
203
-
? ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete']
204
-
: ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'])
205
function getCurrentStepIndex(): number {
206
switch (flow.state.step) {
207
case 'welcome':
208
-
case 'source-login': return 0
209
case 'choose-handle': return 1
210
case 'review': return 2
211
case 'migrating': return 3
212
case 'email-verify': return 4
213
case 'plc-token':
214
case 'did-web-update':
215
-
case 'finalizing': return 5
216
-
case 'success': return 6
217
default: return 0
218
}
219
}
220
</script>
221
222
-
<div class="inbound-wizard">
223
<div class="step-indicator">
224
-
{#each steps as stepName, i}
225
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
226
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
227
-
<span class="step-label">{stepName}</span>
228
</div>
229
{#if i < steps.length - 1}
230
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
231
{/if}
232
{/each}
233
</div>
234
235
{#if flow.state.error}
236
<div class="message error">{flow.state.error}</div>
···
238
239
{#if flow.state.step === 'welcome'}
240
<div class="step-content">
241
-
<h2>Migrate Your Account Here</h2>
242
-
<p>This wizard will help you move your AT Protocol account from another PDS to this one.</p>
243
244
<div class="info-box">
245
-
<h3>What will happen:</h3>
246
<ol>
247
-
<li>Log in to your current PDS</li>
248
-
<li>Choose your new handle on this server</li>
249
-
<li>Your repository and blobs will be transferred</li>
250
-
<li>Verify the migration via email</li>
251
-
<li>Your identity will be updated to point here</li>
252
</ol>
253
</div>
254
255
<div class="warning-box">
256
-
<strong>Before you proceed:</strong>
257
<ul>
258
-
<li>You need access to the email registered with your current account</li>
259
-
<li>Large accounts may take several minutes to transfer</li>
260
-
<li>Your old account will be deactivated after migration</li>
261
</ul>
262
</div>
263
264
<label class="checkbox-label">
265
<input type="checkbox" bind:checked={understood} />
266
-
<span>I understand the risks and want to proceed with migration</span>
267
</label>
268
269
<div class="button-row">
270
-
<button class="ghost" onclick={onBack}>Cancel</button>
271
-
<button disabled={!understood} onclick={() => flow.setStep('source-login')}>
272
-
Continue
273
</button>
274
</div>
275
</div>
276
277
-
{:else if flow.state.step === 'source-login'}
278
<div class="step-content">
279
-
<h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2>
280
-
<p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p>
281
282
-
{#if isResumedMigration}
283
-
<div class="info-box">
284
-
<p>Your migration was interrupted. Log in to both accounts to resume.</p>
285
-
<p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong> → <strong>{flow.state.targetHandle}</strong></p>
286
</div>
287
{/if}
288
289
-
<form onsubmit={handleLogin}>
290
<div class="field">
291
-
<label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label>
292
<input
293
-
id="handle"
294
type="text"
295
-
placeholder="alice.bsky.social"
296
bind:value={handleInput}
297
-
disabled={loading}
298
required
299
/>
300
-
<p class="hint">Your current handle on your existing PDS</p>
301
</div>
302
303
-
<div class="field">
304
-
<label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label>
305
-
<input
306
-
id="password"
307
-
type="password"
308
-
bind:value={passwordInput}
309
-
disabled={loading}
310
-
required
311
-
/>
312
-
<p class="hint">Your account password (not an app password)</p>
313
-
</div>
314
-
315
-
{#if flow.state.requires2FA}
316
-
<div class="field">
317
-
<label for="2fa">Two-Factor Code</label>
318
-
<input
319
-
id="2fa"
320
-
type="text"
321
-
placeholder="Enter code from email"
322
-
bind:value={flow.state.twoFactorCode}
323
-
disabled={loading}
324
-
required
325
-
/>
326
-
<p class="hint">Check your email for the verification code</p>
327
-
</div>
328
-
{/if}
329
-
330
-
{#if isResumedMigration}
331
-
<hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" />
332
-
333
-
<div class="field">
334
-
<label for="local-password">New Account Password</label>
335
-
<input
336
-
id="local-password"
337
-
type="password"
338
-
placeholder="Password for your new account"
339
-
bind:value={localPasswordInput}
340
-
disabled={loading}
341
-
required
342
-
/>
343
-
<p class="hint">The password you set for your account on this PDS</p>
344
-
</div>
345
-
{/if}
346
-
347
<div class="button-row">
348
-
<button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button>
349
-
<button type="submit" disabled={loading}>
350
-
{loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')}
351
</button>
352
</div>
353
</form>
···
355
356
{:else if flow.state.step === 'choose-handle'}
357
<div class="step-content">
358
-
<h2>Choose Your New Handle</h2>
359
-
<p>Select a handle for your account on this PDS.</p>
360
361
<div class="current-info">
362
-
<span class="label">Migrating from:</span>
363
<span class="value">{flow.state.sourceHandle}</span>
364
</div>
365
366
<div class="field">
367
-
<label for="new-handle">New Handle</label>
368
<div class="handle-input-group">
369
<input
370
id="new-handle"
···
383
</div>
384
385
{#if checkingHandle}
386
-
<p class="hint">Checking availability...</p>
387
{:else if handleAvailable === true}
388
-
<p class="hint success">Handle is available!</p>
389
{:else if handleAvailable === false}
390
-
<p class="hint error">Handle is already taken</p>
391
{:else}
392
-
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
393
{/if}
394
</div>
395
396
<div class="field">
397
-
<label for="email">Email Address</label>
398
<input
399
id="email"
400
type="email"
···
406
</div>
407
408
<div class="field">
409
-
<label for="new-password">Password</label>
410
-
<input
411
-
id="new-password"
412
-
type="password"
413
-
placeholder="Password for your new account"
414
-
bind:value={flow.state.targetPassword}
415
-
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
416
-
required
417
-
minlength="8"
418
-
/>
419
-
<p class="hint">At least 8 characters</p>
420
</div>
421
422
{#if serverInfo?.inviteCodeRequired}
423
<div class="field">
424
-
<label for="invite">Invite Code</label>
425
<input
426
id="invite"
427
type="text"
···
434
{/if}
435
436
<div class="button-row">
437
-
<button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button>
438
<button
439
-
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false}
440
-
onclick={proceedToReview}
441
>
442
-
Continue
443
</button>
444
</div>
445
</div>
446
447
{:else if flow.state.step === 'review'}
448
<div class="step-content">
449
-
<h2>Review Migration</h2>
450
-
<p>Please confirm the details of your migration.</p>
451
452
<div class="review-card">
453
<div class="review-row">
454
-
<span class="label">Current Handle:</span>
455
<span class="value">{flow.state.sourceHandle}</span>
456
</div>
457
<div class="review-row">
458
-
<span class="label">New Handle:</span>
459
<span class="value">{flow.state.targetHandle}</span>
460
</div>
461
<div class="review-row">
462
-
<span class="label">DID:</span>
463
<span class="value mono">{flow.state.sourceDid}</span>
464
</div>
465
<div class="review-row">
466
-
<span class="label">From PDS:</span>
467
<span class="value">{flow.state.sourcePdsUrl}</span>
468
</div>
469
<div class="review-row">
470
-
<span class="label">To PDS:</span>
471
<span class="value">{window.location.origin}</span>
472
</div>
473
<div class="review-row">
474
-
<span class="label">Email:</span>
475
<span class="value">{flow.state.targetEmail}</span>
476
</div>
477
</div>
478
479
<div class="warning-box">
480
-
<strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin
481
-
transferring. This process cannot be easily undone.
482
</div>
483
484
<div class="button-row">
485
-
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button>
486
<button onclick={startMigration} disabled={loading}>
487
-
{loading ? 'Starting...' : 'Start Migration'}
488
</button>
489
</div>
490
</div>
491
492
{:else if flow.state.step === 'migrating'}
493
<div class="step-content">
494
-
<h2>Migration in Progress</h2>
495
-
<p>Please wait while your account is being transferred...</p>
496
497
<div class="progress-section">
498
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
499
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
500
-
<span>Export repository</span>
501
</div>
502
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
503
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
504
-
<span>Import repository</span>
505
</div>
506
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
507
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
508
-
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
509
</div>
510
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
511
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
512
-
<span>Migrate preferences</span>
513
</div>
514
</div>
515
···
525
<p class="status-text">{flow.state.progress.currentOperation}</p>
526
</div>
527
528
{:else if flow.state.step === 'email-verify'}
529
<div class="step-content">
530
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
···
537
</div>
538
539
{#if flow.state.error}
540
-
<div class="error-box">
541
{flow.state.error}
542
</div>
543
{/if}
···
569
570
{:else if flow.state.step === 'plc-token'}
571
<div class="step-content">
572
-
<h2>Verify Migration</h2>
573
-
<p>A verification code has been sent to the email registered with your old account.</p>
574
575
<div class="info-box">
576
-
<p>
577
-
This code confirms you have access to the account and authorizes updating your identity
578
-
to point to this PDS.
579
-
</p>
580
</div>
581
582
<form onsubmit={submitPlcToken}>
583
<div class="field">
584
-
<label for="plc-token">Verification Code</label>
585
<input
586
id="plc-token"
587
type="text"
588
-
placeholder="Enter code from email"
589
bind:value={flow.state.plcToken}
590
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
591
disabled={loading}
···
595
596
<div class="button-row">
597
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
598
-
Resend Code
599
</button>
600
<button type="submit" disabled={loading || !flow.state.plcToken}>
601
-
{loading ? 'Verifying...' : 'Complete Migration'}
602
</button>
603
</div>
604
</form>
···
653
</div>
654
655
<div class="button-row">
656
-
<button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>Back</button>
657
<button onclick={completeDidWeb} disabled={loading}>
658
{loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')}
659
</button>
···
662
663
{:else if flow.state.step === 'finalizing'}
664
<div class="step-content">
665
-
<h2>Finalizing Migration</h2>
666
-
<p>Please wait while we complete the migration...</p>
667
668
<div class="progress-section">
669
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
670
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
671
-
<span>Sign identity update</span>
672
</div>
673
<div class="progress-item" class:completed={flow.state.progress.activated}>
674
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
675
-
<span>Activate new account</span>
676
</div>
677
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
678
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
679
-
<span>Deactivate old account</span>
680
</div>
681
</div>
682
···
686
{:else if flow.state.step === 'success'}
687
<div class="step-content success-content">
688
<div class="success-icon">✓</div>
689
-
<h2>Migration Complete!</h2>
690
-
<p>Your account has been successfully migrated to this PDS.</p>
691
692
<div class="success-details">
693
<div class="detail-row">
694
-
<span class="label">Your new handle:</span>
695
<span class="value">{flow.state.targetHandle}</span>
696
</div>
697
<div class="detail-row">
698
-
<span class="label">DID:</span>
699
<span class="value mono">{flow.state.sourceDid}</span>
700
</div>
701
</div>
702
703
{#if flow.state.progress.blobsFailed.length > 0}
704
-
<div class="warning-box">
705
-
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
706
-
These may be images or other media that are no longer available.
707
</div>
708
{/if}
709
710
-
<p class="redirect-text">Redirecting to dashboard...</p>
711
</div>
712
713
{:else if flow.state.step === 'error'}
714
<div class="step-content">
715
-
<h2>Migration Error</h2>
716
-
<p>An error occurred during migration.</p>
717
718
-
<div class="error-box">
719
-
{flow.state.error}
720
</div>
721
722
<div class="button-row">
723
-
<button class="ghost" onclick={onBack}>Start Over</button>
724
</div>
725
</div>
726
{/if}
727
</div>
728
729
<style>
730
-
.inbound-wizard {
731
-
max-width: 600px;
732
-
margin: 0 auto;
733
-
}
734
-
735
-
.step-indicator {
736
-
display: flex;
737
-
align-items: center;
738
-
justify-content: center;
739
-
margin-bottom: var(--space-8);
740
-
padding: 0 var(--space-4);
741
}
742
-
743
-
.step {
744
-
display: flex;
745
-
flex-direction: column;
746
-
align-items: center;
747
-
gap: var(--space-2);
748
}
749
-
750
-
.step-dot {
751
-
width: 32px;
752
-
height: 32px;
753
-
border-radius: 50%;
754
-
background: var(--bg-secondary);
755
-
border: 2px solid var(--border);
756
-
display: flex;
757
-
align-items: center;
758
-
justify-content: center;
759
-
font-size: var(--text-sm);
760
-
font-weight: var(--font-medium);
761
-
color: var(--text-secondary);
762
-
}
763
-
764
-
.step.active .step-dot {
765
-
background: var(--accent);
766
-
border-color: var(--accent);
767
-
color: var(--text-inverse);
768
-
}
769
-
770
-
.step.completed .step-dot {
771
-
background: var(--success-bg);
772
-
border-color: var(--success-text);
773
-
color: var(--success-text);
774
-
}
775
-
776
-
.step-label {
777
-
font-size: var(--text-xs);
778
-
color: var(--text-secondary);
779
-
}
780
-
781
-
.step.active .step-label {
782
-
color: var(--accent);
783
-
font-weight: var(--font-medium);
784
-
}
785
-
786
-
.step-line {
787
-
flex: 1;
788
-
height: 2px;
789
-
background: var(--border);
790
-
margin: 0 var(--space-2);
791
-
margin-bottom: var(--space-6);
792
-
min-width: 20px;
793
-
}
794
-
795
-
.step-line.completed {
796
-
background: var(--success-text);
797
-
}
798
-
799
-
.step-content {
800
-
background: var(--bg-secondary);
801
border-radius: var(--radius-xl);
802
padding: var(--space-6);
803
-
}
804
-
805
-
.step-content h2 {
806
-
margin: 0 0 var(--space-3) 0;
807
}
808
-
809
-
.step-content > p {
810
color: var(--text-secondary);
811
-
margin: 0 0 var(--space-5) 0;
812
}
813
-
814
-
.info-box {
815
-
background: var(--accent-muted);
816
-
border: 1px solid var(--accent);
817
-
border-radius: var(--radius-lg);
818
padding: var(--space-5);
819
-
margin-bottom: var(--space-5);
820
-
}
821
-
822
-
.info-box h3 {
823
-
margin: 0 0 var(--space-3) 0;
824
-
font-size: var(--text-base);
825
-
}
826
-
827
-
.info-box ol, .info-box ul {
828
-
margin: 0;
829
-
padding-left: var(--space-5);
830
-
}
831
-
832
-
.info-box li {
833
-
margin-bottom: var(--space-2);
834
-
color: var(--text-secondary);
835
}
836
-
837
-
.info-box p {
838
-
margin: 0;
839
-
color: var(--text-secondary);
840
-
}
841
-
842
-
.warning-box {
843
-
background: var(--warning-bg);
844
-
border: 1px solid var(--warning-border);
845
-
border-radius: var(--radius-lg);
846
-
padding: var(--space-5);
847
-
margin-bottom: var(--space-5);
848
font-size: var(--text-sm);
849
}
850
-
851
-
.warning-box strong {
852
-
color: var(--warning-text);
853
-
}
854
-
855
-
.warning-box ul {
856
-
margin: var(--space-3) 0 0 0;
857
-
padding-left: var(--space-5);
858
-
}
859
-
860
-
.error-box {
861
-
background: var(--error-bg);
862
-
border: 1px solid var(--error-border);
863
-
border-radius: var(--radius-lg);
864
-
padding: var(--space-5);
865
margin-bottom: var(--space-5);
866
-
color: var(--error-text);
867
}
868
-
869
-
.checkbox-label {
870
-
display: inline-flex;
871
-
align-items: flex-start;
872
-
gap: var(--space-3);
873
-
cursor: pointer;
874
-
margin-bottom: var(--space-5);
875
-
text-align: left;
876
}
877
-
878
-
.checkbox-label input[type="checkbox"] {
879
-
width: 18px;
880
-
height: 18px;
881
-
margin: 0;
882
-
flex-shrink: 0;
883
-
}
884
-
885
-
.button-row {
886
-
display: flex;
887
-
gap: var(--space-3);
888
-
justify-content: flex-end;
889
-
margin-top: var(--space-5);
890
-
}
891
-
892
-
.field {
893
-
margin-bottom: var(--space-5);
894
-
}
895
-
896
-
.field label {
897
-
display: block;
898
-
margin-bottom: var(--space-2);
899
-
font-weight: var(--font-medium);
900
-
}
901
-
902
-
.field input, .field select {
903
-
width: 100%;
904
-
padding: var(--space-3);
905
-
border: 1px solid var(--border);
906
-
border-radius: var(--radius-md);
907
-
background: var(--bg-primary);
908
-
color: var(--text-primary);
909
-
}
910
-
911
-
.field input:focus, .field select:focus {
912
-
outline: none;
913
-
border-color: var(--accent);
914
-
}
915
-
916
-
.hint {
917
-
font-size: var(--text-sm);
918
-
color: var(--text-secondary);
919
-
margin: var(--space-2) 0 0 0;
920
-
}
921
-
922
-
.hint.success {
923
-
color: var(--success-text);
924
-
}
925
-
926
-
.hint.error {
927
-
color: var(--error-text);
928
-
}
929
-
930
-
.handle-input-group {
931
display: flex;
932
gap: var(--space-2);
933
}
934
-
935
-
.handle-input-group input {
936
-
flex: 1;
937
-
}
938
-
939
-
.handle-input-group select {
940
-
width: auto;
941
-
}
942
-
943
-
.current-info {
944
-
background: var(--bg-primary);
945
-
border-radius: var(--radius-lg);
946
-
padding: var(--space-4);
947
-
margin-bottom: var(--space-5);
948
-
display: flex;
949
-
justify-content: space-between;
950
-
}
951
-
952
-
.current-info .label {
953
-
color: var(--text-secondary);
954
-
}
955
-
956
-
.current-info .value {
957
-
font-weight: var(--font-medium);
958
-
}
959
-
960
-
.review-card {
961
-
background: var(--bg-primary);
962
-
border-radius: var(--radius-lg);
963
-
padding: var(--space-4);
964
-
margin-bottom: var(--space-5);
965
-
}
966
-
967
-
.review-row {
968
display: flex;
969
justify-content: space-between;
970
-
padding: var(--space-3) 0;
971
-
border-bottom: 1px solid var(--border);
972
-
}
973
-
974
-
.review-row:last-child {
975
-
border-bottom: none;
976
-
}
977
-
978
-
.review-row .label {
979
-
color: var(--text-secondary);
980
-
}
981
-
982
-
.review-row .value {
983
-
font-weight: var(--font-medium);
984
-
text-align: right;
985
-
word-break: break-all;
986
-
}
987
-
988
-
.review-row .value.mono {
989
-
font-family: var(--font-mono);
990
font-size: var(--text-sm);
991
}
992
-
993
-
.progress-section {
994
-
margin-bottom: var(--space-5);
995
-
}
996
-
997
-
.progress-item {
998
-
display: flex;
999
-
align-items: center;
1000
-
gap: var(--space-3);
1001
-
padding: var(--space-3) 0;
1002
-
color: var(--text-secondary);
1003
-
}
1004
-
1005
-
.progress-item.completed {
1006
-
color: var(--success-text);
1007
-
}
1008
-
1009
-
.progress-item.active {
1010
-
color: var(--accent);
1011
-
}
1012
-
1013
-
.progress-item .icon {
1014
-
width: 24px;
1015
-
text-align: center;
1016
-
}
1017
-
1018
-
.progress-bar {
1019
-
height: 8px;
1020
-
background: var(--bg-primary);
1021
-
border-radius: 4px;
1022
-
overflow: hidden;
1023
-
margin-bottom: var(--space-4);
1024
-
}
1025
-
1026
-
.progress-fill {
1027
-
height: 100%;
1028
-
background: var(--accent);
1029
-
transition: width 0.3s ease;
1030
-
}
1031
-
1032
-
.status-text {
1033
-
text-align: center;
1034
-
color: var(--text-secondary);
1035
-
font-size: var(--text-sm);
1036
-
}
1037
-
1038
-
.success-content {
1039
-
text-align: center;
1040
-
}
1041
-
1042
-
.success-icon {
1043
-
width: 64px;
1044
-
height: 64px;
1045
-
background: var(--success-bg);
1046
-
color: var(--success-text);
1047
-
border-radius: 50%;
1048
-
display: flex;
1049
-
align-items: center;
1050
-
justify-content: center;
1051
-
font-size: var(--text-2xl);
1052
-
margin: 0 auto var(--space-5) auto;
1053
-
}
1054
-
1055
-
.success-details {
1056
-
background: var(--bg-primary);
1057
-
border-radius: var(--radius-lg);
1058
-
padding: var(--space-4);
1059
-
margin: var(--space-5) 0;
1060
-
text-align: left;
1061
-
}
1062
-
1063
-
.success-details .detail-row {
1064
-
display: flex;
1065
-
justify-content: space-between;
1066
-
padding: var(--space-2) 0;
1067
-
}
1068
-
1069
-
.success-details .label {
1070
color: var(--text-secondary);
1071
}
1072
-
1073
-
.success-details .value {
1074
font-weight: var(--font-medium);
1075
}
1076
-
1077
-
.success-details .value.mono {
1078
-
font-family: var(--font-mono);
1079
font-size: var(--text-sm);
1080
-
}
1081
-
1082
-
.redirect-text {
1083
-
color: var(--text-secondary);
1084
font-style: italic;
1085
-
}
1086
-
1087
-
.message.error {
1088
-
background: var(--error-bg);
1089
-
border: 1px solid var(--error-border);
1090
-
color: var(--error-text);
1091
-
padding: var(--space-4);
1092
-
border-radius: var(--radius-lg);
1093
-
margin-bottom: var(--space-5);
1094
-
}
1095
-
1096
-
.code-block {
1097
-
background: var(--bg-primary);
1098
-
border: 1px solid var(--border);
1099
-
border-radius: var(--radius-lg);
1100
-
padding: var(--space-4);
1101
-
margin-bottom: var(--space-5);
1102
-
overflow-x: auto;
1103
-
}
1104
-
1105
-
.code-block pre {
1106
-
margin: 0;
1107
-
font-family: var(--font-mono);
1108
-
font-size: var(--text-sm);
1109
-
white-space: pre-wrap;
1110
-
word-break: break-all;
1111
-
}
1112
-
1113
-
code {
1114
-
font-family: var(--font-mono);
1115
-
background: var(--bg-primary);
1116
-
padding: 2px 6px;
1117
-
border-radius: var(--radius-sm);
1118
-
font-size: 0.9em;
1119
}
1120
</style>
···
1
<script lang="ts">
2
import type { InboundMigrationFlow } from '../../lib/migration'
3
+
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
4
+
import { getErrorMessage } from '../../lib/migration/types'
5
+
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
import { _ } from '../../lib/i18n'
7
+
import '../../styles/migration.css'
8
+
9
+
interface ResumeInfo {
10
+
direction: 'inbound' | 'outbound'
11
+
sourceHandle: string
12
+
targetHandle: string
13
+
sourcePdsUrl: string
14
+
targetPdsUrl: string
15
+
targetEmail: string
16
+
authMethod?: AuthMethod
17
+
progressSummary: string
18
+
step: string
19
+
}
20
21
interface Props {
22
flow: InboundMigrationFlow
23
+
resumeInfo?: ResumeInfo | null
24
onBack: () => void
25
onComplete: () => void
26
}
27
28
+
let { flow, resumeInfo = null, onBack, onComplete }: Props = $props()
29
30
let serverInfo = $state<ServerDescription | null>(null)
31
let loading = $state(false)
32
let handleInput = $state('')
33
let localPasswordInput = $state('')
34
let understood = $state(false)
35
let selectedDomain = $state('')
36
let handleAvailable = $state<boolean | null>(null)
37
let checkingHandle = $state(false)
38
+
let selectedAuthMethod = $state<AuthMethod>('password')
39
+
let passkeyName = $state('')
40
+
let appPasswordCopied = $state(false)
41
+
let appPasswordAcknowledged = $state(false)
42
43
+
const isResuming = $derived(flow.state.needsReauth === true)
44
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
45
46
$effect(() => {
47
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
48
loadServerInfo()
49
}
50
+
if (flow.state.step === 'choose-handle') {
51
+
handleInput = ''
52
+
handleAvailable = null
53
+
}
54
+
if (flow.state.step === 'source-handle' && resumeInfo) {
55
+
handleInput = resumeInfo.sourceHandle
56
+
selectedAuthMethod = resumeInfo.authMethod ?? 'password'
57
+
}
58
})
59
60
···
88
}
89
}
90
91
async function checkHandle() {
92
if (!handleInput.trim()) return
93
···
121
try {
122
await flow.startMigration()
123
} catch (err) {
124
+
flow.setError(getErrorMessage(err))
125
} finally {
126
loading = false
127
}
···
133
try {
134
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined)
135
} catch (err) {
136
+
flow.setError(getErrorMessage(err))
137
} finally {
138
loading = false
139
}
···
145
await flow.resendEmailVerification()
146
flow.setError(null)
147
} catch (err) {
148
+
flow.setError(getErrorMessage(err))
149
} finally {
150
loading = false
151
}
···
157
try {
158
await flow.submitPlcToken(flow.state.plcToken)
159
} catch (err) {
160
+
flow.setError(getErrorMessage(err))
161
} finally {
162
loading = false
163
}
···
169
await flow.resendPlcToken()
170
flow.setError(null)
171
} catch (err) {
172
+
flow.setError(getErrorMessage(err))
173
} finally {
174
loading = false
175
}
···
180
try {
181
await flow.completeDidWebMigration()
182
} catch (err) {
183
+
flow.setError(getErrorMessage(err))
184
+
} finally {
185
+
loading = false
186
+
}
187
+
}
188
+
189
+
async function registerPasskey() {
190
+
loading = true
191
+
flow.setError(null)
192
+
193
+
try {
194
+
if (!window.PublicKeyCredential) {
195
+
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
196
+
}
197
+
198
+
const { options } = await flow.startPasskeyRegistration()
199
+
200
+
const publicKeyOptions = prepareWebAuthnCreationOptions(
201
+
options as { publicKey: Record<string, unknown> }
202
+
)
203
+
const credential = await navigator.credentials.create({
204
+
publicKey: publicKeyOptions,
205
+
})
206
+
207
+
if (!credential) {
208
+
throw new Error('Passkey creation was cancelled')
209
+
}
210
+
211
+
const publicKeyCredential = credential as PublicKeyCredential
212
+
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
213
+
214
+
const credentialData = {
215
+
id: publicKeyCredential.id,
216
+
rawId: base64UrlEncode(publicKeyCredential.rawId),
217
+
type: publicKeyCredential.type,
218
+
response: {
219
+
clientDataJSON: base64UrlEncode(response.clientDataJSON),
220
+
attestationObject: base64UrlEncode(response.attestationObject),
221
+
},
222
+
}
223
+
224
+
await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
225
+
} catch (err) {
226
+
const message = getErrorMessage(err)
227
+
if (message.includes('cancelled') || message.includes('AbortError')) {
228
+
flow.setError('Passkey registration was cancelled. Please try again.')
229
+
} else {
230
+
flow.setError(message)
231
+
}
232
} finally {
233
loading = false
234
}
235
}
236
237
+
function copyAppPassword() {
238
+
if (flow.state.generatedAppPassword) {
239
+
navigator.clipboard.writeText(flow.state.generatedAppPassword)
240
+
appPasswordCopied = true
241
+
}
242
+
}
243
+
244
+
async function handleProceedFromAppPassword() {
245
+
loading = true
246
+
try {
247
+
await flow.proceedFromAppPassword()
248
+
} catch (err) {
249
+
flow.setError(getErrorMessage(err))
250
+
} finally {
251
+
loading = false
252
+
}
253
+
}
254
+
255
+
async function handleSourceHandleSubmit(e: Event) {
256
+
e.preventDefault()
257
+
loading = true
258
+
flow.updateField('error', null)
259
+
260
+
try {
261
+
await flow.initiateOAuthLogin(handleInput)
262
+
} catch (err) {
263
+
flow.setError(getErrorMessage(err))
264
+
} finally {
265
+
loading = false
266
+
}
267
+
}
268
+
269
+
function proceedToReviewWithAuth() {
270
+
const fullHandle = handleInput.includes('.')
271
+
? handleInput
272
+
: `${handleInput}.${selectedDomain}`
273
+
274
+
flow.updateField('targetHandle', fullHandle)
275
+
flow.updateField('authMethod', selectedAuthMethod)
276
+
flow.setStep('review')
277
+
}
278
+
279
const steps = $derived(isDidWeb
280
+
? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete']
281
+
: flow.state.authMethod === 'passkey'
282
+
? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete']
283
+
: ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'])
284
+
285
function getCurrentStepIndex(): number {
286
+
const isPasskey = flow.state.authMethod === 'passkey'
287
switch (flow.state.step) {
288
case 'welcome':
289
+
case 'source-handle': return 0
290
case 'choose-handle': return 1
291
case 'review': return 2
292
case 'migrating': return 3
293
case 'email-verify': return 4
294
+
case 'passkey-setup': return isPasskey ? 5 : 4
295
+
case 'app-password': return 6
296
case 'plc-token':
297
case 'did-web-update':
298
+
case 'finalizing': return isPasskey ? 7 : 5
299
+
case 'success': return isPasskey ? 8 : 6
300
default: return 0
301
}
302
}
303
</script>
304
305
+
<div class="migration-wizard">
306
<div class="step-indicator">
307
+
{#each steps as _, i}
308
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
309
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
310
</div>
311
{#if i < steps.length - 1}
312
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
313
{/if}
314
{/each}
315
</div>
316
+
<div class="current-step-label">
317
+
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
318
+
</div>
319
320
{#if flow.state.error}
321
<div class="message error">{flow.state.error}</div>
···
323
324
{#if flow.state.step === 'welcome'}
325
<div class="step-content">
326
+
<h2>{$_('migration.inbound.welcome.title')}</h2>
327
+
<p>{$_('migration.inbound.welcome.desc')}</p>
328
329
<div class="info-box">
330
+
<h3>{$_('migration.inbound.common.whatWillHappen')}</h3>
331
<ol>
332
+
<li>{$_('migration.inbound.common.step1')}</li>
333
+
<li>{$_('migration.inbound.common.step2')}</li>
334
+
<li>{$_('migration.inbound.common.step3')}</li>
335
+
<li>{$_('migration.inbound.common.step4')}</li>
336
+
<li>{$_('migration.inbound.common.step5')}</li>
337
</ol>
338
</div>
339
340
<div class="warning-box">
341
+
<strong>{$_('migration.inbound.common.beforeProceed')}</strong>
342
<ul>
343
+
<li>{$_('migration.inbound.common.warning1')}</li>
344
+
<li>{$_('migration.inbound.common.warning2')}</li>
345
+
<li>{$_('migration.inbound.common.warning3')}</li>
346
</ul>
347
</div>
348
349
<label class="checkbox-label">
350
<input type="checkbox" bind:checked={understood} />
351
+
<span>{$_('migration.inbound.welcome.understand')}</span>
352
</label>
353
354
<div class="button-row">
355
+
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
356
+
<button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
357
+
{$_('migration.inbound.common.continue')}
358
</button>
359
</div>
360
</div>
361
362
+
{:else if flow.state.step === 'source-handle'}
363
<div class="step-content">
364
+
<h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2>
365
+
<p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p>
366
367
+
{#if isResuming && resumeInfo}
368
+
<div class="info-box resume-info">
369
+
<h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3>
370
+
<div class="resume-details">
371
+
<div class="resume-row">
372
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span>
373
+
<span class="value">@{resumeInfo.sourceHandle}</span>
374
+
</div>
375
+
<div class="resume-row">
376
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span>
377
+
<span class="value">@{resumeInfo.targetHandle}</span>
378
+
</div>
379
+
<div class="resume-row">
380
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span>
381
+
<span class="value">{resumeInfo.progressSummary}</span>
382
+
</div>
383
+
</div>
384
+
<p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p>
385
</div>
386
{/if}
387
388
+
<form onsubmit={handleSourceHandleSubmit}>
389
<div class="field">
390
+
<label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label>
391
<input
392
+
id="source-handle"
393
type="text"
394
+
placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')}
395
bind:value={handleInput}
396
+
disabled={loading || isResuming}
397
required
398
/>
399
+
<p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p>
400
</div>
401
402
<div class="button-row">
403
+
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
404
+
<button type="submit" disabled={loading || !handleInput.trim()}>
405
+
{loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))}
406
</button>
407
</div>
408
</form>
···
410
411
{:else if flow.state.step === 'choose-handle'}
412
<div class="step-content">
413
+
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
414
+
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
415
416
<div class="current-info">
417
+
<span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
418
<span class="value">{flow.state.sourceHandle}</span>
419
</div>
420
421
<div class="field">
422
+
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
423
<div class="handle-input-group">
424
<input
425
id="new-handle"
···
438
</div>
439
440
{#if checkingHandle}
441
+
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
442
{:else if handleAvailable === true}
443
+
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
444
{:else if handleAvailable === false}
445
+
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
446
{:else}
447
+
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
448
{/if}
449
</div>
450
451
<div class="field">
452
+
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
453
<input
454
id="email"
455
type="email"
···
461
</div>
462
463
<div class="field">
464
+
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
465
+
<div class="auth-method-options">
466
+
<label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
467
+
<input
468
+
type="radio"
469
+
name="auth-method"
470
+
value="password"
471
+
bind:group={selectedAuthMethod}
472
+
/>
473
+
<div class="auth-option-content">
474
+
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
475
+
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
476
+
</div>
477
+
</label>
478
+
<label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
479
+
<input
480
+
type="radio"
481
+
name="auth-method"
482
+
value="passkey"
483
+
bind:group={selectedAuthMethod}
484
+
/>
485
+
<div class="auth-option-content">
486
+
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
487
+
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
488
+
</div>
489
+
</label>
490
+
</div>
491
</div>
492
493
+
{#if selectedAuthMethod === 'password'}
494
+
<div class="field">
495
+
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
496
+
<input
497
+
id="new-password"
498
+
type="password"
499
+
placeholder="Password for your new account"
500
+
bind:value={flow.state.targetPassword}
501
+
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
502
+
required
503
+
minlength="8"
504
+
/>
505
+
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
506
+
</div>
507
+
{:else}
508
+
<div class="info-box">
509
+
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
510
+
</div>
511
+
{/if}
512
+
513
{#if serverInfo?.inviteCodeRequired}
514
<div class="field">
515
+
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
516
<input
517
id="invite"
518
type="text"
···
525
{/if}
526
527
<div class="button-row">
528
+
<button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
529
<button
530
+
disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
531
+
onclick={proceedToReviewWithAuth}
532
>
533
+
{$_('migration.inbound.common.continue')}
534
</button>
535
</div>
536
</div>
537
538
{:else if flow.state.step === 'review'}
539
<div class="step-content">
540
+
<h2>{$_('migration.inbound.review.title')}</h2>
541
+
<p>{$_('migration.inbound.review.desc')}</p>
542
543
<div class="review-card">
544
<div class="review-row">
545
+
<span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
546
<span class="value">{flow.state.sourceHandle}</span>
547
</div>
548
<div class="review-row">
549
+
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
550
<span class="value">{flow.state.targetHandle}</span>
551
</div>
552
<div class="review-row">
553
+
<span class="label">{$_('migration.inbound.review.did')}:</span>
554
<span class="value mono">{flow.state.sourceDid}</span>
555
</div>
556
<div class="review-row">
557
+
<span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
558
<span class="value">{flow.state.sourcePdsUrl}</span>
559
</div>
560
<div class="review-row">
561
+
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
562
<span class="value">{window.location.origin}</span>
563
</div>
564
<div class="review-row">
565
+
<span class="label">{$_('migration.inbound.review.email')}:</span>
566
<span class="value">{flow.state.targetEmail}</span>
567
</div>
568
+
<div class="review-row">
569
+
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
570
+
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
571
+
</div>
572
</div>
573
574
<div class="warning-box">
575
+
{$_('migration.inbound.review.warning')}
576
</div>
577
578
<div class="button-row">
579
+
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
580
<button onclick={startMigration} disabled={loading}>
581
+
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
582
</button>
583
</div>
584
</div>
585
586
{:else if flow.state.step === 'migrating'}
587
<div class="step-content">
588
+
<h2>{$_('migration.inbound.migrating.title')}</h2>
589
+
<p>{$_('migration.inbound.migrating.desc')}</p>
590
591
<div class="progress-section">
592
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
593
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
594
+
<span>{$_('migration.inbound.migrating.exportRepo')}</span>
595
</div>
596
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
597
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
598
+
<span>{$_('migration.inbound.migrating.importRepo')}</span>
599
</div>
600
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
601
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
602
+
<span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
603
</div>
604
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
605
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
606
+
<span>{$_('migration.inbound.migrating.migratePrefs')}</span>
607
</div>
608
</div>
609
···
619
<p class="status-text">{flow.state.progress.currentOperation}</p>
620
</div>
621
622
+
{:else if flow.state.step === 'passkey-setup'}
623
+
<div class="step-content">
624
+
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
625
+
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
626
+
627
+
{#if flow.state.error}
628
+
<div class="message error">
629
+
{flow.state.error}
630
+
</div>
631
+
{/if}
632
+
633
+
<div class="field">
634
+
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
635
+
<input
636
+
id="passkey-name"
637
+
type="text"
638
+
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
639
+
bind:value={passkeyName}
640
+
disabled={loading}
641
+
/>
642
+
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
643
+
</div>
644
+
645
+
<div class="passkey-section">
646
+
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
647
+
<button class="primary" onclick={registerPasskey} disabled={loading}>
648
+
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
649
+
</button>
650
+
</div>
651
+
</div>
652
+
653
+
{:else if flow.state.step === 'app-password'}
654
+
<div class="step-content">
655
+
<h2>{$_('migration.inbound.appPassword.title')}</h2>
656
+
<p>{$_('migration.inbound.appPassword.desc')}</p>
657
+
658
+
<div class="warning-box">
659
+
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
660
+
</div>
661
+
662
+
<div class="app-password-display">
663
+
<div class="app-password-label">
664
+
{$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
665
+
</div>
666
+
<code class="app-password-code">{flow.state.generatedAppPassword}</code>
667
+
<button type="button" class="copy-btn" onclick={copyAppPassword}>
668
+
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
669
+
</button>
670
+
</div>
671
+
672
+
<label class="checkbox-label">
673
+
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
674
+
<span>{$_('migration.inbound.appPassword.saved')}</span>
675
+
</label>
676
+
677
+
<div class="button-row">
678
+
<button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
679
+
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
680
+
</button>
681
+
</div>
682
+
</div>
683
+
684
{:else if flow.state.step === 'email-verify'}
685
<div class="step-content">
686
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
···
693
</div>
694
695
{#if flow.state.error}
696
+
<div class="message error">
697
{flow.state.error}
698
</div>
699
{/if}
···
725
726
{:else if flow.state.step === 'plc-token'}
727
<div class="step-content">
728
+
<h2>{$_('migration.inbound.plcToken.title')}</h2>
729
+
<p>{$_('migration.inbound.plcToken.desc')}</p>
730
731
<div class="info-box">
732
+
<p>{$_('migration.inbound.plcToken.info')}</p>
733
</div>
734
735
<form onsubmit={submitPlcToken}>
736
<div class="field">
737
+
<label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label>
738
<input
739
id="plc-token"
740
type="text"
741
+
placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')}
742
bind:value={flow.state.plcToken}
743
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
744
disabled={loading}
···
748
749
<div class="button-row">
750
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
751
+
{$_('migration.inbound.plcToken.resend')}
752
</button>
753
<button type="submit" disabled={loading || !flow.state.plcToken}>
754
+
{loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')}
755
</button>
756
</div>
757
</form>
···
806
</div>
807
808
<div class="button-row">
809
+
<button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
810
<button onclick={completeDidWeb} disabled={loading}>
811
{loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')}
812
</button>
···
815
816
{:else if flow.state.step === 'finalizing'}
817
<div class="step-content">
818
+
<h2>{$_('migration.inbound.finalizing.title')}</h2>
819
+
<p>{$_('migration.inbound.finalizing.desc')}</p>
820
821
<div class="progress-section">
822
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
823
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
824
+
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
825
</div>
826
<div class="progress-item" class:completed={flow.state.progress.activated}>
827
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
828
+
<span>{$_('migration.inbound.finalizing.activating')}</span>
829
</div>
830
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
831
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
832
+
<span>{$_('migration.inbound.finalizing.deactivating')}</span>
833
</div>
834
</div>
835
···
839
{:else if flow.state.step === 'success'}
840
<div class="step-content success-content">
841
<div class="success-icon">✓</div>
842
+
<h2>{$_('migration.inbound.success.title')}</h2>
843
+
<p>{$_('migration.inbound.success.desc')}</p>
844
845
<div class="success-details">
846
<div class="detail-row">
847
+
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
848
<span class="value">{flow.state.targetHandle}</span>
849
</div>
850
<div class="detail-row">
851
+
<span class="label">{$_('migration.inbound.success.did')}:</span>
852
<span class="value mono">{flow.state.sourceDid}</span>
853
</div>
854
</div>
855
856
{#if flow.state.progress.blobsFailed.length > 0}
857
+
<div class="message warning">
858
+
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
859
</div>
860
{/if}
861
862
+
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
863
</div>
864
865
{:else if flow.state.step === 'error'}
866
<div class="step-content">
867
+
<h2>{$_('migration.inbound.error.title')}</h2>
868
+
<p>{$_('migration.inbound.error.desc')}</p>
869
870
+
<div class="message error">
871
+
{flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
872
</div>
873
874
<div class="button-row">
875
+
<button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
876
</div>
877
</div>
878
{/if}
879
</div>
880
881
<style>
882
+
.passkey-section {
883
+
margin-top: 16px;
884
}
885
+
.passkey-section button {
886
+
width: 100%;
887
+
margin-top: 12px;
888
}
889
+
.app-password-display {
890
+
background: var(--bg-card);
891
+
border: 2px solid var(--accent);
892
border-radius: var(--radius-xl);
893
padding: var(--space-6);
894
+
text-align: center;
895
+
margin: var(--space-4) 0;
896
}
897
+
.app-password-label {
898
+
font-size: var(--text-sm);
899
color: var(--text-secondary);
900
+
margin-bottom: var(--space-4);
901
}
902
+
.app-password-code {
903
+
display: block;
904
+
font-size: var(--text-xl);
905
+
font-family: ui-monospace, monospace;
906
+
letter-spacing: 0.1em;
907
padding: var(--space-5);
908
+
background: var(--bg-input);
909
+
border-radius: var(--radius-md);
910
+
margin-bottom: var(--space-4);
911
+
user-select: all;
912
}
913
+
.copy-btn {
914
+
padding: var(--space-3) var(--space-5);
915
font-size: var(--text-sm);
916
}
917
+
.resume-info {
918
margin-bottom: var(--space-5);
919
}
920
+
.resume-info h3 {
921
+
margin: 0 0 var(--space-3) 0;
922
+
font-size: var(--text-base);
923
}
924
+
.resume-details {
925
display: flex;
926
+
flex-direction: column;
927
gap: var(--space-2);
928
}
929
+
.resume-row {
930
display: flex;
931
justify-content: space-between;
932
font-size: var(--text-sm);
933
}
934
+
.resume-row .label {
935
color: var(--text-secondary);
936
}
937
+
.resume-row .value {
938
font-weight: var(--font-medium);
939
}
940
+
.resume-note {
941
+
margin-top: var(--space-3);
942
font-size: var(--text-sm);
943
font-style: italic;
944
}
945
</style>
+20
-466
frontend/src/components/migration/OutboundWizard.svelte
+20
-466
frontend/src/components/migration/OutboundWizard.svelte
···
2
import type { OutboundMigrationFlow } from '../../lib/migration'
3
import type { ServerDescription } from '../../lib/migration/types'
4
import { getAuthState, logout } from '../../lib/auth.svelte'
5
6
interface Props {
7
flow: OutboundMigrationFlow
···
119
}
120
</script>
121
122
-
<div class="outbound-wizard">
123
{#if flow.state.step !== 'welcome'}
124
<div class="step-indicator">
125
{#each steps as stepName, i}
···
135
{/if}
136
137
{#if flow.state.error}
138
-
<div class="message error">{flow.state.error}</div>
139
{/if}
140
141
{#if flow.state.step === 'welcome'}
···
149
</div>
150
151
{#if isDidWeb()}
152
-
<div class="warning-box">
153
<strong>did:web Migration Notice</strong>
154
<p>
155
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
···
161
</div>
162
{/if}
163
164
-
<div class="info-box">
165
<h3>What will happen:</h3>
166
<ol>
167
<li>Choose your new PDS</li>
···
173
</ol>
174
</div>
175
176
-
<div class="warning-box">
177
<strong>Before you proceed:</strong>
178
<ul>
179
<li>You need access to the email registered with this account</li>
···
202
<p>Enter the URL of the PDS you want to migrate to.</p>
203
204
<form onsubmit={validatePds}>
205
-
<div class="field">
206
<label for="pds-url">PDS URL</label>
207
<input
208
id="pds-url"
···
212
disabled={loading}
213
required
214
/>
215
-
<p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
216
</div>
217
218
<div class="button-row">
···
264
<span class="value">{flow.state.targetPdsUrl}</span>
265
</div>
266
267
-
<div class="field">
268
<label for="new-handle">New Handle</label>
269
<div class="handle-input-group">
270
<input
···
281
</select>
282
{/if}
283
</div>
284
-
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
285
</div>
286
287
-
<div class="field">
288
<label for="email">Email Address</label>
289
<input
290
id="email"
···
296
/>
297
</div>
298
299
-
<div class="field">
300
<label for="new-password">Password</label>
301
<input
302
id="new-password"
···
307
required
308
minlength="8"
309
/>
310
-
<p class="hint">At least 8 characters. This will be your password on the new PDS.</p>
311
</div>
312
313
{#if flow.state.targetServerInfo?.inviteCodeRequired}
314
-
<div class="field">
315
<label for="invite">Invite Code</label>
316
<input
317
id="invite"
···
321
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
322
required
323
/>
324
-
<p class="hint">Required by this PDS to create an account</p>
325
</div>
326
{/if}
327
···
368
</div>
369
</div>
370
371
-
<div class="warning-box final-warning">
372
<strong>This action cannot be easily undone!</strong>
373
<p>
374
After migration completes, your account on this PDS will be deactivated.
···
430
<h2>Verify Migration</h2>
431
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
432
433
-
<div class="info-box">
434
<p>
435
This code confirms you have access to the account and authorizes updating your identity
436
to point to the new PDS.
···
438
</div>
439
440
<form onsubmit={submitPlcToken}>
441
-
<div class="field">
442
<label for="plc-token">Verification Code</label>
443
<input
444
id="plc-token"
···
507
</div>
508
509
{#if flow.state.progress.blobsFailed.length > 0}
510
-
<div class="warning-box">
511
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
512
These may be images or other media that are no longer available.
513
</div>
···
530
<h2>Migration Error</h2>
531
<p>An error occurred during migration.</p>
532
533
-
<div class="error-box">
534
{flow.state.error}
535
</div>
536
···
542
</div>
543
544
<style>
545
-
.outbound-wizard {
546
-
max-width: 600px;
547
-
margin: 0 auto;
548
-
}
549
-
550
-
.step-indicator {
551
-
display: flex;
552
-
align-items: center;
553
-
justify-content: center;
554
-
margin-bottom: var(--space-8);
555
-
padding: 0 var(--space-4);
556
-
}
557
-
558
-
.step {
559
-
display: flex;
560
-
flex-direction: column;
561
-
align-items: center;
562
-
gap: var(--space-2);
563
-
}
564
-
565
-
.step-dot {
566
-
width: 32px;
567
-
height: 32px;
568
-
border-radius: 50%;
569
-
background: var(--bg-secondary);
570
-
border: 2px solid var(--border);
571
-
display: flex;
572
-
align-items: center;
573
-
justify-content: center;
574
-
font-size: var(--text-sm);
575
-
font-weight: var(--font-medium);
576
-
color: var(--text-secondary);
577
-
}
578
-
579
-
.step.active .step-dot {
580
-
background: var(--accent);
581
-
border-color: var(--accent);
582
-
color: var(--text-inverse);
583
-
}
584
-
585
-
.step.completed .step-dot {
586
-
background: var(--success-bg);
587
-
border-color: var(--success-text);
588
-
color: var(--success-text);
589
-
}
590
-
591
-
.step-label {
592
-
font-size: var(--text-xs);
593
-
color: var(--text-secondary);
594
-
}
595
-
596
-
.step.active .step-label {
597
-
color: var(--accent);
598
-
font-weight: var(--font-medium);
599
-
}
600
-
601
-
.step-line {
602
-
flex: 1;
603
-
height: 2px;
604
-
background: var(--border);
605
-
margin: 0 var(--space-2);
606
-
margin-bottom: var(--space-6);
607
-
min-width: 20px;
608
-
}
609
-
610
-
.step-line.completed {
611
-
background: var(--success-text);
612
-
}
613
-
614
-
.step-content {
615
-
background: var(--bg-secondary);
616
-
border-radius: var(--radius-xl);
617
-
padding: var(--space-6);
618
-
}
619
-
620
-
.step-content h2 {
621
-
margin: 0 0 var(--space-3) 0;
622
-
}
623
-
624
-
.step-content > p {
625
-
color: var(--text-secondary);
626
-
margin: 0 0 var(--space-5) 0;
627
-
}
628
-
629
-
.current-account {
630
-
background: var(--bg-primary);
631
-
border-radius: var(--radius-lg);
632
-
padding: var(--space-4);
633
-
margin-bottom: var(--space-5);
634
-
display: flex;
635
-
justify-content: space-between;
636
-
align-items: center;
637
-
}
638
-
639
-
.current-account .label {
640
-
color: var(--text-secondary);
641
-
}
642
-
643
-
.current-account .value {
644
-
font-weight: var(--font-medium);
645
-
font-size: var(--text-lg);
646
-
}
647
-
648
-
.info-box {
649
-
background: var(--accent-muted);
650
-
border: 1px solid var(--accent);
651
-
border-radius: var(--radius-lg);
652
-
padding: var(--space-5);
653
-
margin-bottom: var(--space-5);
654
-
}
655
-
656
-
.info-box h3 {
657
-
margin: 0 0 var(--space-3) 0;
658
-
font-size: var(--text-base);
659
-
}
660
-
661
-
.info-box ol, .info-box ul {
662
-
margin: 0;
663
-
padding-left: var(--space-5);
664
-
}
665
-
666
-
.info-box li {
667
-
margin-bottom: var(--space-2);
668
-
color: var(--text-secondary);
669
-
}
670
-
671
-
.info-box p {
672
-
margin: 0;
673
-
color: var(--text-secondary);
674
-
}
675
-
676
-
.warning-box {
677
-
background: var(--warning-bg);
678
-
border: 1px solid var(--warning-border);
679
-
border-radius: var(--radius-lg);
680
-
padding: var(--space-5);
681
-
margin-bottom: var(--space-5);
682
-
font-size: var(--text-sm);
683
-
}
684
-
685
-
.warning-box strong {
686
-
color: var(--warning-text);
687
-
}
688
-
689
-
.warning-box p {
690
-
margin: var(--space-3) 0 0 0;
691
-
color: var(--text-secondary);
692
-
}
693
-
694
-
.warning-box ul {
695
-
margin: var(--space-3) 0 0 0;
696
-
padding-left: var(--space-5);
697
-
}
698
-
699
-
.final-warning {
700
-
background: var(--error-bg);
701
-
border-color: var(--error-border);
702
-
}
703
-
704
-
.final-warning strong {
705
-
color: var(--error-text);
706
-
}
707
-
708
-
.error-box {
709
-
background: var(--error-bg);
710
-
border: 1px solid var(--error-border);
711
-
border-radius: var(--radius-lg);
712
-
padding: var(--space-5);
713
-
margin-bottom: var(--space-5);
714
-
color: var(--error-text);
715
-
}
716
-
717
-
.checkbox-label {
718
-
display: inline-flex;
719
-
align-items: flex-start;
720
-
gap: var(--space-3);
721
-
cursor: pointer;
722
-
margin-bottom: var(--space-5);
723
-
text-align: left;
724
-
}
725
-
726
-
.checkbox-label input[type="checkbox"] {
727
-
width: 18px;
728
-
height: 18px;
729
-
margin: 0;
730
-
flex-shrink: 0;
731
-
}
732
-
733
-
.button-row {
734
-
display: flex;
735
-
gap: var(--space-3);
736
-
justify-content: flex-end;
737
-
margin-top: var(--space-5);
738
-
}
739
-
740
-
.field {
741
-
margin-bottom: var(--space-5);
742
-
}
743
-
744
-
.field label {
745
-
display: block;
746
-
margin-bottom: var(--space-2);
747
-
font-weight: var(--font-medium);
748
-
}
749
-
750
-
.field input, .field select {
751
-
width: 100%;
752
-
padding: var(--space-3);
753
-
border: 1px solid var(--border);
754
-
border-radius: var(--radius-md);
755
-
background: var(--bg-primary);
756
-
color: var(--text-primary);
757
-
}
758
-
759
-
.field input:focus, .field select:focus {
760
-
outline: none;
761
-
border-color: var(--accent);
762
-
}
763
-
764
-
.hint {
765
-
font-size: var(--text-sm);
766
-
color: var(--text-secondary);
767
-
margin: var(--space-2) 0 0 0;
768
-
}
769
-
770
-
.handle-input-group {
771
-
display: flex;
772
-
gap: var(--space-2);
773
-
}
774
-
775
-
.handle-input-group input {
776
-
flex: 1;
777
-
}
778
-
779
-
.handle-input-group select {
780
-
width: auto;
781
-
}
782
-
783
-
.current-info {
784
-
background: var(--bg-primary);
785
-
border-radius: var(--radius-lg);
786
-
padding: var(--space-4);
787
-
margin-bottom: var(--space-5);
788
-
display: flex;
789
-
justify-content: space-between;
790
-
}
791
-
792
-
.current-info .label {
793
-
color: var(--text-secondary);
794
-
}
795
-
796
-
.current-info .value {
797
-
font-weight: var(--font-medium);
798
-
}
799
-
800
-
.server-info {
801
-
background: var(--bg-primary);
802
-
border-radius: var(--radius-lg);
803
-
padding: var(--space-4);
804
-
margin-top: var(--space-5);
805
-
}
806
-
807
-
.server-info h3 {
808
-
margin: 0 0 var(--space-3) 0;
809
-
font-size: var(--text-base);
810
-
color: var(--success-text);
811
-
}
812
-
813
-
.server-info .info-row {
814
-
display: flex;
815
-
justify-content: space-between;
816
-
padding: var(--space-2) 0;
817
-
font-size: var(--text-sm);
818
-
}
819
-
820
-
.server-info .label {
821
-
color: var(--text-secondary);
822
-
}
823
-
824
-
.server-info a {
825
-
display: inline-block;
826
-
margin-top: var(--space-2);
827
-
margin-right: var(--space-3);
828
-
color: var(--accent);
829
-
font-size: var(--text-sm);
830
-
}
831
-
832
-
.review-card {
833
-
background: var(--bg-primary);
834
-
border-radius: var(--radius-lg);
835
-
padding: var(--space-4);
836
-
margin-bottom: var(--space-5);
837
-
}
838
-
839
-
.review-row {
840
-
display: flex;
841
-
justify-content: space-between;
842
-
padding: var(--space-3) 0;
843
-
border-bottom: 1px solid var(--border);
844
-
}
845
-
846
-
.review-row:last-child {
847
-
border-bottom: none;
848
-
}
849
-
850
-
.review-row .label {
851
-
color: var(--text-secondary);
852
-
}
853
-
854
-
.review-row .value {
855
-
font-weight: var(--font-medium);
856
-
text-align: right;
857
-
word-break: break-all;
858
-
}
859
-
860
-
.review-row .value.mono {
861
-
font-family: var(--font-mono);
862
-
font-size: var(--text-sm);
863
-
}
864
-
865
-
.progress-section {
866
-
margin-bottom: var(--space-5);
867
-
}
868
-
869
-
.progress-item {
870
-
display: flex;
871
-
align-items: center;
872
-
gap: var(--space-3);
873
-
padding: var(--space-3) 0;
874
-
color: var(--text-secondary);
875
-
}
876
-
877
-
.progress-item.completed {
878
-
color: var(--success-text);
879
-
}
880
-
881
-
.progress-item.active {
882
-
color: var(--accent);
883
-
}
884
-
885
-
.progress-item .icon {
886
-
width: 24px;
887
-
text-align: center;
888
-
}
889
-
890
-
.progress-bar {
891
-
height: 8px;
892
-
background: var(--bg-primary);
893
-
border-radius: 4px;
894
-
overflow: hidden;
895
-
margin-bottom: var(--space-4);
896
-
}
897
-
898
-
.progress-fill {
899
-
height: 100%;
900
-
background: var(--accent);
901
-
transition: width 0.3s ease;
902
-
}
903
-
904
-
.status-text {
905
-
text-align: center;
906
-
color: var(--text-secondary);
907
-
font-size: var(--text-sm);
908
-
}
909
-
910
-
.success-content {
911
-
text-align: center;
912
-
}
913
-
914
-
.success-icon {
915
-
width: 64px;
916
-
height: 64px;
917
-
background: var(--success-bg);
918
-
color: var(--success-text);
919
-
border-radius: 50%;
920
-
display: flex;
921
-
align-items: center;
922
-
justify-content: center;
923
-
font-size: var(--text-2xl);
924
-
margin: 0 auto var(--space-5) auto;
925
-
}
926
-
927
-
.success-details {
928
-
background: var(--bg-primary);
929
-
border-radius: var(--radius-lg);
930
-
padding: var(--space-4);
931
-
margin: var(--space-5) 0;
932
-
text-align: left;
933
-
}
934
-
935
-
.success-details .detail-row {
936
-
display: flex;
937
-
justify-content: space-between;
938
-
padding: var(--space-2) 0;
939
-
}
940
-
941
-
.success-details .label {
942
-
color: var(--text-secondary);
943
-
}
944
-
945
-
.success-details .value {
946
-
font-weight: var(--font-medium);
947
-
}
948
-
949
-
.success-details .value.mono {
950
-
font-family: var(--font-mono);
951
-
font-size: var(--text-sm);
952
-
}
953
-
954
-
.next-steps {
955
-
background: var(--accent-muted);
956
-
border-radius: var(--radius-lg);
957
-
padding: var(--space-5);
958
-
margin: var(--space-5) 0;
959
-
text-align: left;
960
-
}
961
-
962
-
.next-steps h3 {
963
-
margin: 0 0 var(--space-3) 0;
964
-
}
965
-
966
-
.next-steps ol {
967
-
margin: 0;
968
-
padding-left: var(--space-5);
969
-
}
970
-
971
-
.next-steps li {
972
-
margin-bottom: var(--space-2);
973
-
}
974
-
975
-
.next-steps a {
976
-
color: var(--accent);
977
-
}
978
-
979
-
.redirect-text {
980
-
color: var(--text-secondary);
981
-
font-style: italic;
982
-
}
983
-
984
-
.message.error {
985
-
background: var(--error-bg);
986
-
border: 1px solid var(--error-border);
987
-
color: var(--error-text);
988
-
padding: var(--space-4);
989
-
border-radius: var(--radius-lg);
990
-
margin-bottom: var(--space-5);
991
-
}
992
</style>
···
2
import type { OutboundMigrationFlow } from '../../lib/migration'
3
import type { ServerDescription } from '../../lib/migration/types'
4
import { getAuthState, logout } from '../../lib/auth.svelte'
5
+
import '../../styles/migration.css'
6
7
interface Props {
8
flow: OutboundMigrationFlow
···
120
}
121
</script>
122
123
+
<div class="migration-wizard">
124
{#if flow.state.step !== 'welcome'}
125
<div class="step-indicator">
126
{#each steps as stepName, i}
···
136
{/if}
137
138
{#if flow.state.error}
139
+
<div class="migration-message error">{flow.state.error}</div>
140
{/if}
141
142
{#if flow.state.step === 'welcome'}
···
150
</div>
151
152
{#if isDidWeb()}
153
+
<div class="migration-warning-box">
154
<strong>did:web Migration Notice</strong>
155
<p>
156
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
···
162
</div>
163
{/if}
164
165
+
<div class="migration-info-box">
166
<h3>What will happen:</h3>
167
<ol>
168
<li>Choose your new PDS</li>
···
174
</ol>
175
</div>
176
177
+
<div class="migration-warning-box">
178
<strong>Before you proceed:</strong>
179
<ul>
180
<li>You need access to the email registered with this account</li>
···
203
<p>Enter the URL of the PDS you want to migrate to.</p>
204
205
<form onsubmit={validatePds}>
206
+
<div class="migration-field">
207
<label for="pds-url">PDS URL</label>
208
<input
209
id="pds-url"
···
213
disabled={loading}
214
required
215
/>
216
+
<p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
217
</div>
218
219
<div class="button-row">
···
265
<span class="value">{flow.state.targetPdsUrl}</span>
266
</div>
267
268
+
<div class="migration-field">
269
<label for="new-handle">New Handle</label>
270
<div class="handle-input-group">
271
<input
···
282
</select>
283
{/if}
284
</div>
285
+
<p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
286
</div>
287
288
+
<div class="migration-field">
289
<label for="email">Email Address</label>
290
<input
291
id="email"
···
297
/>
298
</div>
299
300
+
<div class="migration-field">
301
<label for="new-password">Password</label>
302
<input
303
id="new-password"
···
308
required
309
minlength="8"
310
/>
311
+
<p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p>
312
</div>
313
314
{#if flow.state.targetServerInfo?.inviteCodeRequired}
315
+
<div class="migration-field">
316
<label for="invite">Invite Code</label>
317
<input
318
id="invite"
···
322
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
323
required
324
/>
325
+
<p class="migration-hint">Required by this PDS to create an account</p>
326
</div>
327
{/if}
328
···
369
</div>
370
</div>
371
372
+
<div class="migration-warning-box final-warning">
373
<strong>This action cannot be easily undone!</strong>
374
<p>
375
After migration completes, your account on this PDS will be deactivated.
···
431
<h2>Verify Migration</h2>
432
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
433
434
+
<div class="migration-info-box">
435
<p>
436
This code confirms you have access to the account and authorizes updating your identity
437
to point to the new PDS.
···
439
</div>
440
441
<form onsubmit={submitPlcToken}>
442
+
<div class="migration-field">
443
<label for="plc-token">Verification Code</label>
444
<input
445
id="plc-token"
···
508
</div>
509
510
{#if flow.state.progress.blobsFailed.length > 0}
511
+
<div class="migration-warning-box">
512
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
513
These may be images or other media that are no longer available.
514
</div>
···
531
<h2>Migration Error</h2>
532
<p>An error occurred during migration.</p>
533
534
+
<div class="migration-error-box">
535
{flow.state.error}
536
</div>
537
···
543
</div>
544
545
<style>
546
</style>
+5
-1
frontend/src/lib/auth.svelte.ts
+5
-1
frontend/src/lib/auth.svelte.ts
···
444
state.savedAccounts = newState.savedAccounts ?? [];
445
}
446
447
-
export function _testReset() {
448
state.session = null;
449
state.loading = true;
450
state.error = null;
451
state.savedAccounts = [];
452
localStorage.removeItem(STORAGE_KEY);
453
localStorage.removeItem(ACCOUNTS_KEY);
454
}
···
444
state.savedAccounts = newState.savedAccounts ?? [];
445
}
446
447
+
export function _testResetState() {
448
state.session = null;
449
state.loading = true;
450
state.error = null;
451
state.savedAccounts = [];
452
+
}
453
+
454
+
export function _testReset() {
455
+
_testResetState();
456
localStorage.removeItem(STORAGE_KEY);
457
localStorage.removeItem(ACCOUNTS_KEY);
458
}
+1
-1
frontend/src/lib/crypto.ts
+1
-1
frontend/src/lib/crypto.ts
+488
-25
frontend/src/lib/migration/atproto-client.ts
+488
-25
frontend/src/lib/migration/atproto-client.ts
···
1
import type {
2
AccountStatus,
3
BlobRef,
4
CreateAccountParams,
5
DidCredentials,
6
DidDocument,
7
-
MigrationError,
8
PlcOperation,
9
Preferences,
10
ServerDescription,
11
Session,
12
} from "./types";
13
14
function apiLog(
···
28
export class AtprotoClient {
29
private baseUrl: string;
30
private accessToken: string | null = null;
31
32
constructor(pdsUrl: string) {
33
this.baseUrl = pdsUrl.replace(/\/$/, "");
···
41
return this.accessToken;
42
}
43
44
private async xrpc<T>(
45
method: string,
46
options?: {
···
67
url += `?${searchParams}`;
68
}
69
70
-
const headers: Record<string, string> = {};
71
-
const token = authToken ?? this.accessToken;
72
-
if (token) {
73
-
headers["Authorization"] = `Bearer ${token}`;
74
-
}
75
76
-
let requestBody: BodyInit | undefined;
77
-
if (rawBody) {
78
-
headers["Content-Type"] = contentType ?? "application/octet-stream";
79
-
requestBody = rawBody;
80
-
} else if (body) {
81
-
headers["Content-Type"] = "application/json";
82
-
requestBody = JSON.stringify(body);
83
-
} else if (httpMethod === "POST") {
84
-
headers["Content-Type"] = "application/json";
85
}
86
87
-
const res = await fetch(url, {
88
-
method: httpMethod,
89
-
headers,
90
-
body: requestBody,
91
-
});
92
-
93
if (!res.ok) {
94
const err = await res.json().catch(() => ({
95
error: "Unknown",
96
message: res.statusText,
97
}));
98
-
const error = new Error(err.message) as Error & {
99
status: number;
100
error: string;
101
};
102
error.status = res.status;
103
error.error = err.error;
104
throw error;
105
}
106
107
const responseContentType = res.headers.get("content-type") ?? "";
···
231
error: "Unknown",
232
message: res.statusText,
233
}));
234
-
const error = new Error(err.message) as Error & {
235
status: number;
236
error: string;
237
};
···
436
httpMethod: "POST",
437
});
438
}
439
}
440
441
export async function resolveDidDocument(did: string): Promise<DidDocument> {
···
466
export async function resolvePdsUrl(
467
handleOrDid: string,
468
): Promise<{ did: string; pdsUrl: string }> {
469
-
let did: string;
470
471
if (handleOrDid.startsWith("did:")) {
472
did = handleOrDid;
···
515
}
516
}
517
518
const didDoc = await resolveDidDocument(did);
519
520
const pdsService = didDoc.service?.find(
···
529
}
530
531
export function createLocalClient(): AtprotoClient {
532
-
return new AtprotoClient(window.location.origin);
533
}
···
1
import type {
2
AccountStatus,
3
BlobRef,
4
+
CompletePasskeySetupResponse,
5
CreateAccountParams,
6
+
CreatePasskeyAccountParams,
7
DidCredentials,
8
DidDocument,
9
+
OAuthServerMetadata,
10
+
OAuthTokenResponse,
11
+
PasskeyAccountSetup,
12
PlcOperation,
13
Preferences,
14
ServerDescription,
15
Session,
16
+
StartPasskeyRegistrationResponse,
17
} from "./types";
18
19
function apiLog(
···
33
export class AtprotoClient {
34
private baseUrl: string;
35
private accessToken: string | null = null;
36
+
private dpopKeyPair: DPoPKeyPair | null = null;
37
+
private dpopNonce: string | null = null;
38
39
constructor(pdsUrl: string) {
40
this.baseUrl = pdsUrl.replace(/\/$/, "");
···
48
return this.accessToken;
49
}
50
51
+
setDPoPKeyPair(keyPair: DPoPKeyPair | null) {
52
+
this.dpopKeyPair = keyPair;
53
+
}
54
+
55
private async xrpc<T>(
56
method: string,
57
options?: {
···
78
url += `?${searchParams}`;
79
}
80
81
+
const makeRequest = async (nonce?: string): Promise<Response> => {
82
+
const headers: Record<string, string> = {};
83
+
const token = authToken ?? this.accessToken;
84
+
if (token) {
85
+
if (this.dpopKeyPair) {
86
+
headers["Authorization"] = `DPoP ${token}`;
87
+
const tokenHash = await computeAccessTokenHash(token);
88
+
const dpopProof = await createDPoPProof(
89
+
this.dpopKeyPair,
90
+
httpMethod,
91
+
url.split("?")[0],
92
+
nonce,
93
+
tokenHash,
94
+
);
95
+
headers["DPoP"] = dpopProof;
96
+
} else {
97
+
headers["Authorization"] = `Bearer ${token}`;
98
+
}
99
+
}
100
+
101
+
let requestBody: BodyInit | undefined;
102
+
if (rawBody) {
103
+
headers["Content-Type"] = contentType ?? "application/octet-stream";
104
+
requestBody = rawBody;
105
+
} else if (body) {
106
+
headers["Content-Type"] = "application/json";
107
+
requestBody = JSON.stringify(body);
108
+
} else if (httpMethod === "POST") {
109
+
headers["Content-Type"] = "application/json";
110
+
}
111
112
+
return fetch(url, {
113
+
method: httpMethod,
114
+
headers,
115
+
body: requestBody,
116
+
});
117
+
};
118
+
119
+
let res = await makeRequest(this.dpopNonce ?? undefined);
120
+
121
+
if (!res.ok && this.dpopKeyPair) {
122
+
const dpopNonce = res.headers.get("DPoP-Nonce");
123
+
if (dpopNonce && dpopNonce !== this.dpopNonce) {
124
+
this.dpopNonce = dpopNonce;
125
+
res = await makeRequest(dpopNonce);
126
+
}
127
}
128
129
if (!res.ok) {
130
const err = await res.json().catch(() => ({
131
error: "Unknown",
132
message: res.statusText,
133
}));
134
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
135
status: number;
136
error: string;
137
};
138
error.status = res.status;
139
error.error = err.error;
140
throw error;
141
+
}
142
+
143
+
const newNonce = res.headers.get("DPoP-Nonce");
144
+
if (newNonce) {
145
+
this.dpopNonce = newNonce;
146
}
147
148
const responseContentType = res.headers.get("content-type") ?? "";
···
272
error: "Unknown",
273
message: res.statusText,
274
}));
275
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
276
status: number;
277
error: string;
278
};
···
477
httpMethod: "POST",
478
});
479
}
480
+
481
+
async createPasskeyAccount(
482
+
params: CreatePasskeyAccountParams,
483
+
serviceToken?: string,
484
+
): Promise<PasskeyAccountSetup> {
485
+
const headers: Record<string, string> = {
486
+
"Content-Type": "application/json",
487
+
};
488
+
if (serviceToken) {
489
+
headers["Authorization"] = `Bearer ${serviceToken}`;
490
+
}
491
+
492
+
const res = await fetch(
493
+
`${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
494
+
{
495
+
method: "POST",
496
+
headers,
497
+
body: JSON.stringify(params),
498
+
},
499
+
);
500
+
501
+
if (!res.ok) {
502
+
const err = await res.json().catch(() => ({
503
+
error: "Unknown",
504
+
message: res.statusText,
505
+
}));
506
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
507
+
status: number;
508
+
error: string;
509
+
};
510
+
error.status = res.status;
511
+
error.error = err.error;
512
+
throw error;
513
+
}
514
+
515
+
return res.json();
516
+
}
517
+
518
+
async startPasskeyRegistrationForSetup(
519
+
did: string,
520
+
setupToken: string,
521
+
friendlyName?: string,
522
+
): Promise<StartPasskeyRegistrationResponse> {
523
+
return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
524
+
httpMethod: "POST",
525
+
body: { did, setupToken, friendlyName },
526
+
});
527
+
}
528
+
529
+
async completePasskeySetup(
530
+
did: string,
531
+
setupToken: string,
532
+
passkeyCredential: unknown,
533
+
passkeyFriendlyName?: string,
534
+
): Promise<CompletePasskeySetupResponse> {
535
+
return this.xrpc("com.tranquil.account.completePasskeySetup", {
536
+
httpMethod: "POST",
537
+
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
538
+
});
539
+
}
540
+
}
541
+
542
+
export async function getOAuthServerMetadata(
543
+
pdsUrl: string,
544
+
): Promise<OAuthServerMetadata | null> {
545
+
try {
546
+
const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
547
+
const directRes = await fetch(directUrl);
548
+
if (directRes.ok) {
549
+
return directRes.json();
550
+
}
551
+
552
+
const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`;
553
+
const protectedRes = await fetch(protectedResourceUrl);
554
+
if (!protectedRes.ok) {
555
+
return null;
556
+
}
557
+
558
+
const protectedMetadata = await protectedRes.json();
559
+
const authServers = protectedMetadata.authorization_servers;
560
+
if (!authServers || authServers.length === 0) {
561
+
return null;
562
+
}
563
+
564
+
const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`;
565
+
const authServerRes = await fetch(authServerUrl);
566
+
if (!authServerRes.ok) {
567
+
return null;
568
+
}
569
+
570
+
return authServerRes.json();
571
+
} catch {
572
+
return null;
573
+
}
574
+
}
575
+
576
+
export async function generatePKCE(): Promise<{
577
+
codeVerifier: string;
578
+
codeChallenge: string;
579
+
}> {
580
+
const array = new Uint8Array(32);
581
+
crypto.getRandomValues(array);
582
+
const codeVerifier = base64UrlEncode(array);
583
+
584
+
const encoder = new TextEncoder();
585
+
const data = encoder.encode(codeVerifier);
586
+
const digest = await crypto.subtle.digest("SHA-256", data);
587
+
const codeChallenge = base64UrlEncode(new Uint8Array(digest));
588
+
589
+
return { codeVerifier, codeChallenge };
590
+
}
591
+
592
+
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
593
+
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
594
+
let binary = "";
595
+
for (let i = 0; i < bytes.length; i++) {
596
+
binary += String.fromCharCode(bytes[i]);
597
+
}
598
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
599
+
}
600
+
601
+
export function base64UrlDecode(base64url: string): Uint8Array {
602
+
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
603
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
604
+
const binary = atob(padded);
605
+
const bytes = new Uint8Array(binary.length);
606
+
for (let i = 0; i < binary.length; i++) {
607
+
bytes[i] = binary.charCodeAt(i);
608
+
}
609
+
return bytes;
610
+
}
611
+
612
+
export function prepareWebAuthnCreationOptions(
613
+
options: { publicKey: Record<string, unknown> },
614
+
): PublicKeyCredentialCreationOptions {
615
+
const pk = options.publicKey;
616
+
return {
617
+
...pk,
618
+
challenge: base64UrlDecode(pk.challenge as string),
619
+
user: {
620
+
...(pk.user as Record<string, unknown>),
621
+
id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
622
+
},
623
+
excludeCredentials:
624
+
((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
625
+
(cred) => ({
626
+
...cred,
627
+
id: base64UrlDecode(cred.id as string),
628
+
}),
629
+
),
630
+
} as PublicKeyCredentialCreationOptions;
631
+
}
632
+
633
+
async function computeAccessTokenHash(accessToken: string): Promise<string> {
634
+
const encoder = new TextEncoder();
635
+
const data = encoder.encode(accessToken);
636
+
const hash = await crypto.subtle.digest("SHA-256", data);
637
+
return base64UrlEncode(new Uint8Array(hash));
638
+
}
639
+
640
+
export function generateOAuthState(): string {
641
+
const array = new Uint8Array(16);
642
+
crypto.getRandomValues(array);
643
+
return base64UrlEncode(array);
644
+
}
645
+
646
+
export function buildOAuthAuthorizationUrl(
647
+
metadata: OAuthServerMetadata,
648
+
params: {
649
+
clientId: string;
650
+
redirectUri: string;
651
+
codeChallenge: string;
652
+
state: string;
653
+
scope?: string;
654
+
dpopJkt?: string;
655
+
loginHint?: string;
656
+
},
657
+
): string {
658
+
const url = new URL(metadata.authorization_endpoint);
659
+
url.searchParams.set("response_type", "code");
660
+
url.searchParams.set("client_id", params.clientId);
661
+
url.searchParams.set("redirect_uri", params.redirectUri);
662
+
url.searchParams.set("code_challenge", params.codeChallenge);
663
+
url.searchParams.set("code_challenge_method", "S256");
664
+
url.searchParams.set("state", params.state);
665
+
url.searchParams.set("scope", params.scope ?? "atproto");
666
+
if (params.dpopJkt) {
667
+
url.searchParams.set("dpop_jkt", params.dpopJkt);
668
+
}
669
+
if (params.loginHint) {
670
+
url.searchParams.set("login_hint", params.loginHint);
671
+
}
672
+
return url.toString();
673
+
}
674
+
675
+
export async function exchangeOAuthCode(
676
+
metadata: OAuthServerMetadata,
677
+
params: {
678
+
code: string;
679
+
codeVerifier: string;
680
+
clientId: string;
681
+
redirectUri: string;
682
+
dpopKeyPair?: DPoPKeyPair;
683
+
},
684
+
): Promise<OAuthTokenResponse> {
685
+
const body = new URLSearchParams({
686
+
grant_type: "authorization_code",
687
+
code: params.code,
688
+
code_verifier: params.codeVerifier,
689
+
client_id: params.clientId,
690
+
redirect_uri: params.redirectUri,
691
+
});
692
+
693
+
const makeRequest = async (nonce?: string): Promise<Response> => {
694
+
const headers: Record<string, string> = {
695
+
"Content-Type": "application/x-www-form-urlencoded",
696
+
};
697
+
698
+
if (params.dpopKeyPair) {
699
+
const dpopProof = await createDPoPProof(
700
+
params.dpopKeyPair,
701
+
"POST",
702
+
metadata.token_endpoint,
703
+
nonce,
704
+
);
705
+
headers["DPoP"] = dpopProof;
706
+
}
707
+
708
+
return fetch(metadata.token_endpoint, {
709
+
method: "POST",
710
+
headers,
711
+
body: body.toString(),
712
+
});
713
+
};
714
+
715
+
let res = await makeRequest();
716
+
717
+
if (!res.ok) {
718
+
const err = await res.json().catch(() => ({
719
+
error: "token_error",
720
+
error_description: res.statusText,
721
+
}));
722
+
723
+
if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
724
+
const dpopNonce = res.headers.get("DPoP-Nonce");
725
+
if (dpopNonce) {
726
+
res = await makeRequest(dpopNonce);
727
+
if (!res.ok) {
728
+
const retryErr = await res.json().catch(() => ({
729
+
error: "token_error",
730
+
error_description: res.statusText,
731
+
}));
732
+
throw new Error(
733
+
retryErr.error_description || retryErr.error || "Token exchange failed",
734
+
);
735
+
}
736
+
return res.json();
737
+
}
738
+
}
739
+
740
+
throw new Error(err.error_description || err.error || "Token exchange failed");
741
+
}
742
+
743
+
return res.json();
744
}
745
746
export async function resolveDidDocument(did: string): Promise<DidDocument> {
···
771
export async function resolvePdsUrl(
772
handleOrDid: string,
773
): Promise<{ did: string; pdsUrl: string }> {
774
+
let did: string | undefined;
775
776
if (handleOrDid.startsWith("did:")) {
777
did = handleOrDid;
···
820
}
821
}
822
823
+
if (!did) {
824
+
throw new Error("Could not resolve DID");
825
+
}
826
+
827
const didDoc = await resolveDidDocument(did);
828
829
const pdsService = didDoc.service?.find(
···
838
}
839
840
export function createLocalClient(): AtprotoClient {
841
+
return new AtprotoClient(globalThis.location.origin);
842
+
}
843
+
844
+
export function getMigrationOAuthClientId(): string {
845
+
return `${globalThis.location.origin}/oauth/client-metadata.json`;
846
+
}
847
+
848
+
export function getMigrationOAuthRedirectUri(): string {
849
+
return `${globalThis.location.origin}/migrate`;
850
+
}
851
+
852
+
export interface DPoPKeyPair {
853
+
privateKey: CryptoKey;
854
+
publicKey: CryptoKey;
855
+
jwk: JsonWebKey;
856
+
thumbprint: string;
857
+
}
858
+
859
+
const DPOP_KEY_STORAGE = "migration_dpop_key";
860
+
const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
861
+
862
+
export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
863
+
const keyPair = await crypto.subtle.generateKey(
864
+
{
865
+
name: "ECDSA",
866
+
namedCurve: "P-256",
867
+
},
868
+
true,
869
+
["sign", "verify"],
870
+
);
871
+
872
+
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
873
+
const thumbprint = await computeJwkThumbprint(publicJwk);
874
+
875
+
return {
876
+
privateKey: keyPair.privateKey,
877
+
publicKey: keyPair.publicKey,
878
+
jwk: publicJwk,
879
+
thumbprint,
880
+
};
881
+
}
882
+
883
+
async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
884
+
const thumbprintInput = JSON.stringify({
885
+
crv: jwk.crv,
886
+
kty: jwk.kty,
887
+
x: jwk.x,
888
+
y: jwk.y,
889
+
});
890
+
891
+
const encoder = new TextEncoder();
892
+
const data = encoder.encode(thumbprintInput);
893
+
const hash = await crypto.subtle.digest("SHA-256", data);
894
+
return base64UrlEncode(new Uint8Array(hash));
895
+
}
896
+
897
+
export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
898
+
const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
899
+
const stored = {
900
+
privateJwk,
901
+
publicJwk: keyPair.jwk,
902
+
thumbprint: keyPair.thumbprint,
903
+
createdAt: Date.now(),
904
+
};
905
+
localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
906
+
}
907
+
908
+
export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
909
+
const stored = localStorage.getItem(DPOP_KEY_STORAGE);
910
+
if (!stored) return null;
911
+
912
+
try {
913
+
const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
914
+
915
+
if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
916
+
localStorage.removeItem(DPOP_KEY_STORAGE);
917
+
return null;
918
+
}
919
+
920
+
const privateKey = await crypto.subtle.importKey(
921
+
"jwk",
922
+
privateJwk,
923
+
{ name: "ECDSA", namedCurve: "P-256" },
924
+
true,
925
+
["sign"],
926
+
);
927
+
928
+
const publicKey = await crypto.subtle.importKey(
929
+
"jwk",
930
+
publicJwk,
931
+
{ name: "ECDSA", namedCurve: "P-256" },
932
+
true,
933
+
["verify"],
934
+
);
935
+
936
+
return { privateKey, publicKey, jwk: publicJwk, thumbprint };
937
+
} catch {
938
+
localStorage.removeItem(DPOP_KEY_STORAGE);
939
+
return null;
940
+
}
941
+
}
942
+
943
+
export function clearDPoPKey(): void {
944
+
localStorage.removeItem(DPOP_KEY_STORAGE);
945
+
}
946
+
947
+
export async function createDPoPProof(
948
+
keyPair: DPoPKeyPair,
949
+
httpMethod: string,
950
+
httpUri: string,
951
+
nonce?: string,
952
+
accessTokenHash?: string,
953
+
): Promise<string> {
954
+
const header = {
955
+
typ: "dpop+jwt",
956
+
alg: "ES256",
957
+
jwk: {
958
+
kty: keyPair.jwk.kty,
959
+
crv: keyPair.jwk.crv,
960
+
x: keyPair.jwk.x,
961
+
y: keyPair.jwk.y,
962
+
},
963
+
};
964
+
965
+
const payload: Record<string, unknown> = {
966
+
jti: crypto.randomUUID(),
967
+
htm: httpMethod,
968
+
htu: httpUri,
969
+
iat: Math.floor(Date.now() / 1000),
970
+
};
971
+
972
+
if (nonce) {
973
+
payload.nonce = nonce;
974
+
}
975
+
976
+
if (accessTokenHash) {
977
+
payload.ath = accessTokenHash;
978
+
}
979
+
980
+
const headerB64 = base64UrlEncode(
981
+
new TextEncoder().encode(JSON.stringify(header)),
982
+
);
983
+
const payloadB64 = base64UrlEncode(
984
+
new TextEncoder().encode(JSON.stringify(payload)),
985
+
);
986
+
987
+
const signingInput = `${headerB64}.${payloadB64}`;
988
+
const signature = await crypto.subtle.sign(
989
+
{ name: "ECDSA", hash: "SHA-256" },
990
+
keyPair.privateKey,
991
+
new TextEncoder().encode(signingInput),
992
+
);
993
+
994
+
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
995
+
return `${headerB64}.${payloadB64}.${signatureB64}`;
996
}
+350
-78
frontend/src/lib/migration/flow.svelte.ts
+350
-78
frontend/src/lib/migration/flow.svelte.ts
···
2
InboundMigrationState,
3
InboundStep,
4
MigrationProgress,
5
OutboundMigrationState,
6
OutboundStep,
7
ServerDescription,
8
StoredMigrationState,
9
} from "./types";
10
import {
11
AtprotoClient,
12
createLocalClient,
13
resolvePdsUrl,
14
} from "./atproto-client";
15
import {
16
clearMigrationState,
17
-
loadMigrationState,
18
saveMigrationState,
19
updateProgress,
20
updateStep,
···
63
plcToken: "",
64
progress: createInitialProgress(),
65
error: null,
66
-
requires2FA: false,
67
-
twoFactorCode: "",
68
targetVerificationMethod: null,
69
});
70
71
let sourceClient: AtprotoClient | null = null;
72
let localClient: AtprotoClient | null = null;
73
let localServerInfo: ServerDescription | null = null;
74
75
function setStep(step: InboundStep) {
76
state.step = step;
···
111
}
112
}
113
114
-
async function loginToSource(
115
-
handle: string,
116
-
password: string,
117
-
twoFactorCode?: string,
118
-
): Promise<void> {
119
-
migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode });
120
121
if (!state.sourcePdsUrl) {
122
await resolveSourcePds(handle);
123
}
124
125
-
if (!sourceClient) {
126
-
sourceClient = new AtprotoClient(state.sourcePdsUrl);
127
}
128
129
try {
130
-
migrationLog("loginToSource: Calling createSession on OLD PDS", {
131
-
pdsUrl: state.sourcePdsUrl,
132
});
133
-
const session = await sourceClient.login(handle, password, twoFactorCode);
134
-
migrationLog("loginToSource SUCCESS", {
135
-
did: session.did,
136
-
handle: session.handle,
137
-
pdsUrl: state.sourcePdsUrl,
138
-
});
139
-
state.sourceAccessToken = session.accessJwt;
140
-
state.sourceRefreshToken = session.refreshJwt;
141
-
state.sourceDid = session.did;
142
-
state.sourceHandle = session.handle;
143
-
state.requires2FA = false;
144
-
saveMigrationState(state);
145
-
} catch (e) {
146
-
const err = e as Error & { error?: string };
147
-
migrationLog("loginToSource FAILED", {
148
-
error: err.message,
149
-
errorCode: err.error,
150
-
});
151
-
if (err.error === "AuthFactorTokenRequired") {
152
-
state.requires2FA = true;
153
-
throw new Error(
154
-
"Two-factor authentication required. Please enter the code sent to your email.",
155
-
);
156
}
157
-
throw e;
158
}
159
}
160
161
async function checkHandleAvailability(handle: string): Promise<boolean> {
···
180
await localClient.loginDeactivated(email, password);
181
}
182
183
async function startMigration(): Promise<void> {
184
migrationLog("startMigration START", {
185
sourceDid: state.sourceDid,
186
sourceHandle: state.sourceHandle,
187
targetHandle: state.targetHandle,
188
sourcePdsUrl: state.sourcePdsUrl,
189
});
190
191
if (!sourceClient || !state.sourceAccessToken) {
192
-
migrationLog("startMigration ERROR: Not logged in to source PDS");
193
-
throw new Error("Not logged in to source PDS");
194
}
195
196
if (!localClient) {
···
198
}
199
200
setStep("migrating");
201
-
setProgress({ currentOperation: "Getting service auth token..." });
202
203
try {
204
migrationLog("startMigration: Loading local server info");
205
const serverInfo = await loadLocalServerInfo();
206
migrationLog("startMigration: Got server info", {
207
serverDid: serverInfo.did,
208
});
209
210
-
migrationLog("startMigration: Getting service auth token from OLD PDS");
211
const { token } = await sourceClient.getServiceAuth(
212
serverInfo.did,
213
"com.atproto.server.createAccount",
···
217
218
setProgress({ currentOperation: "Creating account on new PDS..." });
219
220
-
const accountParams = {
221
-
did: state.sourceDid,
222
-
handle: state.targetHandle,
223
-
email: state.targetEmail,
224
-
password: state.targetPassword,
225
-
inviteCode: state.inviteCode || undefined,
226
-
};
227
228
-
migrationLog("startMigration: Creating account on NEW PDS", {
229
-
did: accountParams.did,
230
-
handle: accountParams.handle,
231
-
});
232
-
const session = await localClient.createAccount(accountParams, token);
233
-
migrationLog("startMigration: Account created on NEW PDS", {
234
-
did: session.did,
235
-
});
236
-
localClient.setAccessToken(session.accessJwt);
237
238
setProgress({ currentOperation: "Exporting repository..." });
239
-
migrationLog("startMigration: Exporting repo from OLD PDS");
240
const exportStart = Date.now();
241
const car = await sourceClient.getRepo(state.sourceDid);
242
migrationLog("startMigration: Repo exported", {
···
320
await localClient.uploadBlob(blobData, "application/octet-stream");
321
migrated++;
322
setProgress({ blobsMigrated: migrated });
323
-
} catch (e) {
324
state.progress.blobsFailed.push(blob.cid);
325
}
326
}
···
336
const prefs = await sourceClient.getPreferences();
337
await localClient.putPreferences(prefs);
338
setProgress({ prefsMigrated: true });
339
-
} catch {
340
-
}
341
}
342
343
async function submitEmailVerifyToken(
···
355
await localClient.verifyToken(token, state.targetEmail);
356
357
if (!sourceClient) {
358
-
setStep("source-login");
359
setError(
360
"Email verified! Please log in to your old account again to complete the migration.",
361
);
362
return;
363
}
364
365
if (localPassword) {
366
setProgress({ currentOperation: "Authenticating to new PDS..." });
367
await localClient.loginDeactivated(state.targetEmail, localPassword);
···
403
if (checkingEmailVerification) return false;
404
if (!sourceClient || !localClient) return false;
405
406
checkingEmailVerification = true;
407
try {
408
await localClient.loginDeactivated(
···
460
services: credentials.services,
461
});
462
463
-
migrationLog("Step 2: Signing PLC operation on OLD PDS", {
464
sourcePdsUrl: state.sourcePdsUrl,
465
});
466
const signStart = Date.now();
···
497
setProgress({ activated: true });
498
499
setProgress({ currentOperation: "Deactivating old account..." });
500
-
migrationLog("Step 5: Deactivating account on OLD PDS", {
501
sourcePdsUrl: state.sourcePdsUrl,
502
});
503
const deactivateStart = Date.now();
504
try {
505
await sourceClient.deactivateAccount();
506
-
migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", {
507
durationMs: Date.now() - deactivateStart,
508
success: true,
509
});
···
513
error?: string;
514
status?: number;
515
};
516
-
migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", {
517
durationMs: Date.now() - deactivateStart,
518
error: err.message,
519
errorCode: err.error,
···
581
setProgress({ activated: true });
582
583
setProgress({ currentOperation: "Deactivating old account..." });
584
-
migrationLog("Deactivating account on OLD PDS");
585
const deactivateStart = Date.now();
586
try {
587
await sourceClient.deactivateAccount();
588
-
migrationLog("Account deactivated on OLD PDS", {
589
durationMs: Date.now() - deactivateStart,
590
});
591
setProgress({ deactivated: true });
592
} catch (deactivateErr) {
593
const err = deactivateErr as Error & { error?: string };
594
-
migrationLog("Could not deactivate on OLD PDS", { error: err.message });
595
}
596
597
migrationLog("completeDidWebMigration SUCCESS");
···
607
}
608
}
609
610
function reset(): void {
611
state = {
612
direction: "inbound",
···
625
plcToken: "",
626
progress: createInitialProgress(),
627
error: null,
628
-
requires2FA: false,
629
-
twoFactorCode: "",
630
targetVerificationMethod: null,
631
};
632
sourceClient = null;
633
clearMigrationState();
634
}
635
636
async function resumeFromState(stored: StoredMigrationState): Promise<void> {
···
641
state.sourceHandle = stored.sourceHandle;
642
state.targetHandle = stored.targetHandle;
643
state.targetEmail = stored.targetEmail;
644
state.progress = {
645
...createInitialProgress(),
646
...stored.progress,
647
};
648
649
-
state.step = "source-login";
650
}
651
652
function getLocalSession():
···
666
get state() {
667
return state;
668
},
669
setStep,
670
setError,
671
loadLocalServerInfo,
672
-
loginToSource,
673
authenticateToLocal,
674
checkHandleAvailability,
675
startMigration,
···
680
submitPlcToken,
681
resendPlcToken,
682
completeDidWebMigration,
683
reset,
684
resumeFromState,
685
getLocalSession,
···
856
await targetClient.uploadBlob(blobData, "application/octet-stream");
857
migrated++;
858
setProgress({ blobsMigrated: migrated });
859
-
} catch (e) {
860
state.progress.blobsFailed.push(blob.cid);
861
}
862
}
···
872
const prefs = await localClient.getPreferences();
873
await targetClient.putPreferences(prefs);
874
setProgress({ prefsMigrated: true });
875
-
} catch {
876
-
}
877
}
878
879
async function submitPlcToken(token: string): Promise<void> {
···
908
try {
909
await localClient.deactivateAccount(state.targetPdsUrl);
910
setProgress({ deactivated: true });
911
-
} catch {
912
-
}
913
914
setStep("success");
915
clearMigrationState();
···
2
InboundMigrationState,
3
InboundStep,
4
MigrationProgress,
5
+
OAuthServerMetadata,
6
OutboundMigrationState,
7
OutboundStep,
8
+
PasskeyAccountSetup,
9
ServerDescription,
10
StoredMigrationState,
11
} from "./types";
12
import {
13
AtprotoClient,
14
+
buildOAuthAuthorizationUrl,
15
+
clearDPoPKey,
16
createLocalClient,
17
+
exchangeOAuthCode,
18
+
generateDPoPKeyPair,
19
+
generateOAuthState,
20
+
generatePKCE,
21
+
getMigrationOAuthClientId,
22
+
getMigrationOAuthRedirectUri,
23
+
getOAuthServerMetadata,
24
+
loadDPoPKey,
25
resolvePdsUrl,
26
+
saveDPoPKey,
27
} from "./atproto-client";
28
import {
29
clearMigrationState,
30
saveMigrationState,
31
updateProgress,
32
updateStep,
···
75
plcToken: "",
76
progress: createInitialProgress(),
77
error: null,
78
targetVerificationMethod: null,
79
+
authMethod: "password",
80
+
passkeySetupToken: null,
81
+
oauthCodeVerifier: null,
82
+
generatedAppPassword: null,
83
+
generatedAppPasswordName: null,
84
});
85
86
let sourceClient: AtprotoClient | null = null;
87
let localClient: AtprotoClient | null = null;
88
let localServerInfo: ServerDescription | null = null;
89
+
let sourceOAuthMetadata: OAuthServerMetadata | null = null;
90
91
function setStep(step: InboundStep) {
92
state.step = step;
···
127
}
128
}
129
130
+
async function initiateOAuthLogin(handle: string): Promise<void> {
131
+
migrationLog("initiateOAuthLogin START", { handle });
132
133
if (!state.sourcePdsUrl) {
134
await resolveSourcePds(handle);
135
}
136
137
+
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
138
+
if (!metadata) {
139
+
throw new Error(
140
+
"Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.",
141
+
);
142
+
}
143
+
sourceOAuthMetadata = metadata;
144
+
145
+
const { codeVerifier, codeChallenge } = await generatePKCE();
146
+
const oauthState = generateOAuthState();
147
+
148
+
const dpopKeyPair = await generateDPoPKeyPair();
149
+
await saveDPoPKey(dpopKeyPair);
150
+
151
+
localStorage.setItem("migration_oauth_state", oauthState);
152
+
localStorage.setItem("migration_oauth_code_verifier", codeVerifier);
153
+
localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl);
154
+
localStorage.setItem("migration_source_did", state.sourceDid);
155
+
localStorage.setItem("migration_source_handle", state.sourceHandle);
156
+
localStorage.setItem("migration_oauth_issuer", metadata.issuer);
157
+
158
+
const authUrl = buildOAuthAuthorizationUrl(metadata, {
159
+
clientId: getMigrationOAuthClientId(),
160
+
redirectUri: getMigrationOAuthRedirectUri(),
161
+
codeChallenge,
162
+
state: oauthState,
163
+
scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*",
164
+
dpopJkt: dpopKeyPair.thumbprint,
165
+
loginHint: state.sourceHandle,
166
+
});
167
+
168
+
migrationLog("initiateOAuthLogin: Redirecting to authorization", {
169
+
sourcePdsUrl: state.sourcePdsUrl,
170
+
authEndpoint: metadata.authorization_endpoint,
171
+
dpopJkt: dpopKeyPair.thumbprint,
172
+
});
173
+
174
+
state.oauthCodeVerifier = codeVerifier;
175
+
saveMigrationState(state);
176
+
177
+
globalThis.location.href = authUrl;
178
+
}
179
+
180
+
function cleanupOAuthSessionData(): void {
181
+
localStorage.removeItem("migration_oauth_state");
182
+
localStorage.removeItem("migration_oauth_code_verifier");
183
+
localStorage.removeItem("migration_source_pds_url");
184
+
localStorage.removeItem("migration_source_did");
185
+
localStorage.removeItem("migration_source_handle");
186
+
localStorage.removeItem("migration_oauth_issuer");
187
+
}
188
+
189
+
async function handleOAuthCallback(
190
+
code: string,
191
+
returnedState: string,
192
+
): Promise<void> {
193
+
migrationLog("handleOAuthCallback START");
194
+
195
+
const savedState = localStorage.getItem("migration_oauth_state");
196
+
const codeVerifier = localStorage.getItem("migration_oauth_code_verifier");
197
+
const sourcePdsUrl = localStorage.getItem("migration_source_pds_url");
198
+
const sourceDid = localStorage.getItem("migration_source_did");
199
+
const sourceHandle = localStorage.getItem("migration_source_handle");
200
+
const oauthIssuer = localStorage.getItem("migration_oauth_issuer");
201
+
202
+
if (returnedState !== savedState) {
203
+
cleanupOAuthSessionData();
204
+
throw new Error("OAuth state mismatch - possible CSRF attack");
205
}
206
207
+
if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) {
208
+
cleanupOAuthSessionData();
209
+
throw new Error("Missing OAuth session data");
210
+
}
211
+
212
+
const dpopKeyPair = await loadDPoPKey();
213
+
if (!dpopKeyPair) {
214
+
cleanupOAuthSessionData();
215
+
throw new Error("Missing DPoP key - please restart the migration");
216
+
}
217
+
218
+
state.sourcePdsUrl = sourcePdsUrl;
219
+
state.sourceDid = sourceDid;
220
+
state.sourceHandle = sourceHandle;
221
+
sourceClient = new AtprotoClient(sourcePdsUrl);
222
+
223
+
let metadata = await getOAuthServerMetadata(sourcePdsUrl);
224
+
if (!metadata && oauthIssuer) {
225
+
metadata = await getOAuthServerMetadata(oauthIssuer);
226
+
}
227
+
if (!metadata) {
228
+
cleanupOAuthSessionData();
229
+
throw new Error("Could not fetch OAuth server metadata");
230
+
}
231
+
sourceOAuthMetadata = metadata;
232
+
233
+
migrationLog("handleOAuthCallback: Exchanging code for tokens");
234
+
235
+
let tokenResponse;
236
try {
237
+
tokenResponse = await exchangeOAuthCode(metadata, {
238
+
code,
239
+
codeVerifier,
240
+
clientId: getMigrationOAuthClientId(),
241
+
redirectUri: getMigrationOAuthRedirectUri(),
242
+
dpopKeyPair,
243
});
244
+
} catch (err) {
245
+
cleanupOAuthSessionData();
246
+
throw err;
247
+
}
248
+
249
+
migrationLog("handleOAuthCallback: Got access token");
250
+
251
+
state.sourceAccessToken = tokenResponse.access_token;
252
+
state.sourceRefreshToken = tokenResponse.refresh_token ?? null;
253
+
sourceClient.setAccessToken(tokenResponse.access_token);
254
+
sourceClient.setDPoPKeyPair(dpopKeyPair);
255
+
256
+
cleanupOAuthSessionData();
257
+
258
+
if (state.needsReauth && state.resumeToStep) {
259
+
const targetStep = state.resumeToStep;
260
+
state.needsReauth = false;
261
+
state.resumeToStep = undefined;
262
+
263
+
const postEmailSteps = [
264
+
"plc-token",
265
+
"did-web-update",
266
+
"finalizing",
267
+
"app-password",
268
+
];
269
+
270
+
if (postEmailSteps.includes(targetStep)) {
271
+
if (state.authMethod === "passkey" && state.passkeySetupToken) {
272
+
localClient = createLocalClient();
273
+
setStep("passkey-setup");
274
+
migrationLog("handleOAuthCallback: Resuming passkey flow at passkey-setup");
275
+
} else {
276
+
setStep("email-verify");
277
+
migrationLog("handleOAuthCallback: Resuming at email-verify for re-auth");
278
+
}
279
+
} else {
280
+
setStep(targetStep);
281
}
282
+
} else {
283
+
setStep("choose-handle");
284
}
285
+
saveMigrationState(state);
286
}
287
288
async function checkHandleAvailability(handle: string): Promise<boolean> {
···
307
await localClient.loginDeactivated(email, password);
308
}
309
310
+
let passkeySetup: PasskeyAccountSetup | null = null;
311
+
312
async function startMigration(): Promise<void> {
313
migrationLog("startMigration START", {
314
sourceDid: state.sourceDid,
315
sourceHandle: state.sourceHandle,
316
targetHandle: state.targetHandle,
317
sourcePdsUrl: state.sourcePdsUrl,
318
+
authMethod: state.authMethod,
319
});
320
321
if (!sourceClient || !state.sourceAccessToken) {
322
+
migrationLog("startMigration ERROR: Not authenticated to source PDS");
323
+
throw new Error("Not authenticated to source PDS");
324
}
325
326
if (!localClient) {
···
328
}
329
330
setStep("migrating");
331
332
try {
333
+
setProgress({ currentOperation: "Getting service auth token..." });
334
migrationLog("startMigration: Loading local server info");
335
const serverInfo = await loadLocalServerInfo();
336
migrationLog("startMigration: Got server info", {
337
serverDid: serverInfo.did,
338
});
339
340
+
migrationLog("startMigration: Getting service auth token from source PDS");
341
const { token } = await sourceClient.getServiceAuth(
342
serverInfo.did,
343
"com.atproto.server.createAccount",
···
347
348
setProgress({ currentOperation: "Creating account on new PDS..." });
349
350
+
if (state.authMethod === "passkey") {
351
+
const passkeyParams = {
352
+
did: state.sourceDid,
353
+
handle: state.targetHandle,
354
+
email: state.targetEmail,
355
+
inviteCode: state.inviteCode || undefined,
356
+
};
357
358
+
migrationLog("startMigration: Creating passkey account on NEW PDS", {
359
+
did: passkeyParams.did,
360
+
handle: passkeyParams.handle,
361
+
inviteCode: passkeyParams.inviteCode,
362
+
stateInviteCode: state.inviteCode,
363
+
});
364
+
passkeySetup = await localClient.createPasskeyAccount(passkeyParams, token);
365
+
migrationLog("startMigration: Passkey account created on NEW PDS", {
366
+
did: passkeySetup.did,
367
+
hasAccessJwt: !!passkeySetup.accessJwt,
368
+
});
369
+
state.passkeySetupToken = passkeySetup.setupToken;
370
+
if (passkeySetup.accessJwt) {
371
+
localClient.setAccessToken(passkeySetup.accessJwt);
372
+
}
373
+
} else {
374
+
const accountParams = {
375
+
did: state.sourceDid,
376
+
handle: state.targetHandle,
377
+
email: state.targetEmail,
378
+
password: state.targetPassword,
379
+
inviteCode: state.inviteCode || undefined,
380
+
};
381
+
382
+
migrationLog("startMigration: Creating account on NEW PDS", {
383
+
did: accountParams.did,
384
+
handle: accountParams.handle,
385
+
});
386
+
const session = await localClient.createAccount(accountParams, token);
387
+
migrationLog("startMigration: Account created on NEW PDS", {
388
+
did: session.did,
389
+
});
390
+
localClient.setAccessToken(session.accessJwt);
391
+
}
392
393
setProgress({ currentOperation: "Exporting repository..." });
394
+
migrationLog("startMigration: Exporting repo from source PDS");
395
const exportStart = Date.now();
396
const car = await sourceClient.getRepo(state.sourceDid);
397
migrationLog("startMigration: Repo exported", {
···
475
await localClient.uploadBlob(blobData, "application/octet-stream");
476
migrated++;
477
setProgress({ blobsMigrated: migrated });
478
+
} catch {
479
state.progress.blobsFailed.push(blob.cid);
480
}
481
}
···
491
const prefs = await sourceClient.getPreferences();
492
await localClient.putPreferences(prefs);
493
setProgress({ prefsMigrated: true });
494
+
} catch { /* optional, best-effort */ }
495
}
496
497
async function submitEmailVerifyToken(
···
509
await localClient.verifyToken(token, state.targetEmail);
510
511
if (!sourceClient) {
512
+
setStep("source-handle");
513
setError(
514
"Email verified! Please log in to your old account again to complete the migration.",
515
);
516
return;
517
}
518
519
+
if (state.authMethod === "passkey") {
520
+
migrationLog(
521
+
"submitEmailVerifyToken: Email verified, proceeding to passkey setup",
522
+
);
523
+
setStep("passkey-setup");
524
+
return;
525
+
}
526
+
527
if (localPassword) {
528
setProgress({ currentOperation: "Authenticating to new PDS..." });
529
await localClient.loginDeactivated(state.targetEmail, localPassword);
···
565
if (checkingEmailVerification) return false;
566
if (!sourceClient || !localClient) return false;
567
568
+
if (state.authMethod === "passkey") {
569
+
return false;
570
+
}
571
+
572
checkingEmailVerification = true;
573
try {
574
await localClient.loginDeactivated(
···
626
services: credentials.services,
627
});
628
629
+
migrationLog("Step 2: Signing PLC operation on source PDS", {
630
sourcePdsUrl: state.sourcePdsUrl,
631
});
632
const signStart = Date.now();
···
663
setProgress({ activated: true });
664
665
setProgress({ currentOperation: "Deactivating old account..." });
666
+
migrationLog("Step 5: Deactivating account on source PDS", {
667
sourcePdsUrl: state.sourcePdsUrl,
668
});
669
const deactivateStart = Date.now();
670
try {
671
await sourceClient.deactivateAccount();
672
+
migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", {
673
durationMs: Date.now() - deactivateStart,
674
success: true,
675
});
···
679
error?: string;
680
status?: number;
681
};
682
+
migrationLog("Step 5 FAILED: Could not deactivate on source PDS", {
683
durationMs: Date.now() - deactivateStart,
684
error: err.message,
685
errorCode: err.error,
···
747
setProgress({ activated: true });
748
749
setProgress({ currentOperation: "Deactivating old account..." });
750
+
migrationLog("Deactivating account on source PDS");
751
const deactivateStart = Date.now();
752
try {
753
await sourceClient.deactivateAccount();
754
+
migrationLog("Account deactivated on source PDS", {
755
durationMs: Date.now() - deactivateStart,
756
});
757
setProgress({ deactivated: true });
758
} catch (deactivateErr) {
759
const err = deactivateErr as Error & { error?: string };
760
+
migrationLog("Could not deactivate on source PDS", { error: err.message });
761
}
762
763
migrationLog("completeDidWebMigration SUCCESS");
···
773
}
774
}
775
776
+
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
777
+
if (!localClient || !state.passkeySetupToken) {
778
+
throw new Error("Not ready for passkey registration");
779
+
}
780
+
781
+
migrationLog("startPasskeyRegistration START", { did: state.sourceDid });
782
+
const result = await localClient.startPasskeyRegistrationForSetup(
783
+
state.sourceDid,
784
+
state.passkeySetupToken,
785
+
);
786
+
migrationLog("startPasskeyRegistration: Got WebAuthn options");
787
+
return result;
788
+
}
789
+
790
+
async function completePasskeyRegistration(
791
+
credential: unknown,
792
+
friendlyName?: string,
793
+
): Promise<void> {
794
+
if (!localClient || !state.passkeySetupToken || !sourceClient) {
795
+
throw new Error("Not ready for passkey registration");
796
+
}
797
+
798
+
migrationLog("completePasskeyRegistration START", { did: state.sourceDid });
799
+
800
+
const result = await localClient.completePasskeySetup(
801
+
state.sourceDid,
802
+
state.passkeySetupToken,
803
+
credential,
804
+
friendlyName,
805
+
);
806
+
migrationLog("completePasskeyRegistration: Passkey registered", {
807
+
appPassword: "***",
808
+
});
809
+
810
+
setProgress({ currentOperation: "Authenticating with app password..." });
811
+
await localClient.loginDeactivated(state.targetEmail, result.appPassword);
812
+
migrationLog("completePasskeyRegistration: Authenticated to new PDS");
813
+
814
+
state.generatedAppPassword = result.appPassword;
815
+
state.generatedAppPasswordName = result.appPasswordName;
816
+
setStep("app-password");
817
+
}
818
+
819
+
async function proceedFromAppPassword(): Promise<void> {
820
+
if (!sourceClient || !localClient) {
821
+
throw new Error("Clients not initialized");
822
+
}
823
+
824
+
migrationLog("proceedFromAppPassword: Starting");
825
+
826
+
if (state.sourceDid.startsWith("did:web:")) {
827
+
const credentials = await localClient.getRecommendedDidCredentials();
828
+
state.targetVerificationMethod =
829
+
credentials.verificationMethods?.atproto || null;
830
+
setStep("did-web-update");
831
+
} else {
832
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
833
+
await sourceClient.requestPlcOperationSignature();
834
+
setStep("plc-token");
835
+
}
836
+
}
837
+
838
function reset(): void {
839
state = {
840
direction: "inbound",
···
853
plcToken: "",
854
progress: createInitialProgress(),
855
error: null,
856
targetVerificationMethod: null,
857
+
authMethod: "password",
858
+
passkeySetupToken: null,
859
+
oauthCodeVerifier: null,
860
+
generatedAppPassword: null,
861
+
generatedAppPasswordName: null,
862
};
863
sourceClient = null;
864
+
passkeySetup = null;
865
+
sourceOAuthMetadata = null;
866
clearMigrationState();
867
+
clearDPoPKey();
868
}
869
870
async function resumeFromState(stored: StoredMigrationState): Promise<void> {
···
875
state.sourceHandle = stored.sourceHandle;
876
state.targetHandle = stored.targetHandle;
877
state.targetEmail = stored.targetEmail;
878
+
state.authMethod = stored.authMethod ?? "password";
879
state.progress = {
880
...createInitialProgress(),
881
...stored.progress,
882
};
883
884
+
const stepsRequiringSourceAuth = [
885
+
"choose-handle",
886
+
"review",
887
+
"migrating",
888
+
"email-verify",
889
+
"plc-token",
890
+
"did-web-update",
891
+
"finalizing",
892
+
"app-password",
893
+
];
894
+
895
+
if (stepsRequiringSourceAuth.includes(stored.step)) {
896
+
state.step = "source-handle";
897
+
state.needsReauth = true;
898
+
state.resumeToStep = stored.step as InboundMigrationState["step"];
899
+
migrationLog("resumeFromState: Requiring re-auth for step", {
900
+
originalStep: stored.step,
901
+
});
902
+
} else if (stored.step === "passkey-setup" && stored.passkeySetupToken) {
903
+
state.passkeySetupToken = stored.passkeySetupToken;
904
+
localClient = createLocalClient();
905
+
state.step = "passkey-setup";
906
+
migrationLog("resumeFromState: Restored passkey-setup with token");
907
+
} else if (stored.step === "success") {
908
+
state.step = "success";
909
+
} else if (stored.step === "error") {
910
+
state.step = "source-handle";
911
+
state.needsReauth = true;
912
+
migrationLog("resumeFromState: Error state, requiring re-auth");
913
+
} else {
914
+
state.step = stored.step as InboundMigrationState["step"];
915
+
}
916
}
917
918
function getLocalSession():
···
932
get state() {
933
return state;
934
},
935
+
get passkeySetup() {
936
+
return passkeySetup;
937
+
},
938
setStep,
939
setError,
940
loadLocalServerInfo,
941
+
resolveSourcePds,
942
+
initiateOAuthLogin,
943
+
handleOAuthCallback,
944
authenticateToLocal,
945
checkHandleAvailability,
946
startMigration,
···
951
submitPlcToken,
952
resendPlcToken,
953
completeDidWebMigration,
954
+
startPasskeyRegistration,
955
+
completePasskeyRegistration,
956
+
proceedFromAppPassword,
957
reset,
958
resumeFromState,
959
getLocalSession,
···
1130
await targetClient.uploadBlob(blobData, "application/octet-stream");
1131
migrated++;
1132
setProgress({ blobsMigrated: migrated });
1133
+
} catch {
1134
state.progress.blobsFailed.push(blob.cid);
1135
}
1136
}
···
1146
const prefs = await localClient.getPreferences();
1147
await targetClient.putPreferences(prefs);
1148
setProgress({ prefsMigrated: true });
1149
+
} catch { /* optional, best-effort */ }
1150
}
1151
1152
async function submitPlcToken(token: string): Promise<void> {
···
1181
try {
1182
await localClient.deactivateAccount(state.targetPdsUrl);
1183
setProgress({ deactivated: true });
1184
+
} catch { /* optional, best-effort */ }
1185
1186
setStep("success");
1187
clearMigrationState();
+28
-19
frontend/src/lib/migration/storage.ts
+28
-19
frontend/src/lib/migration/storage.ts
···
3
MigrationState,
4
StoredMigrationState,
5
} from "./types";
6
7
const STORAGE_KEY = "tranquil_migration_state";
8
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
···
15
startedAt: new Date().toISOString(),
16
sourcePdsUrl: state.direction === "inbound"
17
? state.sourcePdsUrl
18
-
: window.location.origin,
19
targetPdsUrl: state.direction === "inbound"
20
-
? window.location.origin
21
: state.targetPdsUrl,
22
sourceDid: state.direction === "inbound" ? state.sourceDid : "",
23
sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
24
targetHandle: state.targetHandle,
25
targetEmail: state.targetEmail,
26
progress: {
27
repoExported: state.progress.repoExported,
28
repoImported: state.progress.repoImported,
···
36
};
37
38
try {
39
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
40
-
} catch {
41
-
}
42
}
43
44
export function loadMigrationState(): StoredMigrationState | null {
45
try {
46
-
const stored = sessionStorage.getItem(STORAGE_KEY);
47
if (!stored) return null;
48
49
const state = JSON.parse(stored) as StoredMigrationState;
50
51
-
if (state.version !== 1) return null;
52
53
const startedAt = new Date(state.startedAt).getTime();
54
if (Date.now() - startedAt > MAX_AGE_MS) {
···
58
59
return state;
60
} catch {
61
return null;
62
}
63
}
64
65
export function clearMigrationState(): void {
66
try {
67
-
sessionStorage.removeItem(STORAGE_KEY);
68
-
} catch {
69
-
}
70
}
71
72
export function hasPendingMigration(): boolean {
···
79
targetHandle: string;
80
sourcePdsUrl: string;
81
targetPdsUrl: string;
82
progressSummary: string;
83
step: string;
84
} | null {
···
102
targetHandle: state.targetHandle,
103
sourcePdsUrl: state.sourcePdsUrl,
104
targetPdsUrl: state.targetPdsUrl,
105
progressSummary: progressParts.length > 0
106
? progressParts.join(", ")
107
: "just started",
···
117
118
state.progress = { ...state.progress, ...updates };
119
try {
120
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
121
-
} catch {
122
-
}
123
}
124
125
export function updateStep(step: string): void {
···
128
129
state.step = step;
130
try {
131
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
132
-
} catch {
133
-
}
134
}
135
136
export function setError(error: string, step: string): void {
···
140
state.lastError = error;
141
state.lastErrorStep = step;
142
try {
143
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
144
-
} catch {
145
-
}
146
}
···
3
MigrationState,
4
StoredMigrationState,
5
} from "./types";
6
+
import { clearDPoPKey } from "./atproto-client";
7
8
const STORAGE_KEY = "tranquil_migration_state";
9
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
···
16
startedAt: new Date().toISOString(),
17
sourcePdsUrl: state.direction === "inbound"
18
? state.sourcePdsUrl
19
+
: globalThis.location.origin,
20
targetPdsUrl: state.direction === "inbound"
21
+
? globalThis.location.origin
22
: state.targetPdsUrl,
23
sourceDid: state.direction === "inbound" ? state.sourceDid : "",
24
sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
25
targetHandle: state.targetHandle,
26
targetEmail: state.targetEmail,
27
+
authMethod: state.direction === "inbound" ? state.authMethod : undefined,
28
+
passkeySetupToken: state.direction === "inbound"
29
+
? state.passkeySetupToken ?? undefined
30
+
: undefined,
31
progress: {
32
repoExported: state.progress.repoExported,
33
repoImported: state.progress.repoImported,
···
41
};
42
43
try {
44
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
45
+
} catch { /* localStorage unavailable */ }
46
}
47
48
export function loadMigrationState(): StoredMigrationState | null {
49
try {
50
+
const stored = localStorage.getItem(STORAGE_KEY);
51
if (!stored) return null;
52
53
const state = JSON.parse(stored) as StoredMigrationState;
54
55
+
if (state.version !== 1) {
56
+
clearMigrationState();
57
+
return null;
58
+
}
59
60
const startedAt = new Date(state.startedAt).getTime();
61
if (Date.now() - startedAt > MAX_AGE_MS) {
···
65
66
return state;
67
} catch {
68
+
clearMigrationState();
69
return null;
70
}
71
}
72
73
export function clearMigrationState(): void {
74
try {
75
+
localStorage.removeItem(STORAGE_KEY);
76
+
clearDPoPKey();
77
+
} catch { /* localStorage unavailable */ }
78
}
79
80
export function hasPendingMigration(): boolean {
···
87
targetHandle: string;
88
sourcePdsUrl: string;
89
targetPdsUrl: string;
90
+
targetEmail: string;
91
+
authMethod?: "password" | "passkey";
92
progressSummary: string;
93
step: string;
94
} | null {
···
112
targetHandle: state.targetHandle,
113
sourcePdsUrl: state.sourcePdsUrl,
114
targetPdsUrl: state.targetPdsUrl,
115
+
targetEmail: state.targetEmail,
116
+
authMethod: state.authMethod,
117
progressSummary: progressParts.length > 0
118
? progressParts.join(", ")
119
: "just started",
···
129
130
state.progress = { ...state.progress, ...updates };
131
try {
132
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
133
+
} catch { /* localStorage unavailable */ }
134
}
135
136
export function updateStep(step: string): void {
···
139
140
state.step = step;
141
try {
142
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
143
+
} catch { /* localStorage unavailable */ }
144
}
145
146
export function setError(error: string, step: string): void {
···
150
state.lastError = error;
151
state.lastErrorStep = step;
152
try {
153
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
154
+
} catch { /* localStorage unavailable */ }
155
}
+69
-3
frontend/src/lib/migration/types.ts
+69
-3
frontend/src/lib/migration/types.ts
···
1
export type InboundStep =
2
| "welcome"
3
-
| "source-login"
4
| "choose-handle"
5
| "review"
6
| "migrating"
7
| "email-verify"
8
| "plc-token"
9
| "did-web-update"
10
| "finalizing"
11
| "success"
12
| "error";
13
14
export type OutboundStep =
15
| "welcome"
···
54
plcToken: string;
55
progress: MigrationProgress;
56
error: string | null;
57
-
requires2FA: boolean;
58
-
twoFactorCode: string;
59
targetVerificationMethod: string | null;
60
}
61
62
export interface OutboundMigrationState {
···
92
sourceHandle: string;
93
targetHandle: string;
94
targetEmail: string;
95
progress: {
96
repoExported: boolean;
97
repoImported: boolean;
···
199
recoveryKey?: string;
200
}
201
202
export interface Preferences {
203
preferences: unknown[];
204
}
···
214
this.name = "MigrationError";
215
}
216
}
···
1
export type InboundStep =
2
| "welcome"
3
+
| "source-handle"
4
| "choose-handle"
5
| "review"
6
| "migrating"
7
+
| "passkey-setup"
8
+
| "app-password"
9
| "email-verify"
10
| "plc-token"
11
| "did-web-update"
12
| "finalizing"
13
| "success"
14
| "error";
15
+
16
+
export type AuthMethod = "password" | "passkey";
17
18
export type OutboundStep =
19
| "welcome"
···
58
plcToken: string;
59
progress: MigrationProgress;
60
error: string | null;
61
targetVerificationMethod: string | null;
62
+
authMethod: AuthMethod;
63
+
passkeySetupToken: string | null;
64
+
oauthCodeVerifier: string | null;
65
+
generatedAppPassword: string | null;
66
+
generatedAppPasswordName: string | null;
67
+
needsReauth?: boolean;
68
+
resumeToStep?: InboundStep;
69
}
70
71
export interface OutboundMigrationState {
···
101
sourceHandle: string;
102
targetHandle: string;
103
targetEmail: string;
104
+
authMethod?: AuthMethod;
105
+
passkeySetupToken?: string;
106
progress: {
107
repoExported: boolean;
108
repoImported: boolean;
···
210
recoveryKey?: string;
211
}
212
213
+
export interface CreatePasskeyAccountParams {
214
+
did?: string;
215
+
handle: string;
216
+
email: string;
217
+
inviteCode?: string;
218
+
}
219
+
220
+
export interface PasskeyAccountSetup {
221
+
setupToken: string;
222
+
did: string;
223
+
handle: string;
224
+
setupExpiresAt: string;
225
+
accessJwt?: string;
226
+
}
227
+
228
+
export interface CompletePasskeySetupResponse {
229
+
did: string;
230
+
handle: string;
231
+
appPassword: string;
232
+
appPasswordName: string;
233
+
}
234
+
235
+
export interface StartPasskeyRegistrationResponse {
236
+
options: unknown;
237
+
}
238
+
239
+
export interface OAuthServerMetadata {
240
+
issuer: string;
241
+
authorization_endpoint: string;
242
+
token_endpoint: string;
243
+
scopes_supported?: string[];
244
+
response_types_supported?: string[];
245
+
grant_types_supported?: string[];
246
+
code_challenge_methods_supported?: string[];
247
+
dpop_signing_alg_values_supported?: string[];
248
+
}
249
+
250
+
export interface OAuthTokenResponse {
251
+
access_token: string;
252
+
token_type: string;
253
+
expires_in?: number;
254
+
refresh_token?: string;
255
+
scope?: string;
256
+
}
257
+
258
export interface Preferences {
259
preferences: unknown[];
260
}
···
270
this.name = "MigrationError";
271
}
272
}
273
+
274
+
export function getErrorMessage(err: unknown): string {
275
+
if (err instanceof Error) {
276
+
return err.message;
277
+
}
278
+
if (typeof err === "string") {
279
+
return err;
280
+
}
281
+
return String(err);
282
+
}
+11
-7
frontend/src/lib/oauth.ts
+11
-7
frontend/src/lib/oauth.ts
···
8
"blob:*/*",
9
].join(" ");
10
const CLIENT_ID = !(import.meta.env.DEV)
11
-
? `${window.location.origin}/oauth/client-metadata.json`
12
: `http://localhost/?scope=${SCOPES}`;
13
-
const REDIRECT_URI = `${window.location.origin}/`;
14
15
interface OAuthState {
16
state: string;
···
106
107
const { request_uri } = await parResponse.json();
108
109
-
const authorizeUrl = new URL("/oauth/authorize", window.location.origin);
110
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
111
authorizeUrl.searchParams.set("request_uri", request_uri);
112
113
-
window.location.href = authorizeUrl.toString();
114
}
115
116
export interface OAuthTokens {
···
191
export function checkForOAuthCallback():
192
| { code: string; state: string }
193
| null {
194
-
const params = new URLSearchParams(window.location.search);
195
const code = params.get("code");
196
const state = params.get("state");
197
···
203
}
204
205
export function clearOAuthCallbackParams(): void {
206
-
const url = new URL(window.location.href);
207
url.search = "";
208
-
window.history.replaceState({}, "", url.toString());
209
}
···
8
"blob:*/*",
9
].join(" ");
10
const CLIENT_ID = !(import.meta.env.DEV)
11
+
? `${globalThis.location.origin}/oauth/client-metadata.json`
12
: `http://localhost/?scope=${SCOPES}`;
13
+
const REDIRECT_URI = `${globalThis.location.origin}/`;
14
15
interface OAuthState {
16
state: string;
···
106
107
const { request_uri } = await parResponse.json();
108
109
+
const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin);
110
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
111
authorizeUrl.searchParams.set("request_uri", request_uri);
112
113
+
globalThis.location.href = authorizeUrl.toString();
114
}
115
116
export interface OAuthTokens {
···
191
export function checkForOAuthCallback():
192
| { code: string; state: string }
193
| null {
194
+
if (globalThis.location.hash === "#/migrate") {
195
+
return null;
196
+
}
197
+
198
+
const params = new URLSearchParams(globalThis.location.search);
199
const code = params.get("code");
200
const state = params.get("state");
201
···
207
}
208
209
export function clearOAuthCallbackParams(): void {
210
+
const url = new URL(globalThis.location.href);
211
url.search = "";
212
+
globalThis.history.replaceState({}, "", url.toString());
213
}
+1
-1
frontend/src/lib/registration/flow.svelte.ts
+1
-1
frontend/src/lib/registration/flow.svelte.ts
···
104
state.externalDidWeb.reservedSigningKey = result.signingKey;
105
publicKeyMultibase = result.signingKey.replace("did:key:", "");
106
} else {
107
-
const keypair = await generateKeypair();
108
state.externalDidWeb.byodPrivateKey = keypair.privateKey;
109
state.externalDidWeb.byodPublicKeyMultibase =
110
keypair.publicKeyMultibase;
···
104
state.externalDidWeb.reservedSigningKey = result.signingKey;
105
publicKeyMultibase = result.signingKey.replace("did:key:", "");
106
} else {
107
+
const keypair = generateKeypair();
108
state.externalDidWeb.byodPrivateKey = keypair.privateKey;
109
state.externalDidWeb.byodPublicKeyMultibase =
110
keypair.publicKeyMultibase;
+4
-4
frontend/src/lib/router.svelte.ts
+4
-4
frontend/src/lib/router.svelte.ts
···
1
let currentPath = $state(
2
-
getPathWithoutQuery(window.location.hash.slice(1) || "/"),
3
);
4
5
function getPathWithoutQuery(hash: string): string {
···
7
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
8
}
9
10
-
window.addEventListener("hashchange", () => {
11
-
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/");
12
});
13
14
export function navigate(path: string) {
15
currentPath = path;
16
-
window.location.hash = path;
17
}
18
19
export function getCurrentPath() {
···
1
let currentPath = $state(
2
+
getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"),
3
);
4
5
function getPathWithoutQuery(hash: string): string {
···
7
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
8
}
9
10
+
globalThis.addEventListener("hashchange", () => {
11
+
currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/");
12
});
13
14
export function navigate(path: string) {
15
currentPath = path;
16
+
globalThis.location.hash = path;
17
}
18
19
export function getCurrentPath() {
+1
-1
frontend/src/lib/serverConfig.svelte.ts
+1
-1
frontend/src/lib/serverConfig.svelte.ts
+106
-32
frontend/src/locales/en.json
+106
-32
frontend/src/locales/en.json
···
902
"reauth": {
903
"title": "Re-authentication Required",
904
"subtitle": "Please verify your identity to continue.",
905
"usePassword": "Use Password",
906
"usePasskey": "Use Passkey",
907
"useTotp": "Use Authenticator",
···
909
"totpPlaceholder": "Enter 6-digit code",
910
"verify": "Verify",
911
"verifying": "Verifying...",
912
"cancel": "Cancel"
913
},
914
"delegation": {
···
1071
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1072
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1073
"learnMore": "Learn more about migration risks",
1074
"resume": {
1075
"title": "Resume Migration?",
1076
"incomplete": "You have an incomplete migration in progress:",
···
1090
"desc": "Move your existing AT Protocol account to this server.",
1091
"understand": "I understand the risks and want to proceed"
1092
},
1093
-
"sourceLogin": {
1094
-
"title": "Sign In to Your Current PDS",
1095
-
"desc": "Enter your credentials for the account you want to migrate.",
1096
"handle": "Handle",
1097
-
"handlePlaceholder": "you.bsky.social",
1098
-
"password": "Password",
1099
-
"twoFactorCode": "Two-Factor Code",
1100
-
"twoFactorRequired": "Two-factor authentication required",
1101
-
"signIn": "Sign In & Continue"
1102
},
1103
"chooseHandle": {
1104
"title": "Choose Your New Handle",
1105
"desc": "Select a handle for your account on this PDS.",
1106
-
"handleHint": "Your full handle will be: @{handle}"
1107
},
1108
"review": {
1109
"title": "Review Migration",
1110
-
"desc": "Please review and confirm your migration details.",
1111
"currentHandle": "Current Handle",
1112
"newHandle": "New Handle",
1113
-
"sourcePds": "Source PDS",
1114
-
"targetPds": "This PDS",
1115
"email": "Email",
1116
"inviteCode": "Invite Code",
1117
-
"confirm": "I confirm I want to migrate my account",
1118
-
"startMigration": "Start Migration"
1119
},
1120
"migrating": {
1121
-
"title": "Migrating Your Account",
1122
-
"desc": "Please wait while we transfer your data...",
1123
-
"gettingServiceAuth": "Getting service authorization...",
1124
-
"creatingAccount": "Creating account on new PDS...",
1125
-
"exportingRepo": "Exporting repository...",
1126
-
"importingRepo": "Importing repository...",
1127
-
"countingBlobs": "Counting blobs...",
1128
-
"migratingBlobs": "Migrating blobs ({current}/{total})...",
1129
-
"migratingPrefs": "Migrating preferences...",
1130
-
"requestingPlc": "Requesting PLC operation..."
1131
},
1132
"emailVerify": {
1133
"title": "Verify Your Email",
···
1140
"verifying": "Verifying..."
1141
},
1142
"plcToken": {
1143
-
"title": "Verify Your Identity",
1144
-
"desc": "A verification code has been sent to your email on your current PDS.",
1145
-
"tokenLabel": "Verification Token",
1146
-
"tokenPlaceholder": "Enter the token from your email",
1147
-
"resend": "Resend Token",
1148
-
"resending": "Resending..."
1149
},
1150
"didWebUpdate": {
1151
"title": "Update Your DID Document",
···
1168
"success": {
1169
"title": "Migration Complete!",
1170
"desc": "Your account has been successfully migrated to this PDS.",
1171
-
"newHandle": "New Handle",
1172
"did": "DID",
1173
-
"goToDashboard": "Go to Dashboard"
1174
}
1175
},
1176
"outbound": {
···
902
"reauth": {
903
"title": "Re-authentication Required",
904
"subtitle": "Please verify your identity to continue.",
905
+
"password": "Password",
906
+
"totp": "TOTP",
907
+
"passkey": "Passkey",
908
+
"authenticatorCode": "Authenticator Code",
909
"usePassword": "Use Password",
910
"usePasskey": "Use Passkey",
911
"useTotp": "Use Authenticator",
···
913
"totpPlaceholder": "Enter 6-digit code",
914
"verify": "Verify",
915
"verifying": "Verifying...",
916
+
"authenticating": "Authenticating...",
917
+
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
918
"cancel": "Cancel"
919
},
920
"delegation": {
···
1077
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1078
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1079
"learnMore": "Learn more about migration risks",
1080
+
"comingSoon": "Coming soon",
1081
+
"oauthCompleting": "Completing authentication...",
1082
+
"oauthFailed": "Authentication Failed",
1083
+
"tryAgain": "Try Again",
1084
"resume": {
1085
"title": "Resume Migration?",
1086
"incomplete": "You have an incomplete migration in progress:",
···
1100
"desc": "Move your existing AT Protocol account to this server.",
1101
"understand": "I understand the risks and want to proceed"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "Enter Your Current Handle",
1105
+
"titleResume": "Resume Migration",
1106
+
"desc": "Enter the handle of the account you want to migrate.",
1107
+
"descResume": "Re-authenticate to your source PDS to continue the migration.",
1108
"handle": "Handle",
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "Your current handle on your existing PDS",
1111
+
"continue": "Continue",
1112
+
"connecting": "Connecting...",
1113
+
"reauthenticate": "Re-authenticate",
1114
+
"resumeTitle": "Migration in Progress",
1115
+
"resumeFrom": "From",
1116
+
"resumeTo": "To",
1117
+
"resumeProgress": "Progress",
1118
+
"resumeOAuthNote": "You need to re-authenticate via OAuth to continue."
1119
},
1120
"chooseHandle": {
1121
"title": "Choose Your New Handle",
1122
"desc": "Select a handle for your account on this PDS.",
1123
+
"migratingFrom": "Migrating from",
1124
+
"newHandle": "New Handle",
1125
+
"checkingAvailability": "Checking availability...",
1126
+
"handleAvailable": "Handle is available!",
1127
+
"handleTaken": "Handle is already taken",
1128
+
"handleHint": "You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)",
1129
+
"email": "Email Address",
1130
+
"authMethod": "Authentication Method",
1131
+
"authPassword": "Password",
1132
+
"authPasswordDesc": "Traditional password-based login",
1133
+
"authPasskey": "Passkey",
1134
+
"authPasskeyDesc": "Passwordless login using biometrics or security key",
1135
+
"password": "Password",
1136
+
"passwordHint": "At least 8 characters",
1137
+
"passkeyInfo": "You'll set up a passkey after your account is created. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.",
1138
+
"inviteCode": "Invite Code"
1139
},
1140
"review": {
1141
"title": "Review Migration",
1142
+
"desc": "Please confirm the details of your migration.",
1143
"currentHandle": "Current Handle",
1144
"newHandle": "New Handle",
1145
+
"did": "DID",
1146
+
"sourcePds": "From PDS",
1147
+
"targetPds": "To PDS",
1148
"email": "Email",
1149
+
"authentication": "Authentication",
1150
+
"authPasskey": "Passkey (passwordless)",
1151
+
"authPassword": "Password",
1152
"inviteCode": "Invite Code",
1153
+
"warning": "After you click \"Start Migration\", your repository and data will begin transferring. This process cannot be easily undone.",
1154
+
"startMigration": "Start Migration",
1155
+
"starting": "Starting..."
1156
},
1157
"migrating": {
1158
+
"title": "Migration in Progress",
1159
+
"desc": "Please wait while your account is being transferred...",
1160
+
"exportRepo": "Export repository",
1161
+
"importRepo": "Import repository",
1162
+
"migrateBlobs": "Migrate blobs",
1163
+
"migratePrefs": "Migrate preferences"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Set Up Your Passkey",
1167
+
"desc": "Your email has been verified. Now set up your passkey for secure, passwordless login.",
1168
+
"nameLabel": "Passkey Name (optional)",
1169
+
"namePlaceholder": "e.g., MacBook Pro, iPhone",
1170
+
"nameHint": "A friendly name to identify this passkey",
1171
+
"instructions": "Click the button below to register your passkey. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.",
1172
+
"register": "Register Passkey",
1173
+
"registering": "Registering..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Save Your App Password",
1177
+
"desc": "Your passkey has been created. An app password has been generated for you to use with apps that don't support passkeys yet.",
1178
+
"warning": "This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.",
1179
+
"label": "App Password for",
1180
+
"saved": "I have saved my app password in a secure location",
1181
+
"continue": "Continue"
1182
},
1183
"emailVerify": {
1184
"title": "Verify Your Email",
···
1191
"verifying": "Verifying..."
1192
},
1193
"plcToken": {
1194
+
"title": "Verify Migration",
1195
+
"desc": "A verification code has been sent to the email registered with your old account.",
1196
+
"info": "This code confirms you have access to the account and authorizes updating your identity to point to this PDS.",
1197
+
"tokenLabel": "Verification Code",
1198
+
"tokenPlaceholder": "Enter code from email",
1199
+
"resend": "Resend Code",
1200
+
"complete": "Complete Migration",
1201
+
"completing": "Verifying..."
1202
},
1203
"didWebUpdate": {
1204
"title": "Update Your DID Document",
···
1221
"success": {
1222
"title": "Migration Complete!",
1223
"desc": "Your account has been successfully migrated to this PDS.",
1224
+
"yourNewHandle": "Your new handle",
1225
"did": "DID",
1226
+
"blobsWarning": "{count} blobs could not be migrated. These may be images or other media that are no longer available.",
1227
+
"redirecting": "Redirecting to dashboard..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Migration Error",
1231
+
"desc": "An error occurred during migration.",
1232
+
"startOver": "Start Over"
1233
+
},
1234
+
"common": {
1235
+
"back": "Back",
1236
+
"cancel": "Cancel",
1237
+
"continue": "Continue",
1238
+
"whatWillHappen": "What will happen:",
1239
+
"step1": "Log in to your current PDS",
1240
+
"step2": "Choose your new handle on this server",
1241
+
"step3": "Your repository and blobs will be transferred",
1242
+
"step4": "Verify the migration via email",
1243
+
"step5": "Your identity will be updated to point here",
1244
+
"beforeProceed": "Before you proceed:",
1245
+
"warning1": "You need access to the email registered with your current account",
1246
+
"warning2": "Large accounts may take several minutes to transfer",
1247
+
"warning3": "Your old account will be deactivated after migration"
1248
}
1249
},
1250
"outbound": {
+103
-29
frontend/src/locales/fi.json
+103
-29
frontend/src/locales/fi.json
···
902
"reauth": {
903
"title": "Uudelleentodennus vaaditaan",
904
"subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.",
905
"usePassword": "Käytä salasanaa",
906
"usePasskey": "Käytä pääsyavainta",
907
"useTotp": "Käytä todentajaa",
···
909
"totpPlaceholder": "Syötä 6-numeroinen koodi",
910
"verify": "Vahvista",
911
"verifying": "Vahvistetaan...",
912
"cancel": "Peruuta"
913
},
914
"verifyChannel": {
···
1071
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1072
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1073
"learnMore": "Lue lisää siirron riskeistä",
1074
"resume": {
1075
"title": "Jatka siirtoa?",
1076
"incomplete": "Sinulla on keskeneräinen siirto:",
···
1090
"desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.",
1091
"understand": "Ymmärrän riskit ja haluan jatkaa"
1092
},
1093
-
"sourceLogin": {
1094
-
"title": "Kirjaudu nykyiseen PDS:ääsi",
1095
-
"desc": "Syötä siirrettävän tilin tunnukset.",
1096
"handle": "Käyttäjätunnus",
1097
-
"handlePlaceholder": "sinä.bsky.social",
1098
-
"password": "Salasana",
1099
-
"twoFactorCode": "Kaksivaiheinen koodi",
1100
-
"twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
1101
-
"signIn": "Kirjaudu ja jatka"
1102
},
1103
"chooseHandle": {
1104
"title": "Valitse uusi käyttäjätunnuksesi",
1105
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
1106
-
"handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}"
1107
},
1108
"review": {
1109
"title": "Tarkista siirto",
1110
-
"desc": "Tarkista ja vahvista siirtotietosi.",
1111
"currentHandle": "Nykyinen käyttäjätunnus",
1112
"newHandle": "Uusi käyttäjätunnus",
1113
"sourcePds": "Lähde-PDS",
1114
-
"targetPds": "Tämä PDS",
1115
"email": "Sähköposti",
1116
"inviteCode": "Kutsukoodi",
1117
-
"confirm": "Vahvistan haluavani siirtää tilini",
1118
-
"startMigration": "Aloita siirto"
1119
},
1120
"migrating": {
1121
-
"title": "Siirretään tiliäsi",
1122
-
"desc": "Odota, kun siirrämme tietojasi...",
1123
-
"gettingServiceAuth": "Haetaan palveluvaltuutusta...",
1124
-
"creatingAccount": "Luodaan tiliä uuteen PDS:ään...",
1125
-
"exportingRepo": "Viedään tietovarastoa...",
1126
-
"importingRepo": "Tuodaan tietovarastoa...",
1127
-
"countingBlobs": "Lasketaan blob-tiedostoja...",
1128
-
"migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...",
1129
-
"migratingPrefs": "Siirretään asetuksia...",
1130
-
"requestingPlc": "Pyydetään PLC-toimintoa..."
1131
},
1132
"emailVerify": {
1133
"title": "Vahvista sähköpostisi",
···
1140
"verifying": "Vahvistetaan..."
1141
},
1142
"plcToken": {
1143
-
"title": "Vahvista henkilöllisyytesi",
1144
-
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.",
1145
"tokenLabel": "Vahvistuskoodi",
1146
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
1147
-
"resend": "Lähetä uudelleen",
1148
-
"resending": "Lähetetään..."
1149
},
1150
"didWebUpdate": {
1151
"title": "Päivitä DID-dokumenttisi",
···
1168
"success": {
1169
"title": "Siirto valmis!",
1170
"desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.",
1171
-
"newHandle": "Uusi käyttäjätunnus",
1172
"did": "DID",
1173
-
"goToDashboard": "Siirry hallintapaneeliin"
1174
}
1175
},
1176
"outbound": {
···
902
"reauth": {
903
"title": "Uudelleentodennus vaaditaan",
904
"subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.",
905
+
"password": "Salasana",
906
+
"totp": "TOTP",
907
+
"passkey": "Pääsyavain",
908
+
"authenticatorCode": "Todentajan koodi",
909
"usePassword": "Käytä salasanaa",
910
"usePasskey": "Käytä pääsyavainta",
911
"useTotp": "Käytä todentajaa",
···
913
"totpPlaceholder": "Syötä 6-numeroinen koodi",
914
"verify": "Vahvista",
915
"verifying": "Vahvistetaan...",
916
+
"authenticating": "Todennetaan...",
917
+
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
918
"cancel": "Peruuta"
919
},
920
"verifyChannel": {
···
1077
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1078
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1079
"learnMore": "Lue lisää siirron riskeistä",
1080
+
"comingSoon": "Tulossa pian",
1081
+
"oauthCompleting": "Viimeistellään todennusta...",
1082
+
"oauthFailed": "Todennus epäonnistui",
1083
+
"tryAgain": "Yritä uudelleen",
1084
"resume": {
1085
"title": "Jatka siirtoa?",
1086
"incomplete": "Sinulla on keskeneräinen siirto:",
···
1100
"desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.",
1101
"understand": "Ymmärrän riskit ja haluan jatkaa"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "Syötä nykyinen käyttäjätunnuksesi",
1105
+
"titleResume": "Jatka siirtoa",
1106
+
"desc": "Syötä siirrettävän tilin käyttäjätunnus.",
1107
+
"descResume": "Tunnistaudu uudelleen lähde-PDS:ään jatkaaksesi siirtoa.",
1108
"handle": "Käyttäjätunnus",
1109
+
"handlePlaceholder": "maija.bsky.social",
1110
+
"handleHint": "Nykyinen käyttäjätunnuksesi nykyisessä PDS:ssäsi",
1111
+
"continue": "Jatka",
1112
+
"connecting": "Yhdistetään...",
1113
+
"reauthenticate": "Tunnistaudu uudelleen",
1114
+
"resumeTitle": "Siirto käynnissä",
1115
+
"resumeFrom": "Mistä",
1116
+
"resumeTo": "Minne",
1117
+
"resumeProgress": "Edistyminen",
1118
+
"resumeOAuthNote": "Sinun täytyy tunnistautua uudelleen OAuth:n kautta jatkaaksesi."
1119
},
1120
"chooseHandle": {
1121
"title": "Valitse uusi käyttäjätunnuksesi",
1122
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
1123
+
"migratingFrom": "Siirretään tililtä",
1124
+
"newHandle": "Uusi käyttäjätunnus",
1125
+
"checkingAvailability": "Tarkistetaan saatavuutta...",
1126
+
"handleAvailable": "Käyttäjätunnus on saatavilla!",
1127
+
"handleTaken": "Käyttäjätunnus on jo varattu",
1128
+
"handleHint": "Voit myös käyttää omaa verkkotunnustasi syöttämällä täydellisen käyttäjätunnuksen (esim. maija.omadomain.fi)",
1129
+
"email": "Sähköpostiosoite",
1130
+
"authMethod": "Tunnistautumistapa",
1131
+
"authPassword": "Salasana",
1132
+
"authPasswordDesc": "Perinteinen salasanapohjainen kirjautuminen",
1133
+
"authPasskey": "Pääsyavain",
1134
+
"authPasskeyDesc": "Salasanaton kirjautuminen biometriikalla tai suojausavaimella",
1135
+
"password": "Salasana",
1136
+
"passwordHint": "Vähintään 8 merkkiä",
1137
+
"passkeyInfo": "Määrität pääsyavaimen tilisi luomisen jälkeen. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.",
1138
+
"inviteCode": "Kutsukoodi"
1139
},
1140
"review": {
1141
"title": "Tarkista siirto",
1142
+
"desc": "Vahvista siirtosi tiedot.",
1143
"currentHandle": "Nykyinen käyttäjätunnus",
1144
"newHandle": "Uusi käyttäjätunnus",
1145
+
"did": "DID",
1146
"sourcePds": "Lähde-PDS",
1147
+
"targetPds": "Kohde-PDS",
1148
"email": "Sähköposti",
1149
+
"authentication": "Tunnistautuminen",
1150
+
"authPasskey": "Pääsyavain (salasanaton)",
1151
+
"authPassword": "Salasana",
1152
"inviteCode": "Kutsukoodi",
1153
+
"warning": "Kun klikkaat \"Aloita siirto\", tietovarastosi ja datasi alkavat siirtyä. Tätä prosessia ei voi helposti peruuttaa.",
1154
+
"startMigration": "Aloita siirto",
1155
+
"starting": "Aloitetaan..."
1156
},
1157
"migrating": {
1158
+
"title": "Siirto käynnissä",
1159
+
"desc": "Odota, kun tiliäsi siirretään...",
1160
+
"exportRepo": "Vie tietovarasto",
1161
+
"importRepo": "Tuo tietovarasto",
1162
+
"migrateBlobs": "Siirrä blob-tiedostot",
1163
+
"migratePrefs": "Siirrä asetukset"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Määritä pääsyavaimesi",
1167
+
"desc": "Sähköpostisi on vahvistettu. Määritä nyt pääsyavaimesi turvallista, salasanatonta kirjautumista varten.",
1168
+
"nameLabel": "Pääsyavaimen nimi (valinnainen)",
1169
+
"namePlaceholder": "esim. MacBook Pro, iPhone",
1170
+
"nameHint": "Kutsumanimi tämän pääsyavaimen tunnistamiseen",
1171
+
"instructions": "Klikkaa alla olevaa painiketta rekisteröidäksesi pääsyavaimesi. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.",
1172
+
"register": "Rekisteröi pääsyavain",
1173
+
"registering": "Rekisteröidään..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Tallenna sovellussalasanasi",
1177
+
"desc": "Pääsyavaimesi on luotu. Sovellussalasana on luotu sinulle käytettäväksi sovellusten kanssa, jotka eivät vielä tue pääsyavaimia.",
1178
+
"warning": "Tämä sovellussalasana vaaditaan kirjautumiseen sovelluksissa, jotka eivät vielä tue pääsyavaimia (kuten bsky.app). Näet tämän salasanan vain kerran.",
1179
+
"label": "Sovellussalasana kohteelle",
1180
+
"saved": "Olen tallentanut sovellussalasanani turvalliseen paikkaan",
1181
+
"continue": "Jatka"
1182
},
1183
"emailVerify": {
1184
"title": "Vahvista sähköpostisi",
···
1191
"verifying": "Vahvistetaan..."
1192
},
1193
"plcToken": {
1194
+
"title": "Vahvista siirto",
1195
+
"desc": "Vahvistuskoodi on lähetetty vanhaan tiliisi rekisteröityyn sähköpostiin.",
1196
+
"info": "Tämä koodi vahvistaa, että sinulla on pääsy tiliin ja valtuuttaa identiteettisi päivityksen osoittamaan tähän PDS:ään.",
1197
"tokenLabel": "Vahvistuskoodi",
1198
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
1199
+
"resend": "Lähetä koodi uudelleen",
1200
+
"complete": "Viimeistele siirto",
1201
+
"completing": "Vahvistetaan..."
1202
},
1203
"didWebUpdate": {
1204
"title": "Päivitä DID-dokumenttisi",
···
1221
"success": {
1222
"title": "Siirto valmis!",
1223
"desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.",
1224
+
"yourNewHandle": "Uusi käyttäjätunnuksesi",
1225
"did": "DID",
1226
+
"blobsWarning": "{count} blob-tiedostoa ei voitu siirtää. Nämä voivat olla kuvia tai muuta mediaa, jotka eivät ole enää saatavilla.",
1227
+
"redirecting": "Uudelleenohjataan hallintapaneeliin..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Siirtovirhe",
1231
+
"desc": "Siirron aikana tapahtui virhe.",
1232
+
"startOver": "Aloita alusta"
1233
+
},
1234
+
"common": {
1235
+
"back": "Takaisin",
1236
+
"cancel": "Peruuta",
1237
+
"continue": "Jatka",
1238
+
"whatWillHappen": "Mitä tapahtuu:",
1239
+
"step1": "Kirjaudu nykyiseen PDS:ääsi",
1240
+
"step2": "Valitse uusi käyttäjätunnus tällä palvelimella",
1241
+
"step3": "Tietovarastosi ja blob-tiedostosi siirretään",
1242
+
"step4": "Vahvista siirto sähköpostilla",
1243
+
"step5": "Identiteettisi päivitetään osoittamaan tänne",
1244
+
"beforeProceed": "Ennen kuin jatkat:",
1245
+
"warning1": "Tarvitset pääsyn nykyiseen tiliisi rekisteröityyn sähköpostiin",
1246
+
"warning2": "Suurten tilien siirto voi kestää useita minuutteja",
1247
+
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
1248
}
1249
},
1250
"outbound": {
+117
-31
frontend/src/locales/ja.json
+117
-31
frontend/src/locales/ja.json
···
189
"title": "DID ドキュメントエディター",
190
"preview": "現在の DID ドキュメント",
191
"verificationMethods": "検証方法(署名キー)",
192
"addKey": "キーを追加",
193
"removeKey": "削除",
194
"keyId": "キー ID",
195
"keyIdPlaceholder": "#atproto",
196
"publicKey": "公開キー(Multibase)",
197
"publicKeyPlaceholder": "zQ3sh...",
198
"alsoKnownAs": "別名(ハンドル)",
199
"addHandle": "ハンドルを追加",
200
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "サービスエンドポイント(現在の PDS)",
202
"save": "変更を保存",
203
"saving": "保存中...",
204
"success": "DID ドキュメントを更新しました",
205
"helpTitle": "これは何ですか?",
206
"helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。"
207
},
···
890
"reauth": {
891
"title": "再認証が必要です",
892
"subtitle": "続行するには本人確認を行ってください。",
893
"usePassword": "パスワードを使用",
894
"usePasskey": "パスキーを使用",
895
"useTotp": "認証アプリを使用",
···
897
"totpPlaceholder": "6桁のコードを入力",
898
"verify": "確認",
899
"verifying": "確認中...",
900
"cancel": "キャンセル"
901
},
902
"verifyChannel": {
···
1059
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1060
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1061
"learnMore": "移行のリスクについて詳しく",
1062
"resume": {
1063
"title": "移行を再開しますか?",
1064
"incomplete": "未完了の移行があります:",
···
1078
"desc": "既存のAT Protocolアカウントをこのサーバーに移動します。",
1079
"understand": "リスクを理解し、続行します"
1080
},
1081
-
"sourceLogin": {
1082
-
"title": "現在のPDSにサインイン",
1083
-
"desc": "移行するアカウントの認証情報を入力してください。",
1084
"handle": "ハンドル",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "パスワード",
1087
-
"twoFactorCode": "2要素認証コード",
1088
-
"twoFactorRequired": "2要素認証が必要です",
1089
-
"signIn": "サインインして続行"
1090
},
1091
"chooseHandle": {
1092
"title": "新しいハンドルを選択",
1093
"desc": "このPDSでのアカウントのハンドルを選択してください。",
1094
-
"handleHint": "完全なハンドル: @{handle}"
1095
},
1096
"review": {
1097
"title": "移行の確認",
1098
"desc": "移行の詳細を確認してください。",
1099
"currentHandle": "現在のハンドル",
1100
"newHandle": "新しいハンドル",
1101
"sourcePds": "移行元PDS",
1102
-
"targetPds": "このPDS",
1103
"email": "メール",
1104
"inviteCode": "招待コード",
1105
-
"confirm": "アカウントを移行することを確認します",
1106
-
"startMigration": "移行を開始"
1107
},
1108
"migrating": {
1109
-
"title": "アカウントを移行中",
1110
-
"desc": "データを転送しています...",
1111
-
"gettingServiceAuth": "サービス認証を取得中...",
1112
-
"creatingAccount": "新しいPDSにアカウントを作成中...",
1113
-
"exportingRepo": "リポジトリをエクスポート中...",
1114
-
"importingRepo": "リポジトリをインポート中...",
1115
-
"countingBlobs": "blobをカウント中...",
1116
-
"migratingBlobs": "blobを移行中 ({current}/{total})...",
1117
-
"migratingPrefs": "設定を移行中...",
1118
-
"requestingPlc": "PLC操作をリクエスト中..."
1119
},
1120
"emailVerify": {
1121
"title": "メールアドレスを確認",
···
1128
"verifying": "確認中..."
1129
},
1130
"plcToken": {
1131
-
"title": "本人確認",
1132
-
"desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。",
1133
-
"tokenLabel": "確認トークン",
1134
-
"tokenPlaceholder": "メールに記載されたトークンを入力",
1135
-
"resend": "再送信",
1136
-
"resending": "送信中..."
1137
},
1138
"didWebUpdate": {
1139
"title": "DIDドキュメントを更新",
···
1156
"success": {
1157
"title": "移行完了!",
1158
"desc": "アカウントはこのPDSに正常に移行されました。",
1159
-
"newHandle": "新しいハンドル",
1160
"did": "DID",
1161
-
"goToDashboard": "ダッシュボードへ"
1162
}
1163
},
1164
"outbound": {
···
189
"title": "DID ドキュメントエディター",
190
"preview": "現在の DID ドキュメント",
191
"verificationMethods": "検証方法(署名キー)",
192
+
"verificationMethodsDesc": "DIDの代わりに動作できる署名キー。新しいPDSに移行する際は、そのPDSの署名キーをここに追加してください。",
193
"addKey": "キーを追加",
194
"removeKey": "削除",
195
"keyId": "キー ID",
196
"keyIdPlaceholder": "#atproto",
197
"publicKey": "公開キー(Multibase)",
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "検証方法が設定されていません。ローカルPDSキーを使用しています。",
200
"alsoKnownAs": "別名(ハンドル)",
201
+
"alsoKnownAsDesc": "DIDを指すハンドル。新しいPDSでハンドルが変更されたら更新してください。",
202
"addHandle": "ハンドルを追加",
203
+
"removeHandle": "削除",
204
+
"handle": "ハンドル",
205
"handlePlaceholder": "at://handle.pds.com",
206
+
"noHandles": "ハンドルが設定されていません。ローカルハンドルを使用しています。",
207
+
"serviceEndpoint": "サービスエンドポイント",
208
+
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
209
+
"currentPds": "現在のPDS URL",
210
"save": "変更を保存",
211
"saving": "保存中...",
212
"success": "DID ドキュメントを更新しました",
213
+
"saveFailed": "DIDドキュメントの保存に失敗しました",
214
+
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
215
+
"invalidMultibase": "公開キーは'z'で始まる有効なmultibase文字列である必要があります",
216
+
"invalidHandle": "ハンドルはat:// URIである必要があります(例:at://handle.example.com)",
217
"helpTitle": "これは何ですか?",
218
"helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。"
219
},
···
902
"reauth": {
903
"title": "再認証が必要です",
904
"subtitle": "続行するには本人確認を行ってください。",
905
+
"password": "パスワード",
906
+
"totp": "TOTP",
907
+
"passkey": "パスキー",
908
+
"authenticatorCode": "認証コード",
909
"usePassword": "パスワードを使用",
910
"usePasskey": "パスキーを使用",
911
"useTotp": "認証アプリを使用",
···
913
"totpPlaceholder": "6桁のコードを入力",
914
"verify": "確認",
915
"verifying": "確認中...",
916
+
"authenticating": "認証中...",
917
+
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
918
"cancel": "キャンセル"
919
},
920
"verifyChannel": {
···
1077
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1078
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1079
"learnMore": "移行のリスクについて詳しく",
1080
+
"comingSoon": "近日公開",
1081
+
"oauthCompleting": "認証を完了しています...",
1082
+
"oauthFailed": "認証に失敗しました",
1083
+
"tryAgain": "再試行",
1084
"resume": {
1085
"title": "移行を再開しますか?",
1086
"incomplete": "未完了の移行があります:",
···
1100
"desc": "既存のAT Protocolアカウントをこのサーバーに移動します。",
1101
"understand": "リスクを理解し、続行します"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "現在のハンドルを入力",
1105
+
"titleResume": "移行を再開",
1106
+
"desc": "移行するアカウントのハンドルを入力してください。",
1107
+
"descResume": "移行を続行するには、元のPDSに再認証してください。",
1108
"handle": "ハンドル",
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "現在のPDSでのハンドル",
1111
+
"continue": "続行",
1112
+
"connecting": "接続中...",
1113
+
"reauthenticate": "再認証",
1114
+
"resumeTitle": "移行中",
1115
+
"resumeFrom": "移行元",
1116
+
"resumeTo": "移行先",
1117
+
"resumeProgress": "進行状況",
1118
+
"resumeOAuthNote": "続行するにはOAuthで再認証が必要です。"
1119
},
1120
"chooseHandle": {
1121
"title": "新しいハンドルを選択",
1122
"desc": "このPDSでのアカウントのハンドルを選択してください。",
1123
+
"migratingFrom": "移行元",
1124
+
"newHandle": "新しいハンドル",
1125
+
"checkingAvailability": "利用可能か確認中...",
1126
+
"handleAvailable": "ハンドルは利用可能です!",
1127
+
"handleTaken": "このハンドルは既に使用されています",
1128
+
"handleHint": "フルハンドル(例:alice.mydomain.com)を入力して独自ドメインを使用することもできます",
1129
+
"email": "メールアドレス",
1130
+
"authMethod": "認証方法",
1131
+
"authPassword": "パスワード",
1132
+
"authPasswordDesc": "従来のパスワードベースのログイン",
1133
+
"authPasskey": "パスキー",
1134
+
"authPasskeyDesc": "生体認証やセキュリティキーを使用したパスワードレスログイン",
1135
+
"password": "パスワード",
1136
+
"passwordHint": "8文字以上",
1137
+
"passkeyInfo": "アカウント作成後にパスキーを設定します。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。",
1138
+
"inviteCode": "招待コード"
1139
},
1140
"review": {
1141
"title": "移行の確認",
1142
"desc": "移行の詳細を確認してください。",
1143
"currentHandle": "現在のハンドル",
1144
"newHandle": "新しいハンドル",
1145
+
"did": "DID",
1146
"sourcePds": "移行元PDS",
1147
+
"targetPds": "移行先PDS",
1148
"email": "メール",
1149
+
"authentication": "認証",
1150
+
"authPasskey": "パスキー(パスワードレス)",
1151
+
"authPassword": "パスワード",
1152
"inviteCode": "招待コード",
1153
+
"warning": "「移行を開始」をクリックすると、リポジトリとデータの転送が始まります。このプロセスは簡単に元に戻すことができません。",
1154
+
"startMigration": "移行を開始",
1155
+
"starting": "開始中..."
1156
},
1157
"migrating": {
1158
+
"title": "移行中",
1159
+
"desc": "アカウントを転送しています...",
1160
+
"exportRepo": "リポジトリをエクスポート",
1161
+
"importRepo": "リポジトリをインポート",
1162
+
"migrateBlobs": "blobを移行",
1163
+
"migratePrefs": "設定を移行"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "パスキーを設定",
1167
+
"desc": "メールが確認されました。安全なパスワードレスログインのためにパスキーを設定してください。",
1168
+
"nameLabel": "パスキー名(任意)",
1169
+
"namePlaceholder": "例:MacBook Pro、iPhone",
1170
+
"nameHint": "このパスキーを識別するためのわかりやすい名前",
1171
+
"instructions": "下のボタンをクリックしてパスキーを登録してください。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。",
1172
+
"register": "パスキーを登録",
1173
+
"registering": "登録中..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "アプリパスワードを保存",
1177
+
"desc": "パスキーが作成されました。パスキーをまだサポートしていないアプリで使用するためのアプリパスワードが生成されました。",
1178
+
"warning": "このアプリパスワードは、パスキーをまだサポートしていないアプリ(bsky.appなど)へのサインインに必要です。このパスワードは一度しか表示されません。",
1179
+
"label": "アプリパスワード:",
1180
+
"saved": "アプリパスワードを安全な場所に保存しました",
1181
+
"continue": "続ける"
1182
},
1183
"emailVerify": {
1184
"title": "メールアドレスを確認",
···
1191
"verifying": "確認中..."
1192
},
1193
"plcToken": {
1194
+
"title": "移行を確認",
1195
+
"desc": "古いアカウントに登録されているメールアドレスに確認コードが送信されました。",
1196
+
"info": "このコードはアカウントへのアクセス権を確認し、このPDSを指すようにアイデンティティを更新することを承認します。",
1197
+
"tokenLabel": "確認コード",
1198
+
"tokenPlaceholder": "メールに記載されたコードを入力",
1199
+
"resend": "コードを再送信",
1200
+
"complete": "移行を完了",
1201
+
"completing": "確認中..."
1202
},
1203
"didWebUpdate": {
1204
"title": "DIDドキュメントを更新",
···
1221
"success": {
1222
"title": "移行完了!",
1223
"desc": "アカウントはこのPDSに正常に移行されました。",
1224
+
"yourNewHandle": "新しいハンドル",
1225
"did": "DID",
1226
+
"blobsWarning": "{count}個のblobを移行できませんでした。これらは利用できなくなった画像やその他のメディアの可能性があります。",
1227
+
"redirecting": "ダッシュボードにリダイレクト中..."
1228
+
},
1229
+
"error": {
1230
+
"title": "移行エラー",
1231
+
"desc": "移行中にエラーが発生しました。",
1232
+
"startOver": "最初からやり直す"
1233
+
},
1234
+
"common": {
1235
+
"back": "戻る",
1236
+
"cancel": "キャンセル",
1237
+
"continue": "続行",
1238
+
"whatWillHappen": "何が起こるか:",
1239
+
"step1": "現在のPDSにログイン",
1240
+
"step2": "このサーバーでの新しいハンドルを選択",
1241
+
"step3": "リポジトリとblobが転送されます",
1242
+
"step4": "メールで移行を確認",
1243
+
"step5": "アイデンティティがここを指すように更新されます",
1244
+
"beforeProceed": "続行する前に:",
1245
+
"warning1": "現在のアカウントに登録されているメールへのアクセスが必要です",
1246
+
"warning2": "大きなアカウントの転送には数分かかる場合があります",
1247
+
"warning3": "移行後、古いアカウントは無効化されます"
1248
}
1249
},
1250
"outbound": {
+118
-32
frontend/src/locales/ko.json
+118
-32
frontend/src/locales/ko.json
···
189
"title": "DID 문서 편집기",
190
"preview": "현재 DID 문서",
191
"verificationMethods": "검증 방법 (서명 키)",
192
"addKey": "키 추가",
193
"removeKey": "삭제",
194
"keyId": "키 ID",
195
"keyIdPlaceholder": "#atproto",
196
"publicKey": "공개 키 (Multibase)",
197
"publicKeyPlaceholder": "zQ3sh...",
198
"alsoKnownAs": "다른 이름 (핸들)",
199
"addHandle": "핸들 추가",
200
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "서비스 엔드포인트 (현재 PDS)",
202
"save": "변경사항 저장",
203
"saving": "저장 중...",
204
"success": "DID 문서가 업데이트되었습니다",
205
"helpTitle": "이것은 무엇인가요?",
206
"helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
207
},
···
890
"reauth": {
891
"title": "재인증 필요",
892
"subtitle": "계속하려면 본인 확인을 해주세요.",
893
"usePassword": "비밀번호 사용",
894
"usePasskey": "패스키 사용",
895
"useTotp": "인증 앱 사용",
···
897
"totpPlaceholder": "6자리 코드 입력",
898
"verify": "확인",
899
"verifying": "확인 중...",
900
"cancel": "취소"
901
},
902
"verifyChannel": {
···
1059
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1060
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1061
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1062
"resume": {
1063
"title": "마이그레이션을 재개하시겠습니까?",
1064
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
···
1078
"desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.",
1079
"understand": "위험을 이해하고 계속 진행합니다"
1080
},
1081
-
"sourceLogin": {
1082
-
"title": "현재 PDS에 로그인",
1083
-
"desc": "마이그레이션할 계정의 인증 정보를 입력하세요.",
1084
"handle": "핸들",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "비밀번호",
1087
-
"twoFactorCode": "2단계 인증 코드",
1088
-
"twoFactorRequired": "2단계 인증이 필요합니다",
1089
-
"signIn": "로그인 및 계속"
1090
},
1091
"chooseHandle": {
1092
"title": "새 핸들 선택",
1093
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
1094
-
"handleHint": "전체 핸들: @{handle}"
1095
},
1096
"review": {
1097
"title": "마이그레이션 검토",
1098
-
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1099
"currentHandle": "현재 핸들",
1100
"newHandle": "새 핸들",
1101
"sourcePds": "소스 PDS",
1102
-
"targetPds": "이 PDS",
1103
"email": "이메일",
1104
"inviteCode": "초대 코드",
1105
-
"confirm": "계정 마이그레이션을 확인합니다",
1106
-
"startMigration": "마이그레이션 시작"
1107
},
1108
"migrating": {
1109
-
"title": "계정 마이그레이션 중",
1110
-
"desc": "데이터를 전송하는 중입니다...",
1111
-
"gettingServiceAuth": "서비스 인증 획득 중...",
1112
-
"creatingAccount": "새 PDS에 계정 생성 중...",
1113
-
"exportingRepo": "저장소 내보내기 중...",
1114
-
"importingRepo": "저장소 가져오기 중...",
1115
-
"countingBlobs": "blob 개수 세는 중...",
1116
-
"migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...",
1117
-
"migratingPrefs": "환경설정 마이그레이션 중...",
1118
-
"requestingPlc": "PLC 작업 요청 중..."
1119
},
1120
"emailVerify": {
1121
"title": "이메일 인증",
···
1128
"verifying": "인증 중..."
1129
},
1130
"plcToken": {
1131
-
"title": "신원 확인",
1132
-
"desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.",
1133
-
"tokenLabel": "인증 토큰",
1134
-
"tokenPlaceholder": "이메일에서 받은 토큰 입력",
1135
-
"resend": "재전송",
1136
-
"resending": "전송 중..."
1137
},
1138
"didWebUpdate": {
1139
"title": "DID 문서 업데이트",
···
1156
"success": {
1157
"title": "마이그레이션 완료!",
1158
"desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.",
1159
-
"newHandle": "새 핸들",
1160
"did": "DID",
1161
-
"goToDashboard": "대시보드로 이동"
1162
}
1163
},
1164
"outbound": {
···
189
"title": "DID 문서 편집기",
190
"preview": "현재 DID 문서",
191
"verificationMethods": "검증 방법 (서명 키)",
192
+
"verificationMethodsDesc": "DID를 대신하여 동작할 수 있는 서명 키입니다. 새 PDS로 마이그레이션할 때 해당 서명 키를 여기에 추가하세요.",
193
"addKey": "키 추가",
194
"removeKey": "삭제",
195
"keyId": "키 ID",
196
"keyIdPlaceholder": "#atproto",
197
"publicKey": "공개 키 (Multibase)",
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "구성된 검증 방법이 없습니다. 로컬 PDS 키를 사용 중입니다.",
200
"alsoKnownAs": "다른 이름 (핸들)",
201
+
"alsoKnownAsDesc": "DID를 가리키는 핸들입니다. 새 PDS에서 핸들이 변경되면 업데이트하세요.",
202
"addHandle": "핸들 추가",
203
+
"removeHandle": "삭제",
204
+
"handle": "핸들",
205
"handlePlaceholder": "at://handle.pds.com",
206
+
"noHandles": "구성된 핸들이 없습니다. 로컬 핸들을 사용 중입니다.",
207
+
"serviceEndpoint": "서비스 엔드포인트",
208
+
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
209
+
"currentPds": "현재 PDS URL",
210
"save": "변경사항 저장",
211
"saving": "저장 중...",
212
"success": "DID 문서가 업데이트되었습니다",
213
+
"saveFailed": "DID 문서 저장에 실패했습니다",
214
+
"loadFailed": "DID 문서 로드에 실패했습니다",
215
+
"invalidMultibase": "공개 키는 'z'로 시작하는 유효한 multibase 문자열이어야 합니다",
216
+
"invalidHandle": "핸들은 at:// URI여야 합니다 (예: at://handle.example.com)",
217
"helpTitle": "이것은 무엇인가요?",
218
"helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
219
},
···
902
"reauth": {
903
"title": "재인증 필요",
904
"subtitle": "계속하려면 본인 확인을 해주세요.",
905
+
"password": "비밀번호",
906
+
"totp": "TOTP",
907
+
"passkey": "패스키",
908
+
"authenticatorCode": "인증 코드",
909
"usePassword": "비밀번호 사용",
910
"usePasskey": "패스키 사용",
911
"useTotp": "인증 앱 사용",
···
913
"totpPlaceholder": "6자리 코드 입력",
914
"verify": "확인",
915
"verifying": "확인 중...",
916
+
"authenticating": "인증 중...",
917
+
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
918
"cancel": "취소"
919
},
920
"verifyChannel": {
···
1077
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1078
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1079
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1080
+
"comingSoon": "곧 출시 예정",
1081
+
"oauthCompleting": "인증 완료 중...",
1082
+
"oauthFailed": "인증 실패",
1083
+
"tryAgain": "다시 시도",
1084
"resume": {
1085
"title": "마이그레이션을 재개하시겠습니까?",
1086
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
···
1100
"desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.",
1101
"understand": "위험을 이해하고 계속 진행합니다"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "현재 핸들 입력",
1105
+
"titleResume": "마이그레이션 재개",
1106
+
"desc": "마이그레이션할 계정의 핸들을 입력하세요.",
1107
+
"descResume": "마이그레이션을 계속하려면 소스 PDS에 재인증하세요.",
1108
"handle": "핸들",
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "현재 PDS에서의 핸들",
1111
+
"continue": "계속",
1112
+
"connecting": "연결 중...",
1113
+
"reauthenticate": "재인증",
1114
+
"resumeTitle": "마이그레이션 진행 중",
1115
+
"resumeFrom": "출발지",
1116
+
"resumeTo": "목적지",
1117
+
"resumeProgress": "진행 상황",
1118
+
"resumeOAuthNote": "계속하려면 OAuth로 재인증이 필요합니다."
1119
},
1120
"chooseHandle": {
1121
"title": "새 핸들 선택",
1122
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
1123
+
"migratingFrom": "마이그레이션 원본",
1124
+
"newHandle": "새 핸들",
1125
+
"checkingAvailability": "사용 가능 여부 확인 중...",
1126
+
"handleAvailable": "핸들을 사용할 수 있습니다!",
1127
+
"handleTaken": "핸들이 이미 사용 중입니다",
1128
+
"handleHint": "전체 핸들(예: alice.mydomain.com)을 입력하여 자체 도메인을 사용할 수도 있습니다",
1129
+
"email": "이메일 주소",
1130
+
"authMethod": "인증 방법",
1131
+
"authPassword": "비밀번호",
1132
+
"authPasswordDesc": "기존 비밀번호 기반 로그인",
1133
+
"authPasskey": "패스키",
1134
+
"authPasskeyDesc": "생체 인식 또는 보안 키를 사용한 비밀번호 없는 로그인",
1135
+
"password": "비밀번호",
1136
+
"passwordHint": "최소 8자",
1137
+
"passkeyInfo": "계정 생성 후 패스키를 설정합니다. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.",
1138
+
"inviteCode": "초대 코드"
1139
},
1140
"review": {
1141
"title": "마이그레이션 검토",
1142
+
"desc": "마이그레이션 세부 정보를 확인하세요.",
1143
"currentHandle": "현재 핸들",
1144
"newHandle": "새 핸들",
1145
+
"did": "DID",
1146
"sourcePds": "소스 PDS",
1147
+
"targetPds": "대상 PDS",
1148
"email": "이메일",
1149
+
"authentication": "인증",
1150
+
"authPasskey": "패스키 (비밀번호 없음)",
1151
+
"authPassword": "비밀번호",
1152
"inviteCode": "초대 코드",
1153
+
"warning": "\"마이그레이션 시작\"을 클릭하면 저장소와 데이터 전송이 시작됩니다. 이 과정은 쉽게 되돌릴 수 없습니다.",
1154
+
"startMigration": "마이그레이션 시작",
1155
+
"starting": "시작 중..."
1156
},
1157
"migrating": {
1158
+
"title": "마이그레이션 진행 중",
1159
+
"desc": "계정을 전송하는 중입니다...",
1160
+
"exportRepo": "저장소 내보내기",
1161
+
"importRepo": "저장소 가져오기",
1162
+
"migrateBlobs": "blob 마이그레이션",
1163
+
"migratePrefs": "환경설정 마이그레이션"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "패스키 설정",
1167
+
"desc": "이메일이 인증되었습니다. 안전한 비밀번호 없는 로그인을 위해 패스키를 설정하세요.",
1168
+
"nameLabel": "패스키 이름 (선택사항)",
1169
+
"namePlaceholder": "예: MacBook Pro, iPhone",
1170
+
"nameHint": "이 패스키를 식별하기 위한 이름",
1171
+
"instructions": "아래 버튼을 클릭하여 패스키를 등록하세요. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.",
1172
+
"register": "패스키 등록",
1173
+
"registering": "등록 중..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "앱 비밀번호 저장",
1177
+
"desc": "패스키가 생성되었습니다. 아직 패스키를 지원하지 않는 앱에서 사용할 앱 비밀번호가 생성되었습니다.",
1178
+
"warning": "이 앱 비밀번호는 아직 패스키를 지원하지 않는 앱(예: bsky.app)에 로그인할 때 필요합니다. 이 비밀번호는 한 번만 표시됩니다.",
1179
+
"label": "앱 비밀번호:",
1180
+
"saved": "앱 비밀번호를 안전한 곳에 저장했습니다",
1181
+
"continue": "계속"
1182
},
1183
"emailVerify": {
1184
"title": "이메일 인증",
···
1191
"verifying": "인증 중..."
1192
},
1193
"plcToken": {
1194
+
"title": "마이그레이션 확인",
1195
+
"desc": "이전 계정에 등록된 이메일로 인증 코드가 전송되었습니다.",
1196
+
"info": "이 코드는 계정 접근 권한을 확인하고 이 PDS를 가리키도록 아이덴티티 업데이트를 승인합니다.",
1197
+
"tokenLabel": "인증 코드",
1198
+
"tokenPlaceholder": "이메일에서 받은 코드 입력",
1199
+
"resend": "코드 재전송",
1200
+
"complete": "마이그레이션 완료",
1201
+
"completing": "확인 중..."
1202
},
1203
"didWebUpdate": {
1204
"title": "DID 문서 업데이트",
···
1221
"success": {
1222
"title": "마이그레이션 완료!",
1223
"desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.",
1224
+
"yourNewHandle": "새 핸들",
1225
"did": "DID",
1226
+
"blobsWarning": "{count}개의 blob을 마이그레이션할 수 없습니다. 더 이상 사용할 수 없는 이미지나 기타 미디어일 수 있습니다.",
1227
+
"redirecting": "대시보드로 리디렉션 중..."
1228
+
},
1229
+
"error": {
1230
+
"title": "마이그레이션 오류",
1231
+
"desc": "마이그레이션 중 오류가 발생했습니다.",
1232
+
"startOver": "처음부터 다시 시작"
1233
+
},
1234
+
"common": {
1235
+
"back": "뒤로",
1236
+
"cancel": "취소",
1237
+
"continue": "계속",
1238
+
"whatWillHappen": "진행 과정:",
1239
+
"step1": "현재 PDS에 로그인",
1240
+
"step2": "이 서버에서 새 핸들 선택",
1241
+
"step3": "저장소와 blob이 전송됩니다",
1242
+
"step4": "이메일로 마이그레이션 확인",
1243
+
"step5": "아이덴티티가 여기를 가리키도록 업데이트됩니다",
1244
+
"beforeProceed": "진행하기 전에:",
1245
+
"warning1": "현재 계정에 등록된 이메일에 접근할 수 있어야 합니다",
1246
+
"warning2": "대용량 계정 전송에는 몇 분이 걸릴 수 있습니다",
1247
+
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
1248
}
1249
},
1250
"outbound": {
+119
-33
frontend/src/locales/sv.json
+119
-33
frontend/src/locales/sv.json
···
189
"title": "DID-dokumentredigerare",
190
"preview": "Nuvarande DID-dokument",
191
"verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
192
"addKey": "Lägg till nyckel",
193
"removeKey": "Ta bort",
194
"keyId": "Nyckel-ID",
195
"keyIdPlaceholder": "#atproto",
196
"publicKey": "Publik nyckel (Multibase)",
197
"publicKeyPlaceholder": "zQ3sh...",
198
"alsoKnownAs": "Även känd som (användarnamn)",
199
"addHandle": "Lägg till användarnamn",
200
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)",
202
"save": "Spara ändringar",
203
"saving": "Sparar...",
204
"success": "DID-dokumentet har uppdaterats",
205
"helpTitle": "Vad är detta?",
206
"helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats."
207
},
···
890
"reauth": {
891
"title": "Återautentisering krävs",
892
"subtitle": "Verifiera din identitet för att fortsätta.",
893
"usePassword": "Använd lösenord",
894
"usePasskey": "Använd nyckel",
895
"useTotp": "Använd autentiserare",
···
897
"totpPlaceholder": "Ange 6-siffrig kod",
898
"verify": "Verifiera",
899
"verifying": "Verifierar...",
900
"cancel": "Avbryt"
901
},
902
"verifyChannel": {
···
1059
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1060
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1061
"learnMore": "Läs mer om flyttningsrisker",
1062
"resume": {
1063
"title": "Återuppta flytt?",
1064
"incomplete": "Du har en ofullständig flytt pågående:",
···
1078
"desc": "Flytta ditt befintliga AT Protocol-konto till denna server.",
1079
"understand": "Jag förstår riskerna och vill fortsätta"
1080
},
1081
-
"sourceLogin": {
1082
-
"title": "Logga in på din nuvarande PDS",
1083
-
"desc": "Ange uppgifterna för kontot du vill flytta.",
1084
"handle": "Användarnamn",
1085
-
"handlePlaceholder": "du.bsky.social",
1086
-
"password": "Lösenord",
1087
-
"twoFactorCode": "Tvåfaktorkod",
1088
-
"twoFactorRequired": "Tvåfaktorautentisering krävs",
1089
-
"signIn": "Logga in och fortsätt"
1090
},
1091
"chooseHandle": {
1092
"title": "Välj ditt nya användarnamn",
1093
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
1094
-
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}"
1095
},
1096
"review": {
1097
"title": "Granska flytt",
1098
-
"desc": "Granska och bekräfta dina flyttdetaljer.",
1099
"currentHandle": "Nuvarande användarnamn",
1100
"newHandle": "Nytt användarnamn",
1101
-
"sourcePds": "Käll-PDS",
1102
-
"targetPds": "Denna PDS",
1103
"email": "E-post",
1104
"inviteCode": "Inbjudningskod",
1105
-
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1106
-
"startMigration": "Starta flytt"
1107
},
1108
"migrating": {
1109
-
"title": "Flyttar ditt konto",
1110
-
"desc": "Vänta medan vi överför din data...",
1111
-
"gettingServiceAuth": "Hämtar tjänstauktorisering...",
1112
-
"creatingAccount": "Skapar konto på ny PDS...",
1113
-
"exportingRepo": "Exporterar arkiv...",
1114
-
"importingRepo": "Importerar arkiv...",
1115
-
"countingBlobs": "Räknar blobbar...",
1116
-
"migratingBlobs": "Flyttar blobbar ({current}/{total})...",
1117
-
"migratingPrefs": "Flyttar inställningar...",
1118
-
"requestingPlc": "Begär PLC-operation..."
1119
},
1120
"emailVerify": {
1121
"title": "Verifiera din e-post",
···
1128
"verifying": "Verifierar..."
1129
},
1130
"plcToken": {
1131
-
"title": "Verifiera din identitet",
1132
-
"desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.",
1133
-
"tokenLabel": "Verifieringstoken",
1134
-
"tokenPlaceholder": "Ange token från din e-post",
1135
-
"resend": "Skicka igen",
1136
-
"resending": "Skickar..."
1137
},
1138
"didWebUpdate": {
1139
"title": "Uppdatera ditt DID-dokument",
···
1156
"success": {
1157
"title": "Flytt klar!",
1158
"desc": "Ditt konto har framgångsrikt flyttats till denna PDS.",
1159
-
"newHandle": "Nytt användarnamn",
1160
"did": "DID",
1161
-
"goToDashboard": "Gå till instrumentpanel"
1162
}
1163
},
1164
"outbound": {
···
189
"title": "DID-dokumentredigerare",
190
"preview": "Nuvarande DID-dokument",
191
"verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
192
+
"verificationMethodsDesc": "Signeringsnycklar som kan agera å din DIDs vägnar. När du migrerar till en ny PDS, lägg till deras signeringsnyckel här.",
193
"addKey": "Lägg till nyckel",
194
"removeKey": "Ta bort",
195
"keyId": "Nyckel-ID",
196
"keyIdPlaceholder": "#atproto",
197
"publicKey": "Publik nyckel (Multibase)",
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "Inga verifieringsmetoder konfigurerade. Använder lokal PDS-nyckel.",
200
"alsoKnownAs": "Även känd som (användarnamn)",
201
+
"alsoKnownAsDesc": "Användarnamn som pekar på din DID. Uppdatera detta när ditt användarnamn ändras på en ny PDS.",
202
"addHandle": "Lägg till användarnamn",
203
+
"removeHandle": "Ta bort",
204
+
"handle": "Användarnamn",
205
"handlePlaceholder": "at://handle.pds.com",
206
+
"noHandles": "Inga användarnamn konfigurerade. Använder lokalt användarnamn.",
207
+
"serviceEndpoint": "Tjänstslutpunkt",
208
+
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
209
+
"currentPds": "Nuvarande PDS-URL",
210
"save": "Spara ändringar",
211
"saving": "Sparar...",
212
"success": "DID-dokumentet har uppdaterats",
213
+
"saveFailed": "Kunde inte spara DID-dokument",
214
+
"loadFailed": "Kunde inte ladda DID-dokument",
215
+
"invalidMultibase": "Publik nyckel måste vara en giltig multibase-sträng som börjar med 'z'",
216
+
"invalidHandle": "Användarnamn måste vara en at:// URI (t.ex. at://handle.example.com)",
217
"helpTitle": "Vad är detta?",
218
"helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats."
219
},
···
902
"reauth": {
903
"title": "Återautentisering krävs",
904
"subtitle": "Verifiera din identitet för att fortsätta.",
905
+
"password": "Lösenord",
906
+
"totp": "TOTP",
907
+
"passkey": "Passkey",
908
+
"authenticatorCode": "Autentiseringskod",
909
"usePassword": "Använd lösenord",
910
"usePasskey": "Använd nyckel",
911
"useTotp": "Använd autentiserare",
···
913
"totpPlaceholder": "Ange 6-siffrig kod",
914
"verify": "Verifiera",
915
"verifying": "Verifierar...",
916
+
"authenticating": "Autentiserar...",
917
+
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
918
"cancel": "Avbryt"
919
},
920
"verifyChannel": {
···
1077
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1078
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1079
"learnMore": "Läs mer om flyttningsrisker",
1080
+
"comingSoon": "Kommer snart",
1081
+
"oauthCompleting": "Slutför autentisering...",
1082
+
"oauthFailed": "Autentisering misslyckades",
1083
+
"tryAgain": "Försök igen",
1084
"resume": {
1085
"title": "Återuppta flytt?",
1086
"incomplete": "Du har en ofullständig flytt pågående:",
···
1100
"desc": "Flytta ditt befintliga AT Protocol-konto till denna server.",
1101
"understand": "Jag förstår riskerna och vill fortsätta"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "Ange ditt nuvarande användarnamn",
1105
+
"titleResume": "Återuppta flytt",
1106
+
"desc": "Ange användarnamnet för kontot du vill flytta.",
1107
+
"descResume": "Autentisera dig igen till din käll-PDS för att fortsätta flytten.",
1108
"handle": "Användarnamn",
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "Ditt nuvarande användarnamn på din befintliga PDS",
1111
+
"continue": "Fortsätt",
1112
+
"connecting": "Ansluter...",
1113
+
"reauthenticate": "Autentisera igen",
1114
+
"resumeTitle": "Flytt pågår",
1115
+
"resumeFrom": "Från",
1116
+
"resumeTo": "Till",
1117
+
"resumeProgress": "Framsteg",
1118
+
"resumeOAuthNote": "Du måste autentisera dig igen via OAuth för att fortsätta."
1119
},
1120
"chooseHandle": {
1121
"title": "Välj ditt nya användarnamn",
1122
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
1123
+
"migratingFrom": "Flyttar från",
1124
+
"newHandle": "Nytt användarnamn",
1125
+
"checkingAvailability": "Kontrollerar tillgänglighet...",
1126
+
"handleAvailable": "Användarnamnet är tillgängligt!",
1127
+
"handleTaken": "Användarnamnet är redan taget",
1128
+
"handleHint": "Du kan också använda din egen domän genom att ange det fullständiga användarnamnet (t.ex. alice.mindomän.se)",
1129
+
"email": "E-postadress",
1130
+
"authMethod": "Autentiseringsmetod",
1131
+
"authPassword": "Lösenord",
1132
+
"authPasswordDesc": "Traditionell lösenordsbaserad inloggning",
1133
+
"authPasskey": "Passkey",
1134
+
"authPasskeyDesc": "Lösenordslös inloggning med biometri eller säkerhetsnyckel",
1135
+
"password": "Lösenord",
1136
+
"passwordHint": "Minst 8 tecken",
1137
+
"passkeyInfo": "Du kommer att konfigurera en passkey efter att ditt konto skapats. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.",
1138
+
"inviteCode": "Inbjudningskod"
1139
},
1140
"review": {
1141
"title": "Granska flytt",
1142
+
"desc": "Bekräfta detaljerna för din flytt.",
1143
"currentHandle": "Nuvarande användarnamn",
1144
"newHandle": "Nytt användarnamn",
1145
+
"did": "DID",
1146
+
"sourcePds": "Från PDS",
1147
+
"targetPds": "Till PDS",
1148
"email": "E-post",
1149
+
"authentication": "Autentisering",
1150
+
"authPasskey": "Passkey (lösenordslös)",
1151
+
"authPassword": "Lösenord",
1152
"inviteCode": "Inbjudningskod",
1153
+
"warning": "När du klickar på \"Starta flytt\" börjar ditt arkiv och data överföras. Denna process kan inte enkelt ångras.",
1154
+
"startMigration": "Starta flytt",
1155
+
"starting": "Startar..."
1156
},
1157
"migrating": {
1158
+
"title": "Flytt pågår",
1159
+
"desc": "Vänta medan ditt konto överförs...",
1160
+
"exportRepo": "Exportera arkiv",
1161
+
"importRepo": "Importera arkiv",
1162
+
"migrateBlobs": "Flytta blobbar",
1163
+
"migratePrefs": "Flytta inställningar"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Konfigurera din passkey",
1167
+
"desc": "Din e-post har verifierats. Konfigurera nu din passkey för säker, lösenordslös inloggning.",
1168
+
"nameLabel": "Passkey-namn (valfritt)",
1169
+
"namePlaceholder": "t.ex. MacBook Pro, iPhone",
1170
+
"nameHint": "Ett vänligt namn för att identifiera denna passkey",
1171
+
"instructions": "Klicka på knappen nedan för att registrera din passkey. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.",
1172
+
"register": "Registrera passkey",
1173
+
"registering": "Registrerar..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Spara ditt applösenord",
1177
+
"desc": "Din passkey har skapats. Ett applösenord har genererats för dig att använda med appar som inte stödjer passkeys ännu.",
1178
+
"warning": "Detta applösenord krävs för att logga in i appar som inte stödjer passkeys ännu (som bsky.app). Du kommer bara att se detta lösenord en gång.",
1179
+
"label": "Applösenord för",
1180
+
"saved": "Jag har sparat mitt applösenord på en säker plats",
1181
+
"continue": "Fortsätt"
1182
},
1183
"emailVerify": {
1184
"title": "Verifiera din e-post",
···
1191
"verifying": "Verifierar..."
1192
},
1193
"plcToken": {
1194
+
"title": "Verifiera flytt",
1195
+
"desc": "En verifieringskod har skickats till e-posten registrerad på ditt gamla konto.",
1196
+
"info": "Denna kod bekräftar att du har tillgång till kontot och auktoriserar uppdatering av din identitet för att peka på denna PDS.",
1197
+
"tokenLabel": "Verifieringskod",
1198
+
"tokenPlaceholder": "Ange kod från e-post",
1199
+
"resend": "Skicka kod igen",
1200
+
"complete": "Slutför flytt",
1201
+
"completing": "Verifierar..."
1202
},
1203
"didWebUpdate": {
1204
"title": "Uppdatera ditt DID-dokument",
···
1221
"success": {
1222
"title": "Flytt klar!",
1223
"desc": "Ditt konto har framgångsrikt flyttats till denna PDS.",
1224
+
"yourNewHandle": "Ditt nya användarnamn",
1225
"did": "DID",
1226
+
"blobsWarning": "{count} blobbar kunde inte flyttas. Dessa kan vara bilder eller annan media som inte längre är tillgängliga.",
1227
+
"redirecting": "Omdirigerar till instrumentpanel..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Flyttfel",
1231
+
"desc": "Ett fel uppstod under flytten.",
1232
+
"startOver": "Börja om"
1233
+
},
1234
+
"common": {
1235
+
"back": "Tillbaka",
1236
+
"cancel": "Avbryt",
1237
+
"continue": "Fortsätt",
1238
+
"whatWillHappen": "Vad som kommer att hända:",
1239
+
"step1": "Logga in på din nuvarande PDS",
1240
+
"step2": "Välj ditt nya användarnamn på denna server",
1241
+
"step3": "Ditt arkiv och blobbar kommer att överföras",
1242
+
"step4": "Verifiera flytten via e-post",
1243
+
"step5": "Din identitet kommer att uppdateras för att peka hit",
1244
+
"beforeProceed": "Innan du fortsätter:",
1245
+
"warning1": "Du behöver tillgång till e-posten registrerad på ditt nuvarande konto",
1246
+
"warning2": "Stora konton kan ta flera minuter att överföra",
1247
+
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
1248
}
1249
},
1250
"outbound": {
+117
-31
frontend/src/locales/zh.json
+117
-31
frontend/src/locales/zh.json
···
189
"title": "DID 文档编辑器",
190
"preview": "当前 DID 文档",
191
"verificationMethods": "验证方法(签名密钥)",
192
"addKey": "添加密钥",
193
"removeKey": "删除",
194
"keyId": "密钥 ID",
195
"keyIdPlaceholder": "#atproto",
196
"publicKey": "公钥(Multibase)",
197
"publicKeyPlaceholder": "zQ3sh...",
198
"alsoKnownAs": "别名(用户名)",
199
"addHandle": "添加用户名",
200
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "服务端点(当前 PDS)",
202
"save": "保存更改",
203
"saving": "保存中...",
204
"success": "DID 文档已更新",
205
"helpTitle": "这是什么?",
206
"helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。"
207
},
···
890
"reauth": {
891
"title": "需要重新验证",
892
"subtitle": "请验证您的身份以继续。",
893
"usePassword": "使用密码",
894
"usePasskey": "使用通行密钥",
895
"useTotp": "使用身份验证器",
···
897
"totpPlaceholder": "输入6位验证码",
898
"verify": "验证",
899
"verifying": "验证中...",
900
"cancel": "取消"
901
},
902
"verifyChannel": {
···
1059
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1060
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1061
"learnMore": "了解更多迁移风险",
1062
"resume": {
1063
"title": "恢复迁移?",
1064
"incomplete": "您有一个未完成的迁移:",
···
1078
"desc": "将您现有的AT Protocol账户移至此服务器。",
1079
"understand": "我了解风险并希望继续"
1080
},
1081
-
"sourceLogin": {
1082
-
"title": "登录到您当前的PDS",
1083
-
"desc": "输入您要迁移的账户凭据。",
1084
"handle": "用户名",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "密码",
1087
-
"twoFactorCode": "双因素验证码",
1088
-
"twoFactorRequired": "需要双因素认证",
1089
-
"signIn": "登录并继续"
1090
},
1091
"chooseHandle": {
1092
"title": "选择新用户名",
1093
"desc": "为您在此PDS上的账户选择用户名。",
1094
-
"handleHint": "您的完整用户名将是:@{handle}"
1095
},
1096
"review": {
1097
"title": "检查迁移",
1098
-
"desc": "请检查并确认您的迁移详情。",
1099
"currentHandle": "当前用户名",
1100
"newHandle": "新用户名",
1101
"sourcePds": "源PDS",
1102
-
"targetPds": "此PDS",
1103
"email": "邮箱",
1104
"inviteCode": "邀请码",
1105
-
"confirm": "我确认要迁移我的账户",
1106
-
"startMigration": "开始迁移"
1107
},
1108
"migrating": {
1109
-
"title": "正在迁移您的账户",
1110
-
"desc": "请稍候,正在转移您的数据...",
1111
-
"gettingServiceAuth": "正在获取服务授权...",
1112
-
"creatingAccount": "正在新PDS上创建账户...",
1113
-
"exportingRepo": "正在导出存储库...",
1114
-
"importingRepo": "正在导入存储库...",
1115
-
"countingBlobs": "正在统计blob...",
1116
-
"migratingBlobs": "正在迁移blob ({current}/{total})...",
1117
-
"migratingPrefs": "正在迁移偏好设置...",
1118
-
"requestingPlc": "正在请求PLC操作..."
1119
},
1120
"emailVerify": {
1121
"title": "验证您的邮箱",
···
1128
"verifying": "验证中..."
1129
},
1130
"plcToken": {
1131
-
"title": "验证您的身份",
1132
-
"desc": "验证码已发送到您在当前PDS注册的邮箱。",
1133
-
"tokenLabel": "验证令牌",
1134
-
"tokenPlaceholder": "输入邮件中的令牌",
1135
"resend": "重新发送",
1136
-
"resending": "发送中..."
1137
},
1138
"didWebUpdate": {
1139
"title": "更新您的DID文档",
···
1156
"success": {
1157
"title": "迁移完成!",
1158
"desc": "您的账户已成功迁移到此PDS。",
1159
-
"newHandle": "新用户名",
1160
"did": "DID",
1161
-
"goToDashboard": "前往仪表板"
1162
}
1163
},
1164
"outbound": {
···
189
"title": "DID 文档编辑器",
190
"preview": "当前 DID 文档",
191
"verificationMethods": "验证方法(签名密钥)",
192
+
"verificationMethodsDesc": "可以代表您的 DID 进行操作的签名密钥。迁移到新 PDS 时,请在此添加其签名密钥。",
193
"addKey": "添加密钥",
194
"removeKey": "删除",
195
"keyId": "密钥 ID",
196
"keyIdPlaceholder": "#atproto",
197
"publicKey": "公钥(Multibase)",
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "未配置验证方法。正在使用本地 PDS 密钥。",
200
"alsoKnownAs": "别名(用户名)",
201
+
"alsoKnownAsDesc": "指向您的 DID 的用户名。当您在新 PDS 上更改用户名时请更新此项。",
202
"addHandle": "添加用户名",
203
+
"removeHandle": "删除",
204
+
"handle": "用户名",
205
"handlePlaceholder": "at://handle.pds.com",
206
+
"noHandles": "未配置用户名。正在使用本地用户名。",
207
+
"serviceEndpoint": "服务端点",
208
+
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
209
+
"currentPds": "当前 PDS URL",
210
"save": "保存更改",
211
"saving": "保存中...",
212
"success": "DID 文档已更新",
213
+
"saveFailed": "保存 DID 文档失败",
214
+
"loadFailed": "加载 DID 文档失败",
215
+
"invalidMultibase": "公钥必须是以 'z' 开头的有效 multibase 字符串",
216
+
"invalidHandle": "用户名必须是 at:// URI(例如:at://handle.example.com)",
217
"helpTitle": "这是什么?",
218
"helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。"
219
},
···
902
"reauth": {
903
"title": "需要重新验证",
904
"subtitle": "请验证您的身份以继续。",
905
+
"password": "密码",
906
+
"totp": "TOTP",
907
+
"passkey": "通行密钥",
908
+
"authenticatorCode": "验证码",
909
"usePassword": "使用密码",
910
"usePasskey": "使用通行密钥",
911
"useTotp": "使用身份验证器",
···
913
"totpPlaceholder": "输入6位验证码",
914
"verify": "验证",
915
"verifying": "验证中...",
916
+
"authenticating": "正在验证...",
917
+
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
918
"cancel": "取消"
919
},
920
"verifyChannel": {
···
1077
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1078
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1079
"learnMore": "了解更多迁移风险",
1080
+
"comingSoon": "即将推出",
1081
+
"oauthCompleting": "正在完成身份验证...",
1082
+
"oauthFailed": "身份验证失败",
1083
+
"tryAgain": "重试",
1084
"resume": {
1085
"title": "恢复迁移?",
1086
"incomplete": "您有一个未完成的迁移:",
···
1100
"desc": "将您现有的AT Protocol账户移至此服务器。",
1101
"understand": "我了解风险并希望继续"
1102
},
1103
+
"sourceAuth": {
1104
+
"title": "输入您当前的用户名",
1105
+
"titleResume": "恢复迁移",
1106
+
"desc": "输入您要迁移的账户用户名。",
1107
+
"descResume": "重新验证您的源PDS以继续迁移。",
1108
"handle": "用户名",
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "您在现有PDS上的当前用户名",
1111
+
"continue": "继续",
1112
+
"connecting": "连接中...",
1113
+
"reauthenticate": "重新验证",
1114
+
"resumeTitle": "迁移进行中",
1115
+
"resumeFrom": "来自",
1116
+
"resumeTo": "迁移至",
1117
+
"resumeProgress": "进度",
1118
+
"resumeOAuthNote": "您需要通过OAuth重新验证才能继续。"
1119
},
1120
"chooseHandle": {
1121
"title": "选择新用户名",
1122
"desc": "为您在此PDS上的账户选择用户名。",
1123
+
"migratingFrom": "迁移自",
1124
+
"newHandle": "新用户名",
1125
+
"checkingAvailability": "检查可用性...",
1126
+
"handleAvailable": "用户名可用!",
1127
+
"handleTaken": "用户名已被占用",
1128
+
"handleHint": "您也可以输入完整的用户名(如alice.mydomain.com)来使用您自己的域名",
1129
+
"email": "邮箱地址",
1130
+
"authMethod": "身份验证方式",
1131
+
"authPassword": "密码",
1132
+
"authPasswordDesc": "传统的密码登录",
1133
+
"authPasskey": "通行密钥",
1134
+
"authPasskeyDesc": "使用生物识别或安全密钥的无密码登录",
1135
+
"password": "密码",
1136
+
"passwordHint": "至少8个字符",
1137
+
"passkeyInfo": "您将在账户创建后设置通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。",
1138
+
"inviteCode": "邀请码"
1139
},
1140
"review": {
1141
"title": "检查迁移",
1142
+
"desc": "确认您的迁移详情。",
1143
"currentHandle": "当前用户名",
1144
"newHandle": "新用户名",
1145
+
"did": "DID",
1146
"sourcePds": "源PDS",
1147
+
"targetPds": "目标PDS",
1148
"email": "邮箱",
1149
+
"authentication": "身份验证",
1150
+
"authPasskey": "通行密钥(无密码)",
1151
+
"authPassword": "密码",
1152
"inviteCode": "邀请码",
1153
+
"warning": "点击「开始迁移」后,您的存储库和数据将开始转移。此过程无法轻易撤销。",
1154
+
"startMigration": "开始迁移",
1155
+
"starting": "启动中..."
1156
},
1157
"migrating": {
1158
+
"title": "迁移进行中",
1159
+
"desc": "正在转移您的账户...",
1160
+
"exportRepo": "导出存储库",
1161
+
"importRepo": "导入存储库",
1162
+
"migrateBlobs": "迁移blob",
1163
+
"migratePrefs": "迁移偏好设置"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "设置您的通行密钥",
1167
+
"desc": "您的邮箱已验证。现在设置通行密钥以实现安全的无密码登录。",
1168
+
"nameLabel": "通行密钥名称(可选)",
1169
+
"namePlaceholder": "例如:MacBook Pro、iPhone",
1170
+
"nameHint": "用于识别此通行密钥的友好名称",
1171
+
"instructions": "点击下方按钮注册您的通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。",
1172
+
"register": "注册通行密钥",
1173
+
"registering": "注册中..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "保存您的应用密码",
1177
+
"desc": "您的通行密钥已创建。已为您生成应用密码,用于尚不支持通行密钥的应用。",
1178
+
"warning": "此应用密码用于登录尚不支持通行密钥的应用(如 bsky.app)。此密码仅显示一次。",
1179
+
"label": "应用密码:",
1180
+
"saved": "我已将应用密码保存在安全的地方",
1181
+
"continue": "继续"
1182
},
1183
"emailVerify": {
1184
"title": "验证您的邮箱",
···
1191
"verifying": "验证中..."
1192
},
1193
"plcToken": {
1194
+
"title": "验证迁移",
1195
+
"desc": "验证码已发送到您旧账户注册的邮箱。",
1196
+
"info": "此代码确认您有权访问该账户,并授权将您的身份更新为指向此PDS。",
1197
+
"tokenLabel": "验证码",
1198
+
"tokenPlaceholder": "输入邮件中的验证码",
1199
"resend": "重新发送",
1200
+
"complete": "完成迁移",
1201
+
"completing": "验证中..."
1202
},
1203
"didWebUpdate": {
1204
"title": "更新您的DID文档",
···
1221
"success": {
1222
"title": "迁移完成!",
1223
"desc": "您的账户已成功迁移到此PDS。",
1224
+
"yourNewHandle": "您的新用户名",
1225
"did": "DID",
1226
+
"blobsWarning": "{count}个blob无法迁移。这些可能是不再可用的图片或其他媒体。",
1227
+
"redirecting": "正在跳转到仪表板..."
1228
+
},
1229
+
"error": {
1230
+
"title": "迁移错误",
1231
+
"desc": "迁移过程中发生错误。",
1232
+
"startOver": "重新开始"
1233
+
},
1234
+
"common": {
1235
+
"back": "返回",
1236
+
"cancel": "取消",
1237
+
"continue": "继续",
1238
+
"whatWillHappen": "将会发生什么:",
1239
+
"step1": "登录到您当前的PDS",
1240
+
"step2": "在此服务器上选择新用户名",
1241
+
"step3": "您的存储库和blob将被转移",
1242
+
"step4": "通过邮件验证迁移",
1243
+
"step5": "您的身份将更新为指向此处",
1244
+
"beforeProceed": "继续之前:",
1245
+
"warning1": "您需要访问当前账户注册的邮箱",
1246
+
"warning2": "大型账户可能需要几分钟才能转移",
1247
+
"warning3": "迁移后您的旧账户将被停用"
1248
}
1249
},
1250
"outbound": {
+150
-42
frontend/src/routes/Migration.svelte
+150
-42
frontend/src/routes/Migration.svelte
···
1
<script lang="ts">
2
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import {
5
createInboundMigrationFlow,
6
createOutboundMigrationFlow,
···
18
let direction = $state<Direction>('select')
19
let showResumeModal = $state(false)
20
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
21
22
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
23
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
24
25
-
if (hasPendingMigration()) {
26
resumeInfo = getResumeInfo()
27
if (resumeInfo) {
28
-
showResumeModal = true
29
}
30
}
31
···
106
{#if showResumeModal && resumeInfo}
107
<div class="modal-overlay">
108
<div class="modal">
109
-
<h2>Resume Migration?</h2>
110
-
<p>You have an incomplete migration in progress:</p>
111
<div class="resume-details">
112
<div class="detail-row">
113
-
<span class="label">Direction:</span>
114
-
<span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span>
115
</div>
116
{#if resumeInfo.sourceHandle}
117
<div class="detail-row">
118
-
<span class="label">From:</span>
119
<span class="value">{resumeInfo.sourceHandle}</span>
120
</div>
121
{/if}
122
{#if resumeInfo.targetHandle}
123
<div class="detail-row">
124
-
<span class="label">To:</span>
125
<span class="value">{resumeInfo.targetHandle}</span>
126
</div>
127
{/if}
128
<div class="detail-row">
129
-
<span class="label">Progress:</span>
130
<span class="value">{resumeInfo.progressSummary}</span>
131
</div>
132
</div>
133
-
<p class="note">You will need to re-enter your credentials to continue.</p>
134
<div class="modal-actions">
135
-
<button class="ghost" onclick={handleStartOver}>Start Over</button>
136
-
<button onclick={handleResume}>Resume</button>
137
</div>
138
</div>
139
</div>
140
{/if}
141
142
-
{#if direction === 'select'}
143
<header class="page-header">
144
-
<h1>Account Migration</h1>
145
-
<p class="subtitle">Move your AT Protocol identity between servers</p>
146
</header>
147
148
<div class="direction-cards">
149
<button class="direction-card ghost" onclick={selectInbound}>
150
<div class="card-icon">↓</div>
151
-
<h2>Migrate Here</h2>
152
-
<p>Move your existing AT Protocol account to this PDS from another server.</p>
153
<ul class="features">
154
-
<li>Bring your DID and identity</li>
155
-
<li>Transfer all your data</li>
156
-
<li>Keep your followers</li>
157
</ul>
158
</button>
159
160
-
<button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}>
161
<div class="card-icon">↑</div>
162
-
<h2>Migrate Away</h2>
163
-
<p>Move your account from this PDS to another server.</p>
164
<ul class="features">
165
-
<li>Export your repository</li>
166
-
<li>Transfer to new PDS</li>
167
-
<li>Update your identity</li>
168
</ul>
169
-
{#if !auth.session}
170
-
<p class="login-required">Login required</p>
171
-
{/if}
172
</button>
173
</div>
174
175
<div class="info-section">
176
-
<h3>What is account migration?</h3>
177
-
<p>
178
-
Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes).
179
-
Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.
180
-
</p>
181
182
-
<h3>Before you migrate</h3>
183
<ul>
184
-
<li>You will need your current account credentials</li>
185
-
<li>Migration requires email verification for security</li>
186
-
<li>Large accounts with many images may take several minutes</li>
187
-
<li>Your old PDS will be notified to deactivate your account</li>
188
</ul>
189
190
<div class="warning-box">
191
-
<strong>Important:</strong> Account migration is a significant action. Make sure you trust the destination PDS
192
-
and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.
193
<a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
194
-
Learn more about migration risks
195
</a>
196
</div>
197
</div>
···
199
{:else if direction === 'inbound' && inboundFlow}
200
<InboundWizard
201
flow={inboundFlow}
202
onBack={handleBack}
203
onComplete={handleInboundComplete}
204
/>
···
409
display: flex;
410
gap: var(--space-3);
411
justify-content: flex-end;
412
}
413
</style>
···
1
<script lang="ts">
2
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
+
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
7
createOutboundMigrationFlow,
···
19
let direction = $state<Direction>('select')
20
let showResumeModal = $state(false)
21
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
22
+
let oauthError = $state<string | null>(null)
23
+
let oauthLoading = $state(false)
24
25
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
26
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
27
+
let oauthCallbackProcessed = $state(false)
28
+
29
+
$effect(() => {
30
+
if (oauthCallbackProcessed) return
31
32
+
const url = new URL(window.location.href)
33
+
const code = url.searchParams.get('code')
34
+
const state = url.searchParams.get('state')
35
+
const errorParam = url.searchParams.get('error')
36
+
const errorDescription = url.searchParams.get('error_description')
37
+
38
+
if (errorParam) {
39
+
oauthCallbackProcessed = true
40
+
oauthError = errorDescription || errorParam
41
+
window.history.replaceState({}, '', '/#/migrate')
42
+
return
43
+
}
44
+
45
+
if (code && state) {
46
+
oauthCallbackProcessed = true
47
+
window.history.replaceState({}, '', '/#/migrate')
48
+
direction = 'inbound'
49
+
oauthLoading = true
50
+
inboundFlow = createInboundMigrationFlow()
51
+
52
+
inboundFlow.handleOAuthCallback(code, state)
53
+
.then(() => {
54
+
oauthLoading = false
55
+
})
56
+
.catch((e) => {
57
+
oauthLoading = false
58
+
oauthError = e.message || 'OAuth authentication failed'
59
+
inboundFlow = null
60
+
direction = 'select'
61
+
})
62
+
return
63
+
}
64
+
})
65
+
66
+
const urlParams = new URLSearchParams(window.location.search)
67
+
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
68
+
69
+
if (!hasOAuthCallback && hasPendingMigration()) {
70
resumeInfo = getResumeInfo()
71
if (resumeInfo) {
72
+
const stored = loadMigrationState()
73
+
if (stored) {
74
+
if (stored.direction === 'inbound') {
75
+
direction = 'inbound'
76
+
inboundFlow = createInboundMigrationFlow()
77
+
inboundFlow.resumeFromState(stored)
78
+
} else {
79
+
direction = 'outbound'
80
+
outboundFlow = createOutboundMigrationFlow()
81
+
}
82
+
}
83
}
84
}
85
···
160
{#if showResumeModal && resumeInfo}
161
<div class="modal-overlay">
162
<div class="modal">
163
+
<h2>{$_('migration.resume.title')}</h2>
164
+
<p>{$_('migration.resume.incomplete')}</p>
165
<div class="resume-details">
166
<div class="detail-row">
167
+
<span class="label">{$_('migration.resume.direction')}:</span>
168
+
<span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
169
</div>
170
{#if resumeInfo.sourceHandle}
171
<div class="detail-row">
172
+
<span class="label">{$_('migration.resume.from')}:</span>
173
<span class="value">{resumeInfo.sourceHandle}</span>
174
</div>
175
{/if}
176
{#if resumeInfo.targetHandle}
177
<div class="detail-row">
178
+
<span class="label">{$_('migration.resume.to')}:</span>
179
<span class="value">{resumeInfo.targetHandle}</span>
180
</div>
181
{/if}
182
<div class="detail-row">
183
+
<span class="label">{$_('migration.resume.progress')}:</span>
184
<span class="value">{resumeInfo.progressSummary}</span>
185
</div>
186
</div>
187
+
<p class="note">{$_('migration.resume.reenterCredentials')}</p>
188
<div class="modal-actions">
189
+
<button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button>
190
+
<button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button>
191
</div>
192
</div>
193
</div>
194
{/if}
195
196
+
{#if oauthLoading}
197
+
<div class="oauth-loading">
198
+
<div class="loading-spinner"></div>
199
+
<p>{$_('migration.oauthCompleting')}</p>
200
+
</div>
201
+
{:else if oauthError}
202
+
<div class="oauth-error">
203
+
<h2>{$_('migration.oauthFailed')}</h2>
204
+
<p>{oauthError}</p>
205
+
<button onclick={() => { oauthError = null; direction = 'select' }}>{$_('migration.tryAgain')}</button>
206
+
</div>
207
+
{:else if direction === 'select'}
208
<header class="page-header">
209
+
<h1>{$_('migration.title')}</h1>
210
+
<p class="subtitle">{$_('migration.subtitle')}</p>
211
</header>
212
213
<div class="direction-cards">
214
<button class="direction-card ghost" onclick={selectInbound}>
215
<div class="card-icon">↓</div>
216
+
<h2>{$_('migration.migrateHere')}</h2>
217
+
<p>{$_('migration.migrateHereDesc')}</p>
218
<ul class="features">
219
+
<li>{$_('migration.bringDid')}</li>
220
+
<li>{$_('migration.transferData')}</li>
221
+
<li>{$_('migration.keepFollowers')}</li>
222
</ul>
223
</button>
224
225
+
<button class="direction-card ghost" onclick={selectOutbound} disabled>
226
<div class="card-icon">↑</div>
227
+
<h2>{$_('migration.migrateAway')}</h2>
228
+
<p>{$_('migration.migrateAwayDesc')}</p>
229
<ul class="features">
230
+
<li>{$_('migration.exportRepo')}</li>
231
+
<li>{$_('migration.transferToPds')}</li>
232
+
<li>{$_('migration.updateIdentity')}</li>
233
</ul>
234
+
<p class="login-required">{$_('migration.comingSoon')}</p>
235
</button>
236
</div>
237
238
<div class="info-section">
239
+
<h3>{$_('migration.whatIsMigration')}</h3>
240
+
<p>{$_('migration.whatIsMigrationDesc')}</p>
241
242
+
<h3>{$_('migration.beforeMigrate')}</h3>
243
<ul>
244
+
<li>{$_('migration.beforeMigrate1')}</li>
245
+
<li>{$_('migration.beforeMigrate2')}</li>
246
+
<li>{$_('migration.beforeMigrate3')}</li>
247
+
<li>{$_('migration.beforeMigrate4')}</li>
248
</ul>
249
250
<div class="warning-box">
251
+
<strong>Important:</strong> {$_('migration.importantWarning')}
252
<a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
253
+
{$_('migration.learnMore')}
254
</a>
255
</div>
256
</div>
···
258
{:else if direction === 'inbound' && inboundFlow}
259
<InboundWizard
260
flow={inboundFlow}
261
+
{resumeInfo}
262
onBack={handleBack}
263
onComplete={handleInboundComplete}
264
/>
···
469
display: flex;
470
gap: var(--space-3);
471
justify-content: flex-end;
472
+
}
473
+
474
+
.oauth-loading {
475
+
display: flex;
476
+
flex-direction: column;
477
+
align-items: center;
478
+
justify-content: center;
479
+
padding: var(--space-12);
480
+
text-align: center;
481
+
}
482
+
483
+
.loading-spinner {
484
+
width: 48px;
485
+
height: 48px;
486
+
border: 3px solid var(--border);
487
+
border-top-color: var(--accent);
488
+
border-radius: 50%;
489
+
animation: spin 1s linear infinite;
490
+
margin-bottom: var(--space-4);
491
+
}
492
+
493
+
@keyframes spin {
494
+
to { transform: rotate(360deg); }
495
+
}
496
+
497
+
.oauth-loading p {
498
+
color: var(--text-secondary);
499
+
margin: 0;
500
+
}
501
+
502
+
.oauth-error {
503
+
max-width: 500px;
504
+
margin: 0 auto;
505
+
text-align: center;
506
+
padding: var(--space-8);
507
+
background: var(--error-bg);
508
+
border: 1px solid var(--error-border);
509
+
border-radius: var(--radius-xl);
510
+
}
511
+
512
+
.oauth-error h2 {
513
+
margin: 0 0 var(--space-4) 0;
514
+
color: var(--error-text);
515
+
}
516
+
517
+
.oauth-error p {
518
+
color: var(--text-secondary);
519
+
margin: 0 0 var(--space-5) 0;
520
}
521
</style>
+3
-3
frontend/src/styles/base.css
+3
-3
frontend/src/styles/base.css
···
229
}
230
231
code {
232
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
233
font-size: 0.9em;
234
background: var(--bg-tertiary);
235
padding: var(--space-1) var(--space-2);
···
237
}
238
239
pre {
240
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
241
font-size: var(--text-sm);
242
background: var(--bg-tertiary);
243
padding: var(--space-4);
···
400
}
401
402
.mono {
403
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
404
}
405
406
.mt-4 {
···
229
}
230
231
code {
232
+
font-family: var(--font-mono);
233
font-size: 0.9em;
234
background: var(--bg-tertiary);
235
padding: var(--space-1) var(--space-2);
···
237
}
238
239
pre {
240
+
font-family: var(--font-mono);
241
font-size: var(--text-sm);
242
background: var(--bg-tertiary);
243
padding: var(--space-4);
···
400
}
401
402
.mono {
403
+
font-family: var(--font-mono);
404
}
405
406
.mt-4 {
+567
frontend/src/styles/migration.css
+567
frontend/src/styles/migration.css
···
···
1
+
.migration-wizard {
2
+
max-width: var(--width-sm);
3
+
margin: 0 auto;
4
+
}
5
+
6
+
.step-indicator {
7
+
display: flex;
8
+
align-items: center;
9
+
justify-content: center;
10
+
margin-bottom: var(--space-6);
11
+
gap: var(--space-1);
12
+
}
13
+
14
+
.step {
15
+
display: flex;
16
+
align-items: center;
17
+
justify-content: center;
18
+
}
19
+
20
+
.step-dot {
21
+
width: 24px;
22
+
height: 24px;
23
+
border-radius: 50%;
24
+
background: var(--bg-secondary);
25
+
border: 2px solid var(--border-color);
26
+
display: flex;
27
+
align-items: center;
28
+
justify-content: center;
29
+
font-size: var(--text-xs);
30
+
font-weight: var(--font-medium);
31
+
color: var(--text-secondary);
32
+
flex-shrink: 0;
33
+
}
34
+
35
+
.step.active .step-dot {
36
+
background: var(--accent);
37
+
border-color: var(--accent);
38
+
color: var(--text-inverse);
39
+
width: 28px;
40
+
height: 28px;
41
+
}
42
+
43
+
.step.completed .step-dot {
44
+
background: var(--success-bg);
45
+
border-color: var(--success-text);
46
+
color: var(--success-text);
47
+
}
48
+
49
+
.step-label {
50
+
display: none;
51
+
}
52
+
53
+
.step-line {
54
+
width: 16px;
55
+
height: 2px;
56
+
background: var(--border-color);
57
+
flex-shrink: 0;
58
+
}
59
+
60
+
.step-line.completed {
61
+
background: var(--success-text);
62
+
}
63
+
64
+
.current-step-label {
65
+
text-align: center;
66
+
font-size: var(--text-sm);
67
+
color: var(--text-secondary);
68
+
margin-bottom: var(--space-5);
69
+
}
70
+
71
+
.current-step-label strong {
72
+
color: var(--text-primary);
73
+
}
74
+
75
+
.step-content {
76
+
background: var(--bg-secondary);
77
+
border-radius: var(--radius-xl);
78
+
padding: var(--space-6);
79
+
}
80
+
81
+
.step-content h2 {
82
+
margin: 0 0 var(--space-3) 0;
83
+
}
84
+
85
+
.step-content > p {
86
+
color: var(--text-secondary);
87
+
margin: 0 0 var(--space-5) 0;
88
+
}
89
+
90
+
.info-box {
91
+
background: var(--accent-muted);
92
+
border: 1px solid var(--border-color);
93
+
border-radius: var(--radius-lg);
94
+
padding: var(--space-5);
95
+
margin-bottom: var(--space-5);
96
+
}
97
+
98
+
.info-box h3 {
99
+
margin: 0 0 var(--space-3) 0;
100
+
font-size: var(--text-base);
101
+
}
102
+
103
+
.info-box ol,
104
+
.info-box ul {
105
+
margin: 0;
106
+
padding-left: var(--space-5);
107
+
}
108
+
109
+
.info-box li {
110
+
margin-bottom: var(--space-2);
111
+
color: var(--text-secondary);
112
+
}
113
+
114
+
.info-box p {
115
+
margin: 0;
116
+
color: var(--text-secondary);
117
+
}
118
+
119
+
.warning-box {
120
+
background: var(--warning-bg);
121
+
border: 1px solid var(--warning-border);
122
+
border-radius: var(--radius-lg);
123
+
padding: var(--space-5);
124
+
margin-bottom: var(--space-5);
125
+
font-size: var(--text-sm);
126
+
}
127
+
128
+
.warning-box strong {
129
+
color: var(--warning-text);
130
+
}
131
+
132
+
.warning-box p {
133
+
margin: var(--space-3) 0 0 0;
134
+
color: var(--text-secondary);
135
+
}
136
+
137
+
.warning-box ul {
138
+
margin: var(--space-3) 0 0 0;
139
+
padding-left: var(--space-5);
140
+
}
141
+
142
+
.checkbox-label {
143
+
display: inline-flex;
144
+
align-items: flex-start;
145
+
gap: var(--space-3);
146
+
cursor: pointer;
147
+
margin-bottom: var(--space-5);
148
+
text-align: left;
149
+
}
150
+
151
+
.checkbox-label input[type="checkbox"] {
152
+
width: 18px;
153
+
height: 18px;
154
+
margin: 0;
155
+
flex-shrink: 0;
156
+
}
157
+
158
+
.button-row {
159
+
display: flex;
160
+
gap: var(--space-3);
161
+
justify-content: flex-end;
162
+
margin-top: var(--space-5);
163
+
}
164
+
165
+
.handle-input-group {
166
+
display: flex;
167
+
gap: var(--space-2);
168
+
}
169
+
170
+
.handle-input-group input {
171
+
flex: 1;
172
+
}
173
+
174
+
.handle-input-group select {
175
+
width: auto;
176
+
}
177
+
178
+
.current-info {
179
+
background: var(--bg-primary);
180
+
border-radius: var(--radius-lg);
181
+
padding: var(--space-4);
182
+
margin-bottom: var(--space-5);
183
+
display: flex;
184
+
justify-content: space-between;
185
+
}
186
+
187
+
.current-info .label {
188
+
color: var(--text-secondary);
189
+
}
190
+
191
+
.current-info .value {
192
+
font-weight: var(--font-medium);
193
+
}
194
+
195
+
.review-card {
196
+
background: var(--bg-primary);
197
+
border-radius: var(--radius-lg);
198
+
padding: var(--space-4);
199
+
margin-bottom: var(--space-5);
200
+
}
201
+
202
+
.review-row {
203
+
display: flex;
204
+
justify-content: space-between;
205
+
padding: var(--space-3) 0;
206
+
border-bottom: 1px solid var(--border-color);
207
+
}
208
+
209
+
.review-row:last-child {
210
+
border-bottom: none;
211
+
}
212
+
213
+
.review-row .label {
214
+
color: var(--text-secondary);
215
+
}
216
+
217
+
.review-row .value {
218
+
font-weight: var(--font-medium);
219
+
text-align: right;
220
+
word-break: break-all;
221
+
}
222
+
223
+
.review-row .value.mono {
224
+
font-family: var(--font-mono);
225
+
font-size: var(--text-sm);
226
+
}
227
+
228
+
.progress-section {
229
+
margin-bottom: var(--space-5);
230
+
}
231
+
232
+
.progress-item {
233
+
display: flex;
234
+
align-items: center;
235
+
gap: var(--space-3);
236
+
padding: var(--space-3) 0;
237
+
color: var(--text-secondary);
238
+
}
239
+
240
+
.progress-item.completed {
241
+
color: var(--success-text);
242
+
}
243
+
244
+
.progress-item.active {
245
+
color: var(--accent);
246
+
}
247
+
248
+
.progress-item .icon {
249
+
width: 24px;
250
+
text-align: center;
251
+
}
252
+
253
+
.progress-bar {
254
+
height: 8px;
255
+
background: var(--bg-primary);
256
+
border-radius: var(--radius-md);
257
+
overflow: hidden;
258
+
margin-bottom: var(--space-4);
259
+
}
260
+
261
+
.progress-fill {
262
+
height: 100%;
263
+
background: var(--accent);
264
+
transition: width var(--transition-slow);
265
+
}
266
+
267
+
.status-text {
268
+
text-align: center;
269
+
color: var(--text-secondary);
270
+
font-size: var(--text-sm);
271
+
}
272
+
273
+
.success-content {
274
+
text-align: center;
275
+
}
276
+
277
+
.success-icon {
278
+
width: 64px;
279
+
height: 64px;
280
+
background: var(--success-bg);
281
+
color: var(--success-text);
282
+
border-radius: 50%;
283
+
display: flex;
284
+
align-items: center;
285
+
justify-content: center;
286
+
font-size: var(--text-2xl);
287
+
margin: 0 auto var(--space-5) auto;
288
+
}
289
+
290
+
.success-details {
291
+
background: var(--bg-primary);
292
+
border-radius: var(--radius-lg);
293
+
padding: var(--space-4);
294
+
margin: var(--space-5) 0;
295
+
text-align: left;
296
+
}
297
+
298
+
.success-details .detail-row {
299
+
display: flex;
300
+
justify-content: space-between;
301
+
padding: var(--space-2) 0;
302
+
}
303
+
304
+
.success-details .label {
305
+
color: var(--text-secondary);
306
+
}
307
+
308
+
.success-details .value {
309
+
font-weight: var(--font-medium);
310
+
}
311
+
312
+
.success-details .value.mono {
313
+
font-family: var(--font-mono);
314
+
font-size: var(--text-sm);
315
+
}
316
+
317
+
.redirect-text {
318
+
color: var(--text-secondary);
319
+
font-style: italic;
320
+
}
321
+
322
+
.code-block {
323
+
background: var(--bg-primary);
324
+
border: 1px solid var(--border-color);
325
+
border-radius: var(--radius-lg);
326
+
padding: var(--space-4);
327
+
margin-bottom: var(--space-5);
328
+
overflow-x: auto;
329
+
}
330
+
331
+
.code-block pre {
332
+
margin: 0;
333
+
font-family: var(--font-mono);
334
+
font-size: var(--text-sm);
335
+
white-space: pre-wrap;
336
+
word-break: break-all;
337
+
}
338
+
339
+
.auth-method-options {
340
+
display: flex;
341
+
flex-direction: column;
342
+
gap: var(--space-3);
343
+
}
344
+
345
+
label.auth-option {
346
+
display: flex;
347
+
flex-direction: row;
348
+
align-items: center;
349
+
gap: var(--space-3);
350
+
padding: var(--space-4);
351
+
border: 2px solid var(--border-color);
352
+
border-radius: var(--radius-lg);
353
+
cursor: pointer;
354
+
margin-bottom: 0;
355
+
transition: border-color var(--transition-normal), background-color var(--transition-normal);
356
+
}
357
+
358
+
.auth-option:hover {
359
+
border-color: var(--accent);
360
+
background: var(--bg-hover);
361
+
}
362
+
363
+
.auth-option.selected {
364
+
border-color: var(--accent);
365
+
background: var(--accent-muted);
366
+
}
367
+
368
+
.auth-option input[type="radio"] {
369
+
flex-shrink: 0;
370
+
width: 18px;
371
+
height: 18px;
372
+
margin: 0;
373
+
}
374
+
375
+
.auth-option-content {
376
+
display: flex;
377
+
flex-direction: column;
378
+
gap: var(--space-1);
379
+
}
380
+
381
+
.auth-option-content strong {
382
+
color: var(--text-primary);
383
+
}
384
+
385
+
.auth-option-content span {
386
+
font-size: var(--text-sm);
387
+
color: var(--text-secondary);
388
+
}
389
+
390
+
.loading-indicator {
391
+
display: flex;
392
+
flex-direction: column;
393
+
align-items: center;
394
+
gap: var(--space-4);
395
+
padding: var(--space-8);
396
+
}
397
+
398
+
.spinner {
399
+
width: 40px;
400
+
height: 40px;
401
+
border: 3px solid var(--border-color);
402
+
border-top-color: var(--accent);
403
+
border-radius: 50%;
404
+
animation: spin 1s linear infinite;
405
+
}
406
+
407
+
@keyframes spin {
408
+
to {
409
+
transform: rotate(360deg);
410
+
}
411
+
}
412
+
413
+
.passkey-section {
414
+
margin-top: var(--space-5);
415
+
text-align: center;
416
+
}
417
+
418
+
.passkey-section p {
419
+
margin-bottom: var(--space-4);
420
+
color: var(--text-secondary);
421
+
}
422
+
423
+
.app-password-display {
424
+
background: var(--bg-primary);
425
+
border-radius: var(--radius-lg);
426
+
padding: var(--space-5);
427
+
margin-bottom: var(--space-5);
428
+
text-align: center;
429
+
}
430
+
431
+
.app-password-label {
432
+
font-size: var(--text-sm);
433
+
color: var(--text-secondary);
434
+
margin-bottom: var(--space-3);
435
+
}
436
+
437
+
.app-password-code {
438
+
display: block;
439
+
font-family: var(--font-mono);
440
+
font-size: var(--text-lg);
441
+
letter-spacing: 0.1em;
442
+
padding: var(--space-4);
443
+
background: var(--bg-tertiary);
444
+
border-radius: var(--radius-md);
445
+
margin-bottom: var(--space-4);
446
+
user-select: all;
447
+
}
448
+
449
+
.copy-btn {
450
+
font-size: var(--text-sm);
451
+
}
452
+
453
+
.current-account {
454
+
background: var(--bg-primary);
455
+
border-radius: var(--radius-lg);
456
+
padding: var(--space-4);
457
+
margin-bottom: var(--space-5);
458
+
display: flex;
459
+
justify-content: space-between;
460
+
align-items: center;
461
+
}
462
+
463
+
.current-account .label {
464
+
color: var(--text-secondary);
465
+
}
466
+
467
+
.current-account .value {
468
+
font-weight: var(--font-medium);
469
+
font-size: var(--text-lg);
470
+
}
471
+
472
+
.server-info {
473
+
background: var(--bg-primary);
474
+
border-radius: var(--radius-lg);
475
+
padding: var(--space-4);
476
+
margin-top: var(--space-5);
477
+
}
478
+
479
+
.server-info h3 {
480
+
margin: 0 0 var(--space-3) 0;
481
+
font-size: var(--text-base);
482
+
color: var(--success-text);
483
+
}
484
+
485
+
.server-info .info-row {
486
+
display: flex;
487
+
justify-content: space-between;
488
+
padding: var(--space-2) 0;
489
+
font-size: var(--text-sm);
490
+
}
491
+
492
+
.server-info .label {
493
+
color: var(--text-secondary);
494
+
}
495
+
496
+
.server-info a {
497
+
display: inline-block;
498
+
margin-top: var(--space-2);
499
+
margin-right: var(--space-3);
500
+
color: var(--accent);
501
+
font-size: var(--text-sm);
502
+
}
503
+
504
+
.final-warning {
505
+
background: var(--error-bg);
506
+
border-color: var(--error-border);
507
+
}
508
+
509
+
.final-warning strong {
510
+
color: var(--error-text);
511
+
}
512
+
513
+
.next-steps {
514
+
background: var(--accent-muted);
515
+
border-radius: var(--radius-lg);
516
+
padding: var(--space-5);
517
+
margin: var(--space-5) 0;
518
+
text-align: left;
519
+
}
520
+
521
+
.next-steps h3 {
522
+
margin: 0 0 var(--space-3) 0;
523
+
}
524
+
525
+
.next-steps ol {
526
+
margin: 0;
527
+
padding-left: var(--space-5);
528
+
}
529
+
530
+
.next-steps li {
531
+
margin-bottom: var(--space-2);
532
+
}
533
+
534
+
.next-steps a {
535
+
color: var(--accent);
536
+
}
537
+
538
+
.resume-info {
539
+
margin-bottom: var(--space-5);
540
+
}
541
+
542
+
.resume-details {
543
+
display: flex;
544
+
flex-direction: column;
545
+
gap: var(--space-2);
546
+
margin-top: var(--space-3);
547
+
}
548
+
549
+
.resume-row {
550
+
display: flex;
551
+
gap: var(--space-3);
552
+
}
553
+
554
+
.resume-row .label {
555
+
color: var(--text-secondary);
556
+
min-width: 80px;
557
+
}
558
+
559
+
.resume-row .value {
560
+
font-weight: var(--font-medium);
561
+
}
562
+
563
+
.resume-note {
564
+
margin-top: var(--space-4);
565
+
font-size: var(--text-sm);
566
+
font-style: italic;
567
+
}
+4
frontend/src/styles/tokens.css
+4
frontend/src/styles/tokens.css
···
48
--transition-normal: 0.15s ease;
49
--transition-slow: 0.25s ease;
50
51
--bg-primary: #f9fafa;
52
--bg-secondary: #f1f3f3;
53
--bg-tertiary: #e8ebeb;
54
--bg-card: #ffffff;
55
--bg-input: #ffffff;
56
--bg-input-disabled: #f1f3f3;
···
93
--bg-primary: #0a0c0c;
94
--bg-secondary: #131616;
95
--bg-tertiary: #1a1d1d;
96
--bg-card: #131616;
97
--bg-input: #1a1d1d;
98
--bg-input-disabled: #131616;
···
48
--transition-normal: 0.15s ease;
49
--transition-slow: 0.25s ease;
50
51
+
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
52
+
53
--bg-primary: #f9fafa;
54
--bg-secondary: #f1f3f3;
55
--bg-tertiary: #e8ebeb;
56
+
--bg-hover: #e8ebeb;
57
--bg-card: #ffffff;
58
--bg-input: #ffffff;
59
--bg-input-disabled: #f1f3f3;
···
96
--bg-primary: #0a0c0c;
97
--bg-secondary: #131616;
98
--bg-tertiary: #1a1d1d;
99
+
--bg-hover: #1a1d1d;
100
--bg-card: #131616;
101
--bg-input: #1a1d1d;
102
--bg-input-disabled: #131616;
+17
-14
frontend/src/tests/AppPasswords.test.ts
+17
-14
frontend/src/tests/AppPasswords.test.ts
···
15
beforeEach(() => {
16
clearMocks();
17
setupFetchMock();
18
-
window.confirm = vi.fn(() => true);
19
});
20
describe("authentication guard", () => {
21
it("redirects to login when not authenticated", async () => {
22
setupUnauthenticatedUser();
23
render(AppPasswords);
24
await waitFor(() => {
25
-
expect(window.location.hash).toBe("#/login");
26
});
27
});
28
});
···
97
await waitFor(() => {
98
expect(screen.getByText("Graysky")).toBeInTheDocument();
99
expect(screen.getByText("Skeets")).toBeInTheDocument();
100
-
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument();
101
-
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument();
102
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
103
2,
104
);
···
199
await fireEvent.input(input, { target: { value: "MyApp" } });
200
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
201
await waitFor(() => {
202
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
203
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
204
-
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument();
205
expect(input.value).toBe("");
206
});
207
});
···
221
});
222
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
223
await waitFor(() => {
224
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
225
});
226
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
227
await waitFor(() => {
228
-
expect(screen.queryByText(/app password created/i)).not
229
.toBeInTheDocument();
230
});
231
});
···
255
});
256
it("shows confirmation dialog before revoking", async () => {
257
const confirmSpy = vi.fn(() => false);
258
-
window.confirm = confirmSpy;
259
mockEndpoint(
260
"com.atproto.server.listAppPasswords",
261
() => jsonResponse({ passwords: [testPassword] }),
···
270
);
271
});
272
it("does not revoke when confirmation is cancelled", async () => {
273
-
window.confirm = vi.fn(() => false);
274
let revokeCalled = false;
275
mockEndpoint(
276
"com.atproto.server.listAppPasswords",
···
288
expect(revokeCalled).toBe(false);
289
});
290
it("calls revokeAppPassword with correct name", async () => {
291
-
window.confirm = vi.fn(() => true);
292
let capturedName: string | null = null;
293
mockEndpoint(
294
"com.atproto.server.listAppPasswords",
···
309
});
310
});
311
it("shows loading state while revoking", async () => {
312
-
window.confirm = vi.fn(() => true);
313
mockEndpoint(
314
"com.atproto.server.listAppPasswords",
315
() => jsonResponse({ passwords: [testPassword] }),
···
328
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
329
});
330
it("reloads password list after successful revocation", async () => {
331
-
window.confirm = vi.fn(() => true);
332
let listCallCount = 0;
333
mockEndpoint("com.atproto.server.listAppPasswords", () => {
334
listCallCount++;
···
352
});
353
});
354
it("shows error when revocation fails", async () => {
355
-
window.confirm = vi.fn(() => true);
356
mockEndpoint(
357
"com.atproto.server.listAppPasswords",
358
() => jsonResponse({ passwords: [testPassword] }),
···
15
beforeEach(() => {
16
clearMocks();
17
setupFetchMock();
18
+
globalThis.confirm = vi.fn(() => true);
19
});
20
describe("authentication guard", () => {
21
it("redirects to login when not authenticated", async () => {
22
setupUnauthenticatedUser();
23
render(AppPasswords);
24
await waitFor(() => {
25
+
expect(globalThis.location.hash).toBe("#/login");
26
});
27
});
28
});
···
97
await waitFor(() => {
98
expect(screen.getByText("Graysky")).toBeInTheDocument();
99
expect(screen.getByText("Skeets")).toBeInTheDocument();
100
+
expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument();
101
+
expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument();
102
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
103
2,
104
);
···
199
await fireEvent.input(input, { target: { value: "MyApp" } });
200
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
201
await waitFor(() => {
202
+
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
203
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
204
+
expect(screen.getByText("MyApp")).toBeInTheDocument();
205
expect(input.value).toBe("");
206
});
207
});
···
221
});
222
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
223
await waitFor(() => {
224
+
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
225
});
226
+
await fireEvent.click(
227
+
screen.getByLabelText(/i have saved my app password/i),
228
+
);
229
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
230
await waitFor(() => {
231
+
expect(screen.queryByText(/save this app password/i)).not
232
.toBeInTheDocument();
233
});
234
});
···
258
});
259
it("shows confirmation dialog before revoking", async () => {
260
const confirmSpy = vi.fn(() => false);
261
+
globalThis.confirm = confirmSpy;
262
mockEndpoint(
263
"com.atproto.server.listAppPasswords",
264
() => jsonResponse({ passwords: [testPassword] }),
···
273
);
274
});
275
it("does not revoke when confirmation is cancelled", async () => {
276
+
globalThis.confirm = vi.fn(() => false);
277
let revokeCalled = false;
278
mockEndpoint(
279
"com.atproto.server.listAppPasswords",
···
291
expect(revokeCalled).toBe(false);
292
});
293
it("calls revokeAppPassword with correct name", async () => {
294
+
globalThis.confirm = vi.fn(() => true);
295
let capturedName: string | null = null;
296
mockEndpoint(
297
"com.atproto.server.listAppPasswords",
···
312
});
313
});
314
it("shows loading state while revoking", async () => {
315
+
globalThis.confirm = vi.fn(() => true);
316
mockEndpoint(
317
"com.atproto.server.listAppPasswords",
318
() => jsonResponse({ passwords: [testPassword] }),
···
331
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
332
});
333
it("reloads password list after successful revocation", async () => {
334
+
globalThis.confirm = vi.fn(() => true);
335
let listCallCount = 0;
336
mockEndpoint("com.atproto.server.listAppPasswords", () => {
337
listCallCount++;
···
355
});
356
});
357
it("shows error when revocation fails", async () => {
358
+
globalThis.confirm = vi.fn(() => true);
359
mockEndpoint(
360
"com.atproto.server.listAppPasswords",
361
() => jsonResponse({ passwords: [testPassword] }),
+76
-13
frontend/src/tests/Comms.test.ts
+76
-13
frontend/src/tests/Comms.test.ts
···
8
mockData,
9
mockEndpoint,
10
setupAuthenticatedUser,
11
-
setupFetchMock,
12
setupUnauthenticatedUser,
13
} from "./mocks";
14
describe("Comms", () => {
15
beforeEach(() => {
16
clearMocks();
17
-
setupFetchMock();
18
});
19
describe("authentication guard", () => {
20
it("redirects to login when not authenticated", async () => {
21
setupUnauthenticatedUser();
22
render(Comms);
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
25
});
26
});
27
});
···
32
"com.tranquil.account.getNotificationPrefs",
33
() => jsonResponse(mockData.notificationPrefs()),
34
);
35
});
36
it("displays all page elements and sections", async () => {
37
render(Comms);
38
await waitFor(() => {
39
expect(
40
screen.getByRole("heading", {
41
-
name: /notification preferences/i,
42
level: 1,
43
}),
44
).toBeInTheDocument();
45
expect(screen.getByRole("link", { name: /dashboard/i }))
46
.toHaveAttribute("href", "#/dashboard");
47
-
expect(screen.getByText(/password resets/i)).toBeInTheDocument();
48
expect(screen.getByRole("heading", { name: /preferred channel/i }))
49
.toBeInTheDocument();
50
expect(screen.getByRole("heading", { name: /channel configuration/i }))
···
55
describe("loading state", () => {
56
beforeEach(() => {
57
setupAuthenticatedUser();
58
});
59
it("shows loading text while fetching preferences", async () => {
60
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
···
68
describe("channel options", () => {
69
beforeEach(() => {
70
setupAuthenticatedUser();
71
});
72
it("displays all four channel options", async () => {
73
mockEndpoint(
···
127
);
128
render(Comms);
129
await waitFor(() => {
130
-
expect(screen.getAllByText(/configure below to enable/i).length)
131
.toBeGreaterThan(0);
132
});
133
});
···
151
describe("channel configuration", () => {
152
beforeEach(() => {
153
setupAuthenticatedUser();
154
});
155
it("displays email as readonly with current value", async () => {
156
mockEndpoint(
···
179
render(Comms);
180
await waitFor(() => {
181
expect(
182
-
(screen.getByLabelText(/discord user id/i) as HTMLInputElement).value,
183
).toBe("123456789");
184
expect(
185
-
(screen.getByLabelText(/telegram username/i) as HTMLInputElement)
186
.value,
187
).toBe("testuser");
188
expect(
189
-
(screen.getByLabelText(/signal phone number/i) as HTMLInputElement)
190
.value,
191
).toBe("+1234567890");
192
});
···
195
describe("verification status badges", () => {
196
beforeEach(() => {
197
setupAuthenticatedUser();
198
});
199
it("shows Primary badge for email", async () => {
200
mockEndpoint(
···
250
describe("save preferences", () => {
251
beforeEach(() => {
252
setupAuthenticatedUser();
253
});
254
it("calls updateNotificationPrefs with correct data", async () => {
255
let capturedBody: Record<string, unknown> | null = null;
···
266
);
267
render(Comms);
268
await waitFor(() => {
269
-
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument();
270
});
271
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
272
target: { value: "999888777" },
273
});
274
await fireEvent.click(
···
319
screen.getByRole("button", { name: /save preferences/i }),
320
);
321
await waitFor(() => {
322
-
expect(screen.getByText(/notification preferences saved/i))
323
.toBeInTheDocument();
324
});
325
});
···
378
describe("channel selection interaction", () => {
379
beforeEach(() => {
380
setupAuthenticatedUser();
381
});
382
it("enables discord channel after entering discord ID", async () => {
383
mockEndpoint(
···
388
await waitFor(() => {
389
expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
390
});
391
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
392
target: { value: "123456789" },
393
});
394
await waitFor(() => {
···
420
describe("error handling", () => {
421
beforeEach(() => {
422
setupAuthenticatedUser();
423
});
424
it("shows error when loading preferences fails", async () => {
425
mockEndpoint(
···
8
mockData,
9
mockEndpoint,
10
setupAuthenticatedUser,
11
+
setupDefaultMocks,
12
setupUnauthenticatedUser,
13
} from "./mocks";
14
describe("Comms", () => {
15
beforeEach(() => {
16
clearMocks();
17
+
setupDefaultMocks();
18
});
19
describe("authentication guard", () => {
20
it("redirects to login when not authenticated", async () => {
21
setupUnauthenticatedUser();
22
render(Comms);
23
await waitFor(() => {
24
+
expect(globalThis.location.hash).toBe("#/login");
25
});
26
});
27
});
···
32
"com.tranquil.account.getNotificationPrefs",
33
() => jsonResponse(mockData.notificationPrefs()),
34
);
35
+
mockEndpoint(
36
+
"com.atproto.server.describeServer",
37
+
() => jsonResponse(mockData.describeServer()),
38
+
);
39
+
mockEndpoint(
40
+
"com.tranquil.account.getNotificationHistory",
41
+
() => jsonResponse({ notifications: [] }),
42
+
);
43
});
44
it("displays all page elements and sections", async () => {
45
render(Comms);
46
await waitFor(() => {
47
expect(
48
screen.getByRole("heading", {
49
+
name: /communication preferences|notification preferences/i,
50
level: 1,
51
}),
52
).toBeInTheDocument();
53
expect(screen.getByRole("link", { name: /dashboard/i }))
54
.toHaveAttribute("href", "#/dashboard");
55
expect(screen.getByRole("heading", { name: /preferred channel/i }))
56
.toBeInTheDocument();
57
expect(screen.getByRole("heading", { name: /channel configuration/i }))
···
62
describe("loading state", () => {
63
beforeEach(() => {
64
setupAuthenticatedUser();
65
+
mockEndpoint(
66
+
"com.atproto.server.describeServer",
67
+
() => jsonResponse(mockData.describeServer()),
68
+
);
69
+
mockEndpoint(
70
+
"com.tranquil.account.getNotificationHistory",
71
+
() => jsonResponse({ notifications: [] }),
72
+
);
73
});
74
it("shows loading text while fetching preferences", async () => {
75
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
···
83
describe("channel options", () => {
84
beforeEach(() => {
85
setupAuthenticatedUser();
86
+
mockEndpoint(
87
+
"com.atproto.server.describeServer",
88
+
() => jsonResponse(mockData.describeServer()),
89
+
);
90
+
mockEndpoint(
91
+
"com.tranquil.account.getNotificationHistory",
92
+
() => jsonResponse({ notifications: [] }),
93
+
);
94
});
95
it("displays all four channel options", async () => {
96
mockEndpoint(
···
150
);
151
render(Comms);
152
await waitFor(() => {
153
+
expect(screen.getAllByText(/configure.*to enable/i).length)
154
.toBeGreaterThan(0);
155
});
156
});
···
174
describe("channel configuration", () => {
175
beforeEach(() => {
176
setupAuthenticatedUser();
177
+
mockEndpoint(
178
+
"com.atproto.server.describeServer",
179
+
() => jsonResponse(mockData.describeServer()),
180
+
);
181
+
mockEndpoint(
182
+
"com.tranquil.account.getNotificationHistory",
183
+
() => jsonResponse({ notifications: [] }),
184
+
);
185
});
186
it("displays email as readonly with current value", async () => {
187
mockEndpoint(
···
210
render(Comms);
211
await waitFor(() => {
212
expect(
213
+
(screen.getByLabelText(/discord.*id/i) as HTMLInputElement).value,
214
).toBe("123456789");
215
expect(
216
+
(screen.getByLabelText(/telegram.*username/i) as HTMLInputElement)
217
.value,
218
).toBe("testuser");
219
expect(
220
+
(screen.getByLabelText(/signal.*number/i) as HTMLInputElement)
221
.value,
222
).toBe("+1234567890");
223
});
···
226
describe("verification status badges", () => {
227
beforeEach(() => {
228
setupAuthenticatedUser();
229
+
mockEndpoint(
230
+
"com.atproto.server.describeServer",
231
+
() => jsonResponse(mockData.describeServer()),
232
+
);
233
+
mockEndpoint(
234
+
"com.tranquil.account.getNotificationHistory",
235
+
() => jsonResponse({ notifications: [] }),
236
+
);
237
});
238
it("shows Primary badge for email", async () => {
239
mockEndpoint(
···
289
describe("save preferences", () => {
290
beforeEach(() => {
291
setupAuthenticatedUser();
292
+
mockEndpoint(
293
+
"com.atproto.server.describeServer",
294
+
() => jsonResponse(mockData.describeServer()),
295
+
);
296
+
mockEndpoint(
297
+
"com.tranquil.account.getNotificationHistory",
298
+
() => jsonResponse({ notifications: [] }),
299
+
);
300
});
301
it("calls updateNotificationPrefs with correct data", async () => {
302
let capturedBody: Record<string, unknown> | null = null;
···
313
);
314
render(Comms);
315
await waitFor(() => {
316
+
expect(screen.getByLabelText(/discord.*id/i)).toBeInTheDocument();
317
});
318
+
await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
319
target: { value: "999888777" },
320
});
321
await fireEvent.click(
···
366
screen.getByRole("button", { name: /save preferences/i }),
367
);
368
await waitFor(() => {
369
+
expect(screen.getByText(/preferences saved/i))
370
.toBeInTheDocument();
371
});
372
});
···
425
describe("channel selection interaction", () => {
426
beforeEach(() => {
427
setupAuthenticatedUser();
428
+
mockEndpoint(
429
+
"com.atproto.server.describeServer",
430
+
() => jsonResponse(mockData.describeServer()),
431
+
);
432
+
mockEndpoint(
433
+
"com.tranquil.account.getNotificationHistory",
434
+
() => jsonResponse({ notifications: [] }),
435
+
);
436
});
437
it("enables discord channel after entering discord ID", async () => {
438
mockEndpoint(
···
443
await waitFor(() => {
444
expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
445
});
446
+
await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
447
target: { value: "123456789" },
448
});
449
await waitFor(() => {
···
475
describe("error handling", () => {
476
beforeEach(() => {
477
setupAuthenticatedUser();
478
+
mockEndpoint(
479
+
"com.atproto.server.describeServer",
480
+
() => jsonResponse(mockData.describeServer()),
481
+
);
482
+
mockEndpoint(
483
+
"com.tranquil.account.getNotificationHistory",
484
+
() => jsonResponse({ notifications: [] }),
485
+
);
486
});
487
it("shows error when loading preferences fails", async () => {
488
mockEndpoint(
+27
-5
frontend/src/tests/Dashboard.test.ts
+27
-5
frontend/src/tests/Dashboard.test.ts
···
21
setupUnauthenticatedUser();
22
render(Dashboard);
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
25
});
26
});
27
it("shows loading state while checking auth", () => {
···
40
.toBeInTheDocument();
41
expect(screen.getByRole("heading", { name: /account overview/i }))
42
.toBeInTheDocument();
43
-
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/))
44
-
.toBeInTheDocument();
45
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/))
46
.toBeInTheDocument();
47
expect(screen.getByText("test@example.com")).toBeInTheDocument();
···
62
await waitFor(() => {
63
const navCards = [
64
{ name: /app passwords/i, href: "#/app-passwords" },
65
-
{ name: /invite codes/i, href: "#/invite-codes" },
66
{ name: /account settings/i, href: "#/settings" },
67
{ name: /communication preferences/i, href: "#/comms" },
68
{ name: /repository explorer/i, href: "#/repo" },
···
74
}
75
});
76
});
77
});
78
describe("logout functionality", () => {
79
beforeEach(() => {
···
89
});
90
render(Dashboard);
91
await waitFor(() => {
92
expect(screen.getByRole("button", { name: /sign out/i }))
93
.toBeInTheDocument();
94
});
95
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
96
await waitFor(() => {
97
expect(deleteSessionCalled).toBe(true);
98
-
expect(window.location.hash).toBe("#/login");
99
});
100
});
101
it("clears session from localStorage after logout", async () => {
102
const storedSession = localStorage.getItem(STORAGE_KEY);
103
expect(storedSession).not.toBeNull();
104
render(Dashboard);
105
await waitFor(() => {
106
expect(screen.getByRole("button", { name: /sign out/i }))
107
.toBeInTheDocument();
···
21
setupUnauthenticatedUser();
22
render(Dashboard);
23
await waitFor(() => {
24
+
expect(globalThis.location.hash).toBe("#/login");
25
});
26
});
27
it("shows loading state while checking auth", () => {
···
40
.toBeInTheDocument();
41
expect(screen.getByRole("heading", { name: /account overview/i }))
42
.toBeInTheDocument();
43
+
expect(screen.getAllByText(/@testuser\.test\.tranquil\.dev/).length)
44
+
.toBeGreaterThan(0);
45
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/))
46
.toBeInTheDocument();
47
expect(screen.getByText("test@example.com")).toBeInTheDocument();
···
62
await waitFor(() => {
63
const navCards = [
64
{ name: /app passwords/i, href: "#/app-passwords" },
65
{ name: /account settings/i, href: "#/settings" },
66
{ name: /communication preferences/i, href: "#/comms" },
67
{ name: /repository explorer/i, href: "#/repo" },
···
73
}
74
});
75
});
76
+
it("displays invite codes card when invites are required and user is admin", async () => {
77
+
setupAuthenticatedUser({ isAdmin: true });
78
+
mockEndpoint(
79
+
"com.atproto.server.describeServer",
80
+
() => jsonResponse(mockData.describeServer({ inviteCodeRequired: true })),
81
+
);
82
+
render(Dashboard);
83
+
await waitFor(() => {
84
+
const inviteCard = screen.getByRole("link", { name: /invite codes/i });
85
+
expect(inviteCard).toBeInTheDocument();
86
+
expect(inviteCard).toHaveAttribute("href", "#/invite-codes");
87
+
});
88
+
});
89
});
90
describe("logout functionality", () => {
91
beforeEach(() => {
···
101
});
102
render(Dashboard);
103
await waitFor(() => {
104
+
expect(screen.getByRole("button", { name: /@testuser/i }))
105
+
.toBeInTheDocument();
106
+
});
107
+
await fireEvent.click(screen.getByRole("button", { name: /@testuser/i }));
108
+
await waitFor(() => {
109
expect(screen.getByRole("button", { name: /sign out/i }))
110
.toBeInTheDocument();
111
});
112
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
113
await waitFor(() => {
114
expect(deleteSessionCalled).toBe(true);
115
+
expect(globalThis.location.hash).toBe("#/login");
116
});
117
});
118
it("clears session from localStorage after logout", async () => {
119
const storedSession = localStorage.getItem(STORAGE_KEY);
120
expect(storedSession).not.toBeNull();
121
render(Dashboard);
122
+
await waitFor(() => {
123
+
expect(screen.getByRole("button", { name: /@testuser/i }))
124
+
.toBeInTheDocument();
125
+
});
126
+
await fireEvent.click(screen.getByRole("button", { name: /@testuser/i }));
127
await waitFor(() => {
128
expect(screen.getByRole("button", { name: /sign out/i }))
129
.toBeInTheDocument();
+132
-132
frontend/src/tests/Login.test.ts
+132
-132
frontend/src/tests/Login.test.ts
···
1
-
import { beforeEach, describe, expect, it } from "vitest";
2
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
import Login from "../routes/Login.svelte";
4
import {
5
clearMocks,
6
-
errorResponse,
7
jsonResponse,
8
mockData,
9
mockEndpoint,
10
setupFetchMock,
11
} from "./mocks";
12
describe("Login", () => {
13
beforeEach(() => {
14
clearMocks();
15
setupFetchMock();
16
-
window.location.hash = "";
17
});
18
-
describe("initial render", () => {
19
-
it("renders login form with all elements and correct initial state", () => {
20
-
render(Login);
21
-
expect(screen.getByRole("heading", { name: /sign in/i }))
22
-
.toBeInTheDocument();
23
-
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument();
24
-
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
25
-
expect(screen.getByRole("button", { name: /sign in/i }))
26
-
.toBeInTheDocument();
27
-
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
28
-
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
29
-
expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute(
30
-
"href",
31
-
"#/register",
32
-
);
33
-
});
34
-
});
35
-
describe("form validation", () => {
36
-
it("enables submit button only when both fields are filled", async () => {
37
-
render(Login);
38
-
const identifierInput = screen.getByLabelText(/handle or email/i);
39
-
const passwordInput = screen.getByLabelText(/password/i);
40
-
const submitButton = screen.getByRole("button", { name: /sign in/i });
41
-
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
42
-
expect(submitButton).toBeDisabled();
43
-
await fireEvent.input(identifierInput, { target: { value: "" } });
44
-
await fireEvent.input(passwordInput, {
45
-
target: { value: "password123" },
46
});
47
-
expect(submitButton).toBeDisabled();
48
-
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
49
-
expect(submitButton).not.toBeDisabled();
50
});
51
-
});
52
-
describe("login submission", () => {
53
-
it("calls createSession with correct credentials", async () => {
54
-
let capturedBody: Record<string, string> | null = null;
55
-
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
56
-
capturedBody = JSON.parse((options?.body as string) || "{}");
57
-
return jsonResponse(mockData.session());
58
-
});
59
render(Login);
60
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
61
-
target: { value: "testuser@example.com" },
62
-
});
63
-
await fireEvent.input(screen.getByLabelText(/password/i), {
64
-
target: { value: "mypassword" },
65
-
});
66
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
67
await waitFor(() => {
68
-
expect(capturedBody).toEqual({
69
-
identifier: "testuser@example.com",
70
-
password: "mypassword",
71
-
});
72
});
73
});
74
-
it("shows styled error message on invalid credentials", async () => {
75
-
mockEndpoint(
76
-
"com.atproto.server.createSession",
77
-
() =>
78
-
errorResponse(
79
-
"AuthenticationRequired",
80
-
"Invalid identifier or password",
81
-
401,
82
-
),
83
-
);
84
render(Login);
85
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
86
-
target: { value: "wronguser" },
87
-
});
88
-
await fireEvent.input(screen.getByLabelText(/password/i), {
89
-
target: { value: "wrongpassword" },
90
-
});
91
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
92
await waitFor(() => {
93
-
const errorDiv = screen.getByText(/invalid identifier or password/i);
94
-
expect(errorDiv).toBeInTheDocument();
95
-
expect(errorDiv).toHaveClass("error");
96
});
97
});
98
-
it("navigates to dashboard on successful login", async () => {
99
-
mockEndpoint(
100
-
"com.atproto.server.createSession",
101
-
() => jsonResponse(mockData.session()),
102
-
);
103
render(Login);
104
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
105
-
target: { value: "test" },
106
-
});
107
-
await fireEvent.input(screen.getByLabelText(/password/i), {
108
-
target: { value: "password" },
109
-
});
110
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
111
await waitFor(() => {
112
-
expect(window.location.hash).toBe("#/dashboard");
113
});
114
});
115
});
116
-
describe("account verification flow", () => {
117
-
it("shows verification form with all controls when account is not verified", async () => {
118
-
mockEndpoint("com.atproto.server.createSession", () => ({
119
-
ok: false,
120
-
status: 401,
121
-
json: async () => ({
122
-
error: "AccountNotVerified",
123
-
message: "Account not verified",
124
-
did: "did:web:test.tranquil.dev:u:testuser",
125
-
}),
126
-
}));
127
-
render(Login);
128
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
129
-
target: { value: "unverified@test.com" },
130
-
});
131
-
await fireEvent.input(screen.getByLabelText(/password/i), {
132
-
target: { value: "password" },
133
});
134
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
135
await waitFor(() => {
136
-
expect(screen.getByRole("heading", { name: /verify your account/i }))
137
.toBeInTheDocument();
138
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
139
-
expect(screen.getByRole("button", { name: /resend code/i }))
140
-
.toBeInTheDocument();
141
-
expect(screen.getByRole("button", { name: /back to login/i }))
142
.toBeInTheDocument();
143
});
144
});
145
-
it("returns to login form when clicking back", async () => {
146
-
mockEndpoint("com.atproto.server.createSession", () => ({
147
-
ok: false,
148
-
status: 401,
149
-
json: async () => ({
150
-
error: "AccountNotVerified",
151
-
message: "Account not verified",
152
-
did: "did:web:test.tranquil.dev:u:testuser",
153
-
}),
154
-
}));
155
render(Login);
156
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
157
-
target: { value: "test" },
158
-
});
159
-
await fireEvent.input(screen.getByLabelText(/password/i), {
160
-
target: { value: "password" },
161
});
162
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
163
await waitFor(() => {
164
-
expect(screen.getByRole("button", { name: /back to login/i }))
165
.toBeInTheDocument();
166
});
167
-
await fireEvent.click(
168
-
screen.getByRole("button", { name: /back to login/i }),
169
-
);
170
await waitFor(() => {
171
-
expect(screen.getByRole("heading", { name: /sign in/i }))
172
-
.toBeInTheDocument();
173
-
expect(screen.queryByLabelText(/verification code/i)).not
174
.toBeInTheDocument();
175
});
176
});
177
});
178
});
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
import Login from "../routes/Login.svelte";
4
import {
5
clearMocks,
6
jsonResponse,
7
mockData,
8
mockEndpoint,
9
setupFetchMock,
10
} from "./mocks";
11
+
import { _testSetState, type SavedAccount } from "../lib/auth.svelte";
12
+
13
describe("Login", () => {
14
beforeEach(() => {
15
clearMocks();
16
setupFetchMock();
17
+
globalThis.location.hash = "";
18
+
mockEndpoint("/oauth/par", () =>
19
+
jsonResponse({ request_uri: "urn:mock:request" })
20
+
);
21
});
22
+
23
+
describe("initial render with no saved accounts", () => {
24
+
beforeEach(() => {
25
+
_testSetState({
26
+
session: null,
27
+
loading: false,
28
+
error: null,
29
+
savedAccounts: [],
30
});
31
});
32
+
33
+
it("renders login page with title and OAuth button", async () => {
34
render(Login);
35
await waitFor(() => {
36
+
expect(screen.getByRole("heading", { name: /sign in/i }))
37
+
.toBeInTheDocument();
38
+
expect(screen.getByRole("button", { name: /sign in/i }))
39
+
.toBeInTheDocument();
40
});
41
});
42
+
43
+
it("shows create account link", async () => {
44
render(Login);
45
await waitFor(() => {
46
+
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
47
+
expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute(
48
+
"href",
49
+
"#/register",
50
+
);
51
});
52
});
53
+
54
+
it("shows forgot password and lost passkey links", async () => {
55
render(Login);
56
await waitFor(() => {
57
+
expect(screen.getByRole("link", { name: /forgot password/i }))
58
+
.toHaveAttribute("href", "#/reset-password");
59
+
expect(screen.getByRole("link", { name: /lost passkey/i }))
60
+
.toHaveAttribute("href", "#/request-passkey-recovery");
61
});
62
});
63
});
64
+
65
+
describe("with saved accounts", () => {
66
+
const savedAccounts: SavedAccount[] = [
67
+
{
68
+
did: "did:web:test.tranquil.dev:u:alice",
69
+
handle: "alice.test.tranquil.dev",
70
+
accessJwt: "mock-jwt-alice",
71
+
refreshJwt: "mock-refresh-alice",
72
+
},
73
+
{
74
+
did: "did:web:test.tranquil.dev:u:bob",
75
+
handle: "bob.test.tranquil.dev",
76
+
accessJwt: "mock-jwt-bob",
77
+
refreshJwt: "mock-refresh-bob",
78
+
},
79
+
];
80
+
81
+
beforeEach(() => {
82
+
_testSetState({
83
+
session: null,
84
+
loading: false,
85
+
error: null,
86
+
savedAccounts,
87
});
88
+
mockEndpoint("com.atproto.server.getSession", () =>
89
+
jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" })));
90
+
});
91
+
92
+
it("displays saved accounts list", async () => {
93
+
render(Login);
94
await waitFor(() => {
95
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
96
.toBeInTheDocument();
97
+
expect(screen.getByText(/@bob\.test\.tranquil\.dev/))
98
.toBeInTheDocument();
99
});
100
});
101
+
102
+
it("shows sign in to another account option", async () => {
103
render(Login);
104
+
await waitFor(() => {
105
+
expect(screen.getByText(/sign in to another/i)).toBeInTheDocument();
106
});
107
+
});
108
+
109
+
it("can click on saved account to switch", async () => {
110
+
render(Login);
111
await waitFor(() => {
112
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
113
.toBeInTheDocument();
114
});
115
+
const aliceAccount = screen.getByText(/@alice\.test\.tranquil\.dev/)
116
+
.closest("[role='button']");
117
+
if (aliceAccount) {
118
+
await fireEvent.click(aliceAccount);
119
+
}
120
+
await waitFor(() => {
121
+
expect(globalThis.location.hash).toBe("#/dashboard");
122
+
});
123
+
});
124
+
125
+
it("can remove saved account with forget button", async () => {
126
+
render(Login);
127
await waitFor(() => {
128
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
129
.toBeInTheDocument();
130
+
const forgetButtons = screen.getAllByTitle(/remove/i);
131
+
expect(forgetButtons.length).toBe(2);
132
});
133
+
});
134
+
});
135
+
136
+
describe("error handling", () => {
137
+
it("displays error message when auth state has error", async () => {
138
+
_testSetState({
139
+
session: null,
140
+
loading: false,
141
+
error: "OAuth login failed",
142
+
savedAccounts: [],
143
+
});
144
+
render(Login);
145
+
await waitFor(() => {
146
+
expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument();
147
+
expect(screen.getByText(/oauth login failed/i)).toHaveClass("error");
148
+
});
149
+
});
150
+
});
151
+
152
+
describe("verification flow", () => {
153
+
beforeEach(() => {
154
+
_testSetState({
155
+
session: null,
156
+
loading: false,
157
+
error: null,
158
+
savedAccounts: [],
159
+
});
160
+
});
161
+
162
+
it("shows verification form when pending verification exists", async () => {
163
+
render(Login);
164
+
});
165
+
});
166
+
167
+
describe("loading state", () => {
168
+
it("shows loading state while auth is initializing", async () => {
169
+
_testSetState({
170
+
session: null,
171
+
loading: true,
172
+
error: null,
173
+
savedAccounts: [],
174
+
});
175
+
render(Login);
176
});
177
});
178
});
+82
-71
frontend/src/tests/Settings.test.ts
+82
-71
frontend/src/tests/Settings.test.ts
···
5
clearMocks,
6
errorResponse,
7
jsonResponse,
8
mockEndpoint,
9
setupAuthenticatedUser,
10
setupFetchMock,
···
14
beforeEach(() => {
15
clearMocks();
16
setupFetchMock();
17
-
window.confirm = vi.fn(() => true);
18
});
19
describe("authentication guard", () => {
20
it("redirects to login when not authenticated", async () => {
21
setupUnauthenticatedUser();
22
render(Settings);
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
25
});
26
});
27
});
···
50
beforeEach(() => {
51
setupAuthenticatedUser();
52
});
53
-
it("displays current email and input field", async () => {
54
render(Settings);
55
await waitFor(() => {
56
-
expect(screen.getByText(/current: test@example.com/i))
57
.toBeInTheDocument();
58
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
59
});
60
});
61
-
it("calls requestEmailUpdate when submitting", async () => {
62
let requestCalled = false;
63
mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
64
requestCalled = true;
···
66
});
67
render(Settings);
68
await waitFor(() => {
69
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
70
-
});
71
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
72
-
target: { value: "newemail@example.com" },
73
});
74
await fireEvent.click(
75
screen.getByRole("button", { name: /change email/i }),
···
78
expect(requestCalled).toBe(true);
79
});
80
});
81
-
it("shows verification code input when token is required", async () => {
82
mockEndpoint(
83
"com.atproto.server.requestEmailUpdate",
84
() => jsonResponse({ tokenRequired: true }),
85
);
86
render(Settings);
87
await waitFor(() => {
88
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
89
-
});
90
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
91
-
target: { value: "newemail@example.com" },
92
});
93
await fireEvent.click(
94
screen.getByRole("button", { name: /change email/i }),
95
);
96
await waitFor(() => {
97
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
98
expect(screen.getByRole("button", { name: /confirm email change/i }))
99
.toBeInTheDocument();
100
});
···
111
capturedBody = JSON.parse((options?.body as string) || "{}");
112
return jsonResponse({});
113
});
114
render(Settings);
115
await waitFor(() => {
116
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
117
-
});
118
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
119
-
target: { value: "newemail@example.com" },
120
});
121
await fireEvent.click(
122
screen.getByRole("button", { name: /change email/i }),
···
127
await fireEvent.input(screen.getByLabelText(/verification code/i), {
128
target: { value: "123456" },
129
});
130
await fireEvent.click(
131
screen.getByRole("button", { name: /confirm email change/i }),
132
);
···
142
() => jsonResponse({ tokenRequired: true }),
143
);
144
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
145
render(Settings);
146
await waitFor(() => {
147
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
148
-
});
149
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
150
-
target: { value: "new@test.com" },
151
});
152
await fireEvent.click(
153
screen.getByRole("button", { name: /change email/i }),
···
158
await fireEvent.input(screen.getByLabelText(/verification code/i), {
159
target: { value: "123456" },
160
});
161
await fireEvent.click(
162
screen.getByRole("button", { name: /confirm email change/i }),
163
);
164
await waitFor(() => {
165
-
expect(screen.getByText(/email updated successfully/i))
166
.toBeInTheDocument();
167
});
168
});
169
-
it("shows cancel button to return to email form", async () => {
170
mockEndpoint(
171
"com.atproto.server.requestEmailUpdate",
172
() => jsonResponse({ tokenRequired: true }),
173
);
174
render(Settings);
175
await waitFor(() => {
176
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
177
-
});
178
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
179
-
target: { value: "new@test.com" },
180
});
181
await fireEvent.click(
182
screen.getByRole("button", { name: /change email/i }),
···
185
expect(screen.getByRole("button", { name: /cancel/i }))
186
.toBeInTheDocument();
187
});
188
-
await fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
189
await waitFor(() => {
190
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
191
expect(screen.queryByLabelText(/verification code/i)).not
192
.toBeInTheDocument();
193
});
194
});
195
-
it("shows error when email update fails", async () => {
196
mockEndpoint(
197
"com.atproto.server.requestEmailUpdate",
198
() => errorResponse("InvalidEmail", "Invalid email format", 400),
199
);
200
render(Settings);
201
await waitFor(() => {
202
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
203
-
});
204
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
205
-
target: { value: "invalid@test.com" },
206
-
});
207
-
await waitFor(() => {
208
-
expect(screen.getByRole("button", { name: /change email/i })).not
209
-
.toBeDisabled();
210
});
211
await fireEvent.click(
212
screen.getByRole("button", { name: /change email/i }),
···
219
describe("handle change", () => {
220
beforeEach(() => {
221
setupAuthenticatedUser();
222
});
223
it("displays current handle", async () => {
224
render(Settings);
225
await waitFor(() => {
226
-
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i))
227
.toBeInTheDocument();
228
});
229
});
230
-
it("calls updateHandle with new handle", async () => {
231
-
let capturedHandle: string | null = null;
232
-
mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => {
233
-
const body = JSON.parse((options?.body as string) || "{}");
234
-
capturedHandle = body.handle;
235
-
return jsonResponse({});
236
});
237
render(Settings);
238
await waitFor(() => {
239
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
240
});
241
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
242
-
target: { value: "newhandle.bsky.social" },
243
});
244
-
await fireEvent.click(
245
-
screen.getByRole("button", { name: /change handle/i }),
246
-
);
247
-
await waitFor(() => {
248
-
expect(capturedHandle).toBe("newhandle.bsky.social");
249
-
});
250
});
251
it("shows success message after handle change", async () => {
252
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
253
render(Settings);
254
await waitFor(() => {
255
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
256
});
257
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
258
target: { value: "newhandle" },
259
});
260
-
await fireEvent.click(
261
-
screen.getByRole("button", { name: /change handle/i }),
262
-
);
263
await waitFor(() => {
264
-
expect(screen.getByText(/handle updated successfully/i))
265
.toBeInTheDocument();
266
});
267
});
···
274
render(Settings);
275
await waitFor(() => {
276
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
277
});
278
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
279
target: { value: "taken" },
280
});
281
-
await fireEvent.click(
282
-
screen.getByRole("button", { name: /change handle/i }),
283
-
);
284
await waitFor(() => {
285
-
expect(screen.getByText(/handle is already taken/i))
286
-
.toBeInTheDocument();
287
});
288
});
289
});
···
345
});
346
it("shows confirmation dialog before final deletion", async () => {
347
const confirmSpy = vi.fn(() => false);
348
-
window.confirm = confirmSpy;
349
mockEndpoint(
350
"com.atproto.server.requestAccountDelete",
351
() => jsonResponse({}),
···
376
);
377
});
378
it("calls deleteAccount with correct parameters", async () => {
379
-
window.confirm = vi.fn(() => true);
380
let capturedBody: Record<string, string> | null = null;
381
mockEndpoint(
382
"com.atproto.server.requestAccountDelete",
···
414
});
415
});
416
it("navigates to login after successful deletion", async () => {
417
-
window.confirm = vi.fn(() => true);
418
mockEndpoint(
419
"com.atproto.server.requestAccountDelete",
420
() => jsonResponse({}),
···
442
screen.getByRole("button", { name: /permanently delete account/i }),
443
);
444
await waitFor(() => {
445
-
expect(window.location.hash).toBe("#/login");
446
});
447
});
448
it("shows cancel button to return to request state", async () => {
···
480
});
481
});
482
it("shows error when deletion fails", async () => {
483
-
window.confirm = vi.fn(() => true);
484
mockEndpoint(
485
"com.atproto.server.requestAccountDelete",
486
() => jsonResponse({}),
···
5
clearMocks,
6
errorResponse,
7
jsonResponse,
8
+
mockData,
9
mockEndpoint,
10
setupAuthenticatedUser,
11
setupFetchMock,
···
15
beforeEach(() => {
16
clearMocks();
17
setupFetchMock();
18
+
globalThis.confirm = vi.fn(() => true);
19
});
20
describe("authentication guard", () => {
21
it("redirects to login when not authenticated", async () => {
22
setupUnauthenticatedUser();
23
render(Settings);
24
await waitFor(() => {
25
+
expect(globalThis.location.hash).toBe("#/login");
26
});
27
});
28
});
···
51
beforeEach(() => {
52
setupAuthenticatedUser();
53
});
54
+
it("displays current email and change button", async () => {
55
render(Settings);
56
await waitFor(() => {
57
+
expect(screen.getByText(/current.*test@example.com/i))
58
.toBeInTheDocument();
59
+
expect(screen.getByRole("button", { name: /change email/i }))
60
+
.toBeInTheDocument();
61
});
62
});
63
+
it("calls requestEmailUpdate when clicking change email button", async () => {
64
let requestCalled = false;
65
mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
66
requestCalled = true;
···
68
});
69
render(Settings);
70
await waitFor(() => {
71
+
expect(screen.getByRole("button", { name: /change email/i }))
72
+
.toBeInTheDocument();
73
});
74
await fireEvent.click(
75
screen.getByRole("button", { name: /change email/i }),
···
78
expect(requestCalled).toBe(true);
79
});
80
});
81
+
it("shows verification code and new email inputs when token is required", async () => {
82
mockEndpoint(
83
"com.atproto.server.requestEmailUpdate",
84
() => jsonResponse({ tokenRequired: true }),
85
);
86
render(Settings);
87
await waitFor(() => {
88
+
expect(screen.getByRole("button", { name: /change email/i }))
89
+
.toBeInTheDocument();
90
});
91
await fireEvent.click(
92
screen.getByRole("button", { name: /change email/i }),
93
);
94
await waitFor(() => {
95
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
96
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
97
expect(screen.getByRole("button", { name: /confirm email change/i }))
98
.toBeInTheDocument();
99
});
···
110
capturedBody = JSON.parse((options?.body as string) || "{}");
111
return jsonResponse({});
112
});
113
+
mockEndpoint("com.atproto.server.getSession", () =>
114
+
jsonResponse(mockData.session()));
115
render(Settings);
116
await waitFor(() => {
117
+
expect(screen.getByRole("button", { name: /change email/i }))
118
+
.toBeInTheDocument();
119
});
120
await fireEvent.click(
121
screen.getByRole("button", { name: /change email/i }),
···
126
await fireEvent.input(screen.getByLabelText(/verification code/i), {
127
target: { value: "123456" },
128
});
129
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
130
+
target: { value: "newemail@example.com" },
131
+
});
132
await fireEvent.click(
133
screen.getByRole("button", { name: /confirm email change/i }),
134
);
···
144
() => jsonResponse({ tokenRequired: true }),
145
);
146
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
147
+
mockEndpoint("com.atproto.server.getSession", () =>
148
+
jsonResponse(mockData.session()));
149
render(Settings);
150
await waitFor(() => {
151
+
expect(screen.getByRole("button", { name: /change email/i }))
152
+
.toBeInTheDocument();
153
});
154
await fireEvent.click(
155
screen.getByRole("button", { name: /change email/i }),
···
160
await fireEvent.input(screen.getByLabelText(/verification code/i), {
161
target: { value: "123456" },
162
});
163
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
164
+
target: { value: "new@test.com" },
165
+
});
166
await fireEvent.click(
167
screen.getByRole("button", { name: /confirm email change/i }),
168
);
169
await waitFor(() => {
170
+
expect(screen.getByText(/email updated/i))
171
.toBeInTheDocument();
172
});
173
});
174
+
it("shows cancel button to return to initial state", async () => {
175
mockEndpoint(
176
"com.atproto.server.requestEmailUpdate",
177
() => jsonResponse({ tokenRequired: true }),
178
);
179
render(Settings);
180
await waitFor(() => {
181
+
expect(screen.getByRole("button", { name: /change email/i }))
182
+
.toBeInTheDocument();
183
});
184
await fireEvent.click(
185
screen.getByRole("button", { name: /change email/i }),
···
188
expect(screen.getByRole("button", { name: /cancel/i }))
189
.toBeInTheDocument();
190
});
191
+
const emailSection = screen.getByRole("heading", { name: /change email/i })
192
+
.closest("section");
193
+
const cancelButton = emailSection?.querySelector("button.secondary");
194
+
if (cancelButton) {
195
+
await fireEvent.click(cancelButton);
196
+
}
197
await waitFor(() => {
198
expect(screen.queryByLabelText(/verification code/i)).not
199
.toBeInTheDocument();
200
});
201
});
202
+
it("shows error when request fails", async () => {
203
mockEndpoint(
204
"com.atproto.server.requestEmailUpdate",
205
() => errorResponse("InvalidEmail", "Invalid email format", 400),
206
);
207
render(Settings);
208
await waitFor(() => {
209
+
expect(screen.getByRole("button", { name: /change email/i }))
210
+
.toBeInTheDocument();
211
});
212
await fireEvent.click(
213
screen.getByRole("button", { name: /change email/i }),
···
220
describe("handle change", () => {
221
beforeEach(() => {
222
setupAuthenticatedUser();
223
+
mockEndpoint("com.atproto.server.describeServer", () =>
224
+
jsonResponse(mockData.describeServer()));
225
});
226
it("displays current handle", async () => {
227
render(Settings);
228
await waitFor(() => {
229
+
expect(screen.getByText(/current.*@testuser\.test\.tranquil\.dev/i))
230
.toBeInTheDocument();
231
});
232
});
233
+
it("shows PDS handle and custom domain tabs", async () => {
234
+
render(Settings);
235
+
await waitFor(() => {
236
+
expect(screen.getByRole("button", { name: /pds handle/i }))
237
+
.toBeInTheDocument();
238
+
expect(screen.getByRole("button", { name: /custom domain/i }))
239
+
.toBeInTheDocument();
240
});
241
+
});
242
+
it("allows entering handle and shows domain suffix", async () => {
243
render(Settings);
244
await waitFor(() => {
245
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
246
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
247
});
248
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
249
+
await fireEvent.input(input, {
250
+
target: { value: "newhandle" },
251
});
252
+
expect(input.value).toBe("newhandle");
253
+
expect(screen.getByRole("button", { name: /change handle/i }))
254
+
.toBeInTheDocument();
255
});
256
it("shows success message after handle change", async () => {
257
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
258
+
mockEndpoint("com.atproto.server.getSession", () =>
259
+
jsonResponse(mockData.session()));
260
render(Settings);
261
await waitFor(() => {
262
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
263
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
264
});
265
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
266
+
await fireEvent.input(input, {
267
target: { value: "newhandle" },
268
});
269
+
const button = screen.getByRole("button", { name: /change handle/i });
270
+
await fireEvent.submit(button.closest("form")!);
271
await waitFor(() => {
272
+
expect(screen.getByText(/handle updated/i))
273
.toBeInTheDocument();
274
});
275
});
···
282
render(Settings);
283
await waitFor(() => {
284
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
285
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
286
});
287
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
288
+
await fireEvent.input(input, {
289
target: { value: "taken" },
290
});
291
+
expect(input.value).toBe("taken");
292
+
const button = screen.getByRole("button", { name: /change handle/i });
293
+
await fireEvent.submit(button.closest("form")!);
294
await waitFor(() => {
295
+
const errorMessage = screen.queryByText(/handle is already taken/i) ||
296
+
screen.queryByText(/handle update failed/i);
297
+
expect(errorMessage).toBeInTheDocument();
298
});
299
});
300
});
···
356
});
357
it("shows confirmation dialog before final deletion", async () => {
358
const confirmSpy = vi.fn(() => false);
359
+
globalThis.confirm = confirmSpy;
360
mockEndpoint(
361
"com.atproto.server.requestAccountDelete",
362
() => jsonResponse({}),
···
387
);
388
});
389
it("calls deleteAccount with correct parameters", async () => {
390
+
globalThis.confirm = vi.fn(() => true);
391
let capturedBody: Record<string, string> | null = null;
392
mockEndpoint(
393
"com.atproto.server.requestAccountDelete",
···
425
});
426
});
427
it("navigates to login after successful deletion", async () => {
428
+
globalThis.confirm = vi.fn(() => true);
429
mockEndpoint(
430
"com.atproto.server.requestAccountDelete",
431
() => jsonResponse({}),
···
453
screen.getByRole("button", { name: /permanently delete account/i }),
454
);
455
await waitFor(() => {
456
+
expect(globalThis.location.hash).toBe("#/login");
457
});
458
});
459
it("shows cancel button to return to request state", async () => {
···
491
});
492
});
493
it("shows error when deletion fails", async () => {
494
+
globalThis.confirm = vi.fn(() => true);
495
mockEndpoint(
496
"com.atproto.server.requestAccountDelete",
497
() => jsonResponse({}),
+514
frontend/src/tests/migration/atproto-client.test.ts
+514
frontend/src/tests/migration/atproto-client.test.ts
···
···
1
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
import {
3
+
base64UrlDecode,
4
+
base64UrlEncode,
5
+
buildOAuthAuthorizationUrl,
6
+
clearDPoPKey,
7
+
generateDPoPKeyPair,
8
+
generateOAuthState,
9
+
generatePKCE,
10
+
getMigrationOAuthClientId,
11
+
getMigrationOAuthRedirectUri,
12
+
loadDPoPKey,
13
+
prepareWebAuthnCreationOptions,
14
+
saveDPoPKey,
15
+
} from "../../lib/migration/atproto-client";
16
+
import type { OAuthServerMetadata } from "../../lib/migration/types";
17
+
18
+
const DPOP_KEY_STORAGE = "migration_dpop_key";
19
+
20
+
describe("migration/atproto-client", () => {
21
+
beforeEach(() => {
22
+
localStorage.removeItem(DPOP_KEY_STORAGE);
23
+
});
24
+
25
+
describe("base64UrlEncode", () => {
26
+
it("encodes empty buffer", () => {
27
+
const result = base64UrlEncode(new Uint8Array([]));
28
+
expect(result).toBe("");
29
+
});
30
+
31
+
it("encodes simple data", () => {
32
+
const data = new TextEncoder().encode("hello");
33
+
const result = base64UrlEncode(data);
34
+
expect(result).toBe("aGVsbG8");
35
+
});
36
+
37
+
it("uses URL-safe characters (no +, /, or =)", () => {
38
+
const data = new Uint8Array([251, 255, 254]);
39
+
const result = base64UrlEncode(data);
40
+
expect(result).not.toContain("+");
41
+
expect(result).not.toContain("/");
42
+
expect(result).not.toContain("=");
43
+
});
44
+
45
+
it("replaces + with -", () => {
46
+
const data = new Uint8Array([251]);
47
+
const result = base64UrlEncode(data);
48
+
expect(result).toContain("-");
49
+
});
50
+
51
+
it("replaces / with _", () => {
52
+
const data = new Uint8Array([255]);
53
+
const result = base64UrlEncode(data);
54
+
expect(result).toContain("_");
55
+
});
56
+
57
+
it("accepts ArrayBuffer", () => {
58
+
const arrayBuffer = new ArrayBuffer(4);
59
+
const view = new Uint8Array(arrayBuffer);
60
+
view[0] = 116; // t
61
+
view[1] = 101; // e
62
+
view[2] = 115; // s
63
+
view[3] = 116; // t
64
+
const result = base64UrlEncode(arrayBuffer);
65
+
expect(result).toBe("dGVzdA");
66
+
});
67
+
});
68
+
69
+
describe("base64UrlDecode", () => {
70
+
it("decodes empty string", () => {
71
+
const result = base64UrlDecode("");
72
+
expect(result.length).toBe(0);
73
+
});
74
+
75
+
it("decodes URL-safe base64", () => {
76
+
const result = base64UrlDecode("aGVsbG8");
77
+
expect(new TextDecoder().decode(result)).toBe("hello");
78
+
});
79
+
80
+
it("handles - and _ characters", () => {
81
+
const encoded = base64UrlEncode(new Uint8Array([251, 255, 254]));
82
+
const decoded = base64UrlDecode(encoded);
83
+
expect(decoded).toEqual(new Uint8Array([251, 255, 254]));
84
+
});
85
+
86
+
it("is inverse of base64UrlEncode", () => {
87
+
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
88
+
const encoded = base64UrlEncode(original);
89
+
const decoded = base64UrlDecode(encoded);
90
+
expect(decoded).toEqual(original);
91
+
});
92
+
93
+
it("handles missing padding", () => {
94
+
const result = base64UrlDecode("YQ");
95
+
expect(new TextDecoder().decode(result)).toBe("a");
96
+
});
97
+
});
98
+
99
+
describe("generateOAuthState", () => {
100
+
it("generates a non-empty string", () => {
101
+
const state = generateOAuthState();
102
+
expect(state).toBeTruthy();
103
+
expect(typeof state).toBe("string");
104
+
});
105
+
106
+
it("generates URL-safe characters only", () => {
107
+
const state = generateOAuthState();
108
+
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
109
+
});
110
+
111
+
it("generates different values each time", () => {
112
+
const state1 = generateOAuthState();
113
+
const state2 = generateOAuthState();
114
+
expect(state1).not.toBe(state2);
115
+
});
116
+
});
117
+
118
+
describe("generatePKCE", () => {
119
+
it("generates code_verifier and code_challenge", async () => {
120
+
const pkce = await generatePKCE();
121
+
expect(pkce.codeVerifier).toBeTruthy();
122
+
expect(pkce.codeChallenge).toBeTruthy();
123
+
});
124
+
125
+
it("generates URL-safe code_verifier", async () => {
126
+
const pkce = await generatePKCE();
127
+
expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/);
128
+
});
129
+
130
+
it("generates URL-safe code_challenge", async () => {
131
+
const pkce = await generatePKCE();
132
+
expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/);
133
+
});
134
+
135
+
it("code_challenge is SHA-256 hash of code_verifier", async () => {
136
+
const pkce = await generatePKCE();
137
+
138
+
const encoder = new TextEncoder();
139
+
const data = encoder.encode(pkce.codeVerifier);
140
+
const digest = await crypto.subtle.digest("SHA-256", data);
141
+
const expectedChallenge = base64UrlEncode(new Uint8Array(digest));
142
+
143
+
expect(pkce.codeChallenge).toBe(expectedChallenge);
144
+
});
145
+
146
+
it("generates different values each time", async () => {
147
+
const pkce1 = await generatePKCE();
148
+
const pkce2 = await generatePKCE();
149
+
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
150
+
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
151
+
});
152
+
});
153
+
154
+
describe("buildOAuthAuthorizationUrl", () => {
155
+
const mockMetadata: OAuthServerMetadata = {
156
+
issuer: "https://bsky.social",
157
+
authorization_endpoint: "https://bsky.social/oauth/authorize",
158
+
token_endpoint: "https://bsky.social/oauth/token",
159
+
scopes_supported: ["atproto"],
160
+
response_types_supported: ["code"],
161
+
grant_types_supported: ["authorization_code"],
162
+
code_challenge_methods_supported: ["S256"],
163
+
dpop_signing_alg_values_supported: ["ES256"],
164
+
};
165
+
166
+
it("builds authorization URL with required parameters", () => {
167
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
168
+
clientId: "https://example.com/oauth/client-metadata.json",
169
+
redirectUri: "https://example.com/migrate",
170
+
codeChallenge: "abc123",
171
+
state: "state123",
172
+
});
173
+
174
+
const parsed = new URL(url);
175
+
expect(parsed.origin).toBe("https://bsky.social");
176
+
expect(parsed.pathname).toBe("/oauth/authorize");
177
+
expect(parsed.searchParams.get("response_type")).toBe("code");
178
+
expect(parsed.searchParams.get("client_id")).toBe(
179
+
"https://example.com/oauth/client-metadata.json",
180
+
);
181
+
expect(parsed.searchParams.get("redirect_uri")).toBe(
182
+
"https://example.com/migrate",
183
+
);
184
+
expect(parsed.searchParams.get("code_challenge")).toBe("abc123");
185
+
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
186
+
expect(parsed.searchParams.get("state")).toBe("state123");
187
+
});
188
+
189
+
it("includes default scope when not specified", () => {
190
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
191
+
clientId: "client",
192
+
redirectUri: "redirect",
193
+
codeChallenge: "challenge",
194
+
state: "state",
195
+
});
196
+
197
+
const parsed = new URL(url);
198
+
expect(parsed.searchParams.get("scope")).toBe("atproto");
199
+
});
200
+
201
+
it("includes custom scope when specified", () => {
202
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
203
+
clientId: "client",
204
+
redirectUri: "redirect",
205
+
codeChallenge: "challenge",
206
+
state: "state",
207
+
scope: "atproto identity:*",
208
+
});
209
+
210
+
const parsed = new URL(url);
211
+
expect(parsed.searchParams.get("scope")).toBe("atproto identity:*");
212
+
});
213
+
214
+
it("includes dpop_jkt when specified", () => {
215
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
216
+
clientId: "client",
217
+
redirectUri: "redirect",
218
+
codeChallenge: "challenge",
219
+
state: "state",
220
+
dpopJkt: "dpop-thumbprint-123",
221
+
});
222
+
223
+
const parsed = new URL(url);
224
+
expect(parsed.searchParams.get("dpop_jkt")).toBe("dpop-thumbprint-123");
225
+
});
226
+
227
+
it("includes login_hint when specified", () => {
228
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
229
+
clientId: "client",
230
+
redirectUri: "redirect",
231
+
codeChallenge: "challenge",
232
+
state: "state",
233
+
loginHint: "alice.bsky.social",
234
+
});
235
+
236
+
const parsed = new URL(url);
237
+
expect(parsed.searchParams.get("login_hint")).toBe("alice.bsky.social");
238
+
});
239
+
240
+
it("omits optional params when not specified", () => {
241
+
const url = buildOAuthAuthorizationUrl(mockMetadata, {
242
+
clientId: "client",
243
+
redirectUri: "redirect",
244
+
codeChallenge: "challenge",
245
+
state: "state",
246
+
});
247
+
248
+
const parsed = new URL(url);
249
+
expect(parsed.searchParams.has("dpop_jkt")).toBe(false);
250
+
expect(parsed.searchParams.has("login_hint")).toBe(false);
251
+
});
252
+
});
253
+
254
+
describe("getMigrationOAuthClientId", () => {
255
+
it("returns client metadata URL based on origin", () => {
256
+
const clientId = getMigrationOAuthClientId();
257
+
expect(clientId).toBe(
258
+
`${globalThis.location.origin}/oauth/client-metadata.json`,
259
+
);
260
+
});
261
+
});
262
+
263
+
describe("getMigrationOAuthRedirectUri", () => {
264
+
it("returns migrate path based on origin", () => {
265
+
const redirectUri = getMigrationOAuthRedirectUri();
266
+
expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`);
267
+
});
268
+
});
269
+
270
+
describe("DPoP key management", () => {
271
+
describe("generateDPoPKeyPair", () => {
272
+
it("generates a valid key pair", async () => {
273
+
const keyPair = await generateDPoPKeyPair();
274
+
275
+
expect(keyPair.privateKey).toBeDefined();
276
+
expect(keyPair.publicKey).toBeDefined();
277
+
expect(keyPair.jwk).toBeDefined();
278
+
expect(keyPair.thumbprint).toBeDefined();
279
+
});
280
+
281
+
it("generates ES256 (P-256) keys", async () => {
282
+
const keyPair = await generateDPoPKeyPair();
283
+
284
+
expect(keyPair.jwk.kty).toBe("EC");
285
+
expect(keyPair.jwk.crv).toBe("P-256");
286
+
expect(keyPair.jwk.x).toBeDefined();
287
+
expect(keyPair.jwk.y).toBeDefined();
288
+
});
289
+
290
+
it("generates URL-safe thumbprint", async () => {
291
+
const keyPair = await generateDPoPKeyPair();
292
+
293
+
expect(keyPair.thumbprint).toMatch(/^[A-Za-z0-9_-]+$/);
294
+
});
295
+
296
+
it("generates different keys each time", async () => {
297
+
const keyPair1 = await generateDPoPKeyPair();
298
+
const keyPair2 = await generateDPoPKeyPair();
299
+
300
+
expect(keyPair1.thumbprint).not.toBe(keyPair2.thumbprint);
301
+
});
302
+
});
303
+
304
+
describe("saveDPoPKey", () => {
305
+
it("saves key pair to localStorage", async () => {
306
+
const keyPair = await generateDPoPKeyPair();
307
+
308
+
await saveDPoPKey(keyPair);
309
+
310
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull();
311
+
});
312
+
313
+
it("stores private and public JWK", async () => {
314
+
const keyPair = await generateDPoPKeyPair();
315
+
316
+
await saveDPoPKey(keyPair);
317
+
318
+
const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!);
319
+
expect(stored.privateJwk).toBeDefined();
320
+
expect(stored.publicJwk).toBeDefined();
321
+
expect(stored.thumbprint).toBe(keyPair.thumbprint);
322
+
});
323
+
324
+
it("stores creation timestamp", async () => {
325
+
const before = Date.now();
326
+
const keyPair = await generateDPoPKeyPair();
327
+
await saveDPoPKey(keyPair);
328
+
const after = Date.now();
329
+
330
+
const stored = JSON.parse(localStorage.getItem(DPOP_KEY_STORAGE)!);
331
+
expect(stored.createdAt).toBeGreaterThanOrEqual(before);
332
+
expect(stored.createdAt).toBeLessThanOrEqual(after);
333
+
});
334
+
});
335
+
336
+
describe("loadDPoPKey", () => {
337
+
it("returns null when no key stored", async () => {
338
+
const keyPair = await loadDPoPKey();
339
+
expect(keyPair).toBeNull();
340
+
});
341
+
342
+
it("loads stored key pair", async () => {
343
+
const original = await generateDPoPKeyPair();
344
+
await saveDPoPKey(original);
345
+
346
+
const loaded = await loadDPoPKey();
347
+
348
+
expect(loaded).not.toBeNull();
349
+
expect(loaded!.thumbprint).toBe(original.thumbprint);
350
+
});
351
+
352
+
it("returns null and clears storage for expired key (> 24 hours)", async () => {
353
+
const stored = {
354
+
privateJwk: { kty: "EC", crv: "P-256", x: "test", y: "test", d: "test" },
355
+
publicJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" },
356
+
thumbprint: "test-thumb",
357
+
createdAt: Date.now() - 25 * 60 * 60 * 1000,
358
+
};
359
+
localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
360
+
361
+
const loaded = await loadDPoPKey();
362
+
363
+
expect(loaded).toBeNull();
364
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull();
365
+
});
366
+
367
+
it("returns null and clears storage for invalid data", async () => {
368
+
localStorage.setItem(DPOP_KEY_STORAGE, "not-valid-json");
369
+
370
+
const loaded = await loadDPoPKey();
371
+
372
+
expect(loaded).toBeNull();
373
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull();
374
+
});
375
+
});
376
+
377
+
describe("clearDPoPKey", () => {
378
+
it("removes key from localStorage", async () => {
379
+
const keyPair = await generateDPoPKeyPair();
380
+
await saveDPoPKey(keyPair);
381
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).not.toBeNull();
382
+
383
+
clearDPoPKey();
384
+
385
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull();
386
+
});
387
+
388
+
it("does not throw when nothing to clear", () => {
389
+
expect(() => clearDPoPKey()).not.toThrow();
390
+
});
391
+
});
392
+
});
393
+
394
+
describe("prepareWebAuthnCreationOptions", () => {
395
+
it("decodes challenge from base64url", () => {
396
+
const options = {
397
+
publicKey: {
398
+
challenge: "dGVzdC1jaGFsbGVuZ2U",
399
+
user: {
400
+
id: "dXNlci1pZA",
401
+
name: "test@example.com",
402
+
displayName: "Test User",
403
+
},
404
+
excludeCredentials: [],
405
+
rp: { name: "Test" },
406
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
407
+
},
408
+
};
409
+
410
+
const prepared = prepareWebAuthnCreationOptions(options);
411
+
412
+
expect(prepared.challenge).toBeInstanceOf(Uint8Array);
413
+
expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe(
414
+
"test-challenge",
415
+
);
416
+
});
417
+
418
+
it("decodes user.id from base64url", () => {
419
+
const options = {
420
+
publicKey: {
421
+
challenge: "Y2hhbGxlbmdl",
422
+
user: {
423
+
id: "dXNlci1pZA",
424
+
name: "test@example.com",
425
+
displayName: "Test User",
426
+
},
427
+
excludeCredentials: [],
428
+
rp: { name: "Test" },
429
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
430
+
},
431
+
};
432
+
433
+
const prepared = prepareWebAuthnCreationOptions(options);
434
+
435
+
expect(prepared.user?.id).toBeInstanceOf(Uint8Array);
436
+
expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe(
437
+
"user-id",
438
+
);
439
+
});
440
+
441
+
it("decodes excludeCredentials ids from base64url", () => {
442
+
const options = {
443
+
publicKey: {
444
+
challenge: "Y2hhbGxlbmdl",
445
+
user: {
446
+
id: "dXNlcg",
447
+
name: "test@example.com",
448
+
displayName: "Test User",
449
+
},
450
+
excludeCredentials: [
451
+
{ id: "Y3JlZDE", type: "public-key" },
452
+
{ id: "Y3JlZDI", type: "public-key" },
453
+
],
454
+
rp: { name: "Test" },
455
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
456
+
},
457
+
};
458
+
459
+
const prepared = prepareWebAuthnCreationOptions(options);
460
+
461
+
expect(prepared.excludeCredentials).toHaveLength(2);
462
+
expect(
463
+
new TextDecoder().decode(
464
+
prepared.excludeCredentials![0].id as Uint8Array,
465
+
),
466
+
).toBe("cred1");
467
+
expect(
468
+
new TextDecoder().decode(
469
+
prepared.excludeCredentials![1].id as Uint8Array,
470
+
),
471
+
).toBe("cred2");
472
+
});
473
+
474
+
it("handles empty excludeCredentials", () => {
475
+
const options = {
476
+
publicKey: {
477
+
challenge: "Y2hhbGxlbmdl",
478
+
user: {
479
+
id: "dXNlcg",
480
+
name: "test@example.com",
481
+
displayName: "Test User",
482
+
},
483
+
rp: { name: "Test" },
484
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
485
+
},
486
+
};
487
+
488
+
const prepared = prepareWebAuthnCreationOptions(options);
489
+
490
+
expect(prepared.excludeCredentials).toEqual([]);
491
+
});
492
+
493
+
it("preserves other user properties", () => {
494
+
const options = {
495
+
publicKey: {
496
+
challenge: "Y2hhbGxlbmdl",
497
+
user: {
498
+
id: "dXNlcg",
499
+
name: "test@example.com",
500
+
displayName: "Test User",
501
+
},
502
+
excludeCredentials: [],
503
+
rp: { name: "Test" },
504
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
505
+
},
506
+
};
507
+
508
+
const prepared = prepareWebAuthnCreationOptions(options);
509
+
510
+
expect(prepared.user?.name).toBe("test@example.com");
511
+
expect(prepared.user?.displayName).toBe("Test User");
512
+
});
513
+
});
514
+
});
+509
frontend/src/tests/migration/storage.test.ts
+509
frontend/src/tests/migration/storage.test.ts
···
···
1
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
import {
3
+
clearMigrationState,
4
+
getResumeInfo,
5
+
hasPendingMigration,
6
+
loadMigrationState,
7
+
saveMigrationState,
8
+
setError,
9
+
updateProgress,
10
+
updateStep,
11
+
} from "../../lib/migration/storage";
12
+
import type {
13
+
InboundMigrationState,
14
+
OutboundMigrationState,
15
+
} from "../../lib/migration/types";
16
+
17
+
const STORAGE_KEY = "tranquil_migration_state";
18
+
const DPOP_KEY_STORAGE = "migration_dpop_key";
19
+
20
+
function createInboundState(
21
+
overrides?: Partial<InboundMigrationState>,
22
+
): InboundMigrationState {
23
+
return {
24
+
direction: "inbound",
25
+
step: "welcome",
26
+
sourcePdsUrl: "https://bsky.social",
27
+
sourceDid: "did:plc:abc123",
28
+
sourceHandle: "alice.bsky.social",
29
+
targetHandle: "alice.example.com",
30
+
targetEmail: "alice@example.com",
31
+
targetPassword: "password123",
32
+
inviteCode: "",
33
+
sourceAccessToken: null,
34
+
sourceRefreshToken: null,
35
+
serviceAuthToken: null,
36
+
emailVerifyToken: "",
37
+
plcToken: "",
38
+
progress: {
39
+
repoExported: false,
40
+
repoImported: false,
41
+
blobsTotal: 0,
42
+
blobsMigrated: 0,
43
+
blobsFailed: [],
44
+
prefsMigrated: false,
45
+
plcSigned: false,
46
+
activated: false,
47
+
deactivated: false,
48
+
currentOperation: "",
49
+
},
50
+
error: null,
51
+
targetVerificationMethod: null,
52
+
authMethod: "password",
53
+
passkeySetupToken: null,
54
+
oauthCodeVerifier: null,
55
+
generatedAppPassword: null,
56
+
generatedAppPasswordName: null,
57
+
...overrides,
58
+
};
59
+
}
60
+
61
+
function createOutboundState(
62
+
overrides?: Partial<OutboundMigrationState>,
63
+
): OutboundMigrationState {
64
+
return {
65
+
direction: "outbound",
66
+
step: "welcome",
67
+
localDid: "did:plc:xyz789",
68
+
localHandle: "bob.example.com",
69
+
targetPdsUrl: "https://new-pds.com",
70
+
targetPdsDid: "did:web:new-pds.com",
71
+
targetHandle: "bob.new-pds.com",
72
+
targetEmail: "bob@new-pds.com",
73
+
targetPassword: "password456",
74
+
inviteCode: "",
75
+
targetAccessToken: null,
76
+
targetRefreshToken: null,
77
+
serviceAuthToken: null,
78
+
plcToken: "",
79
+
progress: {
80
+
repoExported: false,
81
+
repoImported: false,
82
+
blobsTotal: 0,
83
+
blobsMigrated: 0,
84
+
blobsFailed: [],
85
+
prefsMigrated: false,
86
+
plcSigned: false,
87
+
activated: false,
88
+
deactivated: false,
89
+
currentOperation: "",
90
+
},
91
+
error: null,
92
+
targetServerInfo: null,
93
+
...overrides,
94
+
};
95
+
}
96
+
97
+
describe("migration/storage", () => {
98
+
beforeEach(() => {
99
+
localStorage.removeItem(STORAGE_KEY);
100
+
localStorage.removeItem(DPOP_KEY_STORAGE);
101
+
});
102
+
103
+
describe("saveMigrationState", () => {
104
+
it("saves inbound migration state to localStorage", () => {
105
+
const state = createInboundState({
106
+
step: "migrating",
107
+
progress: {
108
+
repoExported: true,
109
+
repoImported: false,
110
+
blobsTotal: 10,
111
+
blobsMigrated: 5,
112
+
blobsFailed: [],
113
+
prefsMigrated: false,
114
+
plcSigned: false,
115
+
activated: false,
116
+
deactivated: false,
117
+
currentOperation: "Migrating blobs...",
118
+
},
119
+
});
120
+
121
+
saveMigrationState(state);
122
+
123
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
124
+
expect(stored.version).toBe(1);
125
+
expect(stored.direction).toBe("inbound");
126
+
expect(stored.step).toBe("migrating");
127
+
expect(stored.sourcePdsUrl).toBe("https://bsky.social");
128
+
expect(stored.sourceDid).toBe("did:plc:abc123");
129
+
expect(stored.sourceHandle).toBe("alice.bsky.social");
130
+
expect(stored.targetHandle).toBe("alice.example.com");
131
+
expect(stored.targetEmail).toBe("alice@example.com");
132
+
expect(stored.progress.repoExported).toBe(true);
133
+
expect(stored.progress.blobsMigrated).toBe(5);
134
+
expect(stored.startedAt).toBeDefined();
135
+
expect(new Date(stored.startedAt).getTime()).not.toBeNaN();
136
+
});
137
+
138
+
it("saves outbound migration state to localStorage", () => {
139
+
const state = createOutboundState({
140
+
step: "review",
141
+
});
142
+
143
+
saveMigrationState(state);
144
+
145
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
146
+
expect(stored.version).toBe(1);
147
+
expect(stored.direction).toBe("outbound");
148
+
expect(stored.step).toBe("review");
149
+
expect(stored.targetHandle).toBe("bob.new-pds.com");
150
+
expect(stored.targetEmail).toBe("bob@new-pds.com");
151
+
});
152
+
153
+
it("saves authMethod for inbound migrations", () => {
154
+
const state = createInboundState({ authMethod: "passkey" });
155
+
156
+
saveMigrationState(state);
157
+
158
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
159
+
expect(stored.authMethod).toBe("passkey");
160
+
});
161
+
162
+
it("saves passkeySetupToken when present", () => {
163
+
const state = createInboundState({
164
+
authMethod: "passkey",
165
+
passkeySetupToken: "setup-token-123",
166
+
});
167
+
168
+
saveMigrationState(state);
169
+
170
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
171
+
expect(stored.passkeySetupToken).toBe("setup-token-123");
172
+
});
173
+
174
+
it("saves error information", () => {
175
+
const state = createInboundState({
176
+
step: "error",
177
+
error: "Connection failed",
178
+
});
179
+
180
+
saveMigrationState(state);
181
+
182
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
183
+
expect(stored.lastError).toBe("Connection failed");
184
+
expect(stored.lastErrorStep).toBe("error");
185
+
});
186
+
});
187
+
188
+
describe("loadMigrationState", () => {
189
+
it("returns null when no state is stored", () => {
190
+
expect(loadMigrationState()).toBeNull();
191
+
});
192
+
193
+
it("loads valid migration state", () => {
194
+
const state = createInboundState({ step: "migrating" });
195
+
saveMigrationState(state);
196
+
197
+
const loaded = loadMigrationState();
198
+
199
+
expect(loaded).not.toBeNull();
200
+
expect(loaded!.direction).toBe("inbound");
201
+
expect(loaded!.step).toBe("migrating");
202
+
expect(loaded!.sourceHandle).toBe("alice.bsky.social");
203
+
});
204
+
205
+
it("clears and returns null for incompatible version", () => {
206
+
localStorage.setItem(
207
+
STORAGE_KEY,
208
+
JSON.stringify({
209
+
version: 999,
210
+
direction: "inbound",
211
+
step: "welcome",
212
+
}),
213
+
);
214
+
215
+
const loaded = loadMigrationState();
216
+
217
+
expect(loaded).toBeNull();
218
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
219
+
});
220
+
221
+
it("clears and returns null for expired state (> 24 hours)", () => {
222
+
const expiredState = {
223
+
version: 1,
224
+
direction: "inbound",
225
+
step: "welcome",
226
+
startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
227
+
sourcePdsUrl: "https://bsky.social",
228
+
targetPdsUrl: "http://localhost:3000",
229
+
sourceDid: "did:plc:abc123",
230
+
sourceHandle: "alice.bsky.social",
231
+
targetHandle: "alice.example.com",
232
+
targetEmail: "alice@example.com",
233
+
progress: {
234
+
repoExported: false,
235
+
repoImported: false,
236
+
blobsTotal: 0,
237
+
blobsMigrated: 0,
238
+
prefsMigrated: false,
239
+
plcSigned: false,
240
+
},
241
+
};
242
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState));
243
+
244
+
const loaded = loadMigrationState();
245
+
246
+
expect(loaded).toBeNull();
247
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
248
+
});
249
+
250
+
it("returns state that is not yet expired (< 24 hours)", () => {
251
+
const recentState = {
252
+
version: 1,
253
+
direction: "inbound",
254
+
step: "review",
255
+
startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(),
256
+
sourcePdsUrl: "https://bsky.social",
257
+
targetPdsUrl: "http://localhost:3000",
258
+
sourceDid: "did:plc:abc123",
259
+
sourceHandle: "alice.bsky.social",
260
+
targetHandle: "alice.example.com",
261
+
targetEmail: "alice@example.com",
262
+
progress: {
263
+
repoExported: false,
264
+
repoImported: false,
265
+
blobsTotal: 0,
266
+
blobsMigrated: 0,
267
+
prefsMigrated: false,
268
+
plcSigned: false,
269
+
},
270
+
};
271
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(recentState));
272
+
273
+
const loaded = loadMigrationState();
274
+
275
+
expect(loaded).not.toBeNull();
276
+
expect(loaded!.step).toBe("review");
277
+
});
278
+
279
+
it("clears and returns null for invalid JSON", () => {
280
+
localStorage.setItem(STORAGE_KEY, "not-valid-json");
281
+
282
+
const loaded = loadMigrationState();
283
+
284
+
expect(loaded).toBeNull();
285
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
286
+
});
287
+
});
288
+
289
+
describe("clearMigrationState", () => {
290
+
it("removes migration state from localStorage", () => {
291
+
const state = createInboundState();
292
+
saveMigrationState(state);
293
+
expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull();
294
+
295
+
clearMigrationState();
296
+
297
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
298
+
});
299
+
300
+
it("also removes DPoP key", () => {
301
+
localStorage.setItem(DPOP_KEY_STORAGE, "some-dpop-key");
302
+
const state = createInboundState();
303
+
saveMigrationState(state);
304
+
305
+
clearMigrationState();
306
+
307
+
expect(localStorage.getItem(DPOP_KEY_STORAGE)).toBeNull();
308
+
});
309
+
310
+
it("does not throw when nothing to clear", () => {
311
+
expect(() => clearMigrationState()).not.toThrow();
312
+
});
313
+
});
314
+
315
+
describe("hasPendingMigration", () => {
316
+
it("returns false when no migration state exists", () => {
317
+
expect(hasPendingMigration()).toBe(false);
318
+
});
319
+
320
+
it("returns true when valid migration state exists", () => {
321
+
const state = createInboundState();
322
+
saveMigrationState(state);
323
+
324
+
expect(hasPendingMigration()).toBe(true);
325
+
});
326
+
327
+
it("returns false when state is expired", () => {
328
+
const expiredState = {
329
+
version: 1,
330
+
direction: "inbound",
331
+
step: "welcome",
332
+
startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
333
+
sourcePdsUrl: "https://bsky.social",
334
+
targetPdsUrl: "http://localhost:3000",
335
+
sourceDid: "did:plc:abc123",
336
+
sourceHandle: "alice.bsky.social",
337
+
targetHandle: "alice.example.com",
338
+
targetEmail: "alice@example.com",
339
+
progress: {
340
+
repoExported: false,
341
+
repoImported: false,
342
+
blobsTotal: 0,
343
+
blobsMigrated: 0,
344
+
prefsMigrated: false,
345
+
plcSigned: false,
346
+
},
347
+
};
348
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(expiredState));
349
+
350
+
expect(hasPendingMigration()).toBe(false);
351
+
});
352
+
});
353
+
354
+
describe("getResumeInfo", () => {
355
+
it("returns null when no migration state exists", () => {
356
+
expect(getResumeInfo()).toBeNull();
357
+
});
358
+
359
+
it("returns resume info for inbound migration", () => {
360
+
const state = createInboundState({
361
+
step: "migrating",
362
+
progress: {
363
+
repoExported: true,
364
+
repoImported: true,
365
+
blobsTotal: 10,
366
+
blobsMigrated: 5,
367
+
blobsFailed: [],
368
+
prefsMigrated: false,
369
+
plcSigned: false,
370
+
activated: false,
371
+
deactivated: false,
372
+
currentOperation: "",
373
+
},
374
+
});
375
+
saveMigrationState(state);
376
+
377
+
const info = getResumeInfo();
378
+
379
+
expect(info).not.toBeNull();
380
+
expect(info!.direction).toBe("inbound");
381
+
expect(info!.sourceHandle).toBe("alice.bsky.social");
382
+
expect(info!.targetHandle).toBe("alice.example.com");
383
+
expect(info!.progressSummary).toContain("repo exported");
384
+
expect(info!.progressSummary).toContain("repo imported");
385
+
expect(info!.progressSummary).toContain("5/10 blobs");
386
+
});
387
+
388
+
it("returns 'just started' when no progress made", () => {
389
+
const state = createInboundState({ step: "welcome" });
390
+
saveMigrationState(state);
391
+
392
+
const info = getResumeInfo();
393
+
394
+
expect(info!.progressSummary).toBe("just started");
395
+
});
396
+
397
+
it("includes authMethod for inbound migrations", () => {
398
+
const state = createInboundState({ authMethod: "passkey" });
399
+
saveMigrationState(state);
400
+
401
+
const info = getResumeInfo();
402
+
403
+
expect(info!.authMethod).toBe("passkey");
404
+
});
405
+
406
+
it("includes all completed progress items", () => {
407
+
const state = createInboundState({
408
+
step: "finalizing",
409
+
progress: {
410
+
repoExported: true,
411
+
repoImported: true,
412
+
blobsTotal: 10,
413
+
blobsMigrated: 10,
414
+
blobsFailed: [],
415
+
prefsMigrated: true,
416
+
plcSigned: true,
417
+
activated: false,
418
+
deactivated: false,
419
+
currentOperation: "",
420
+
},
421
+
});
422
+
saveMigrationState(state);
423
+
424
+
const info = getResumeInfo();
425
+
426
+
expect(info!.progressSummary).toContain("repo exported");
427
+
expect(info!.progressSummary).toContain("repo imported");
428
+
expect(info!.progressSummary).toContain("preferences migrated");
429
+
expect(info!.progressSummary).toContain("PLC signed");
430
+
});
431
+
});
432
+
433
+
describe("updateProgress", () => {
434
+
it("updates progress fields in stored state", () => {
435
+
const state = createInboundState();
436
+
saveMigrationState(state);
437
+
438
+
updateProgress({ repoExported: true, blobsTotal: 50 });
439
+
440
+
const loaded = loadMigrationState();
441
+
expect(loaded!.progress.repoExported).toBe(true);
442
+
expect(loaded!.progress.blobsTotal).toBe(50);
443
+
});
444
+
445
+
it("preserves other progress fields", () => {
446
+
const state = createInboundState({
447
+
progress: {
448
+
repoExported: true,
449
+
repoImported: false,
450
+
blobsTotal: 10,
451
+
blobsMigrated: 0,
452
+
blobsFailed: [],
453
+
prefsMigrated: false,
454
+
plcSigned: false,
455
+
activated: false,
456
+
deactivated: false,
457
+
currentOperation: "",
458
+
},
459
+
});
460
+
saveMigrationState(state);
461
+
462
+
updateProgress({ repoImported: true });
463
+
464
+
const loaded = loadMigrationState();
465
+
expect(loaded!.progress.repoExported).toBe(true);
466
+
expect(loaded!.progress.repoImported).toBe(true);
467
+
});
468
+
469
+
it("does nothing when no state exists", () => {
470
+
expect(() => updateProgress({ repoExported: true })).not.toThrow();
471
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
472
+
});
473
+
});
474
+
475
+
describe("updateStep", () => {
476
+
it("updates step in stored state", () => {
477
+
const state = createInboundState({ step: "welcome" });
478
+
saveMigrationState(state);
479
+
480
+
updateStep("migrating");
481
+
482
+
const loaded = loadMigrationState();
483
+
expect(loaded!.step).toBe("migrating");
484
+
});
485
+
486
+
it("does nothing when no state exists", () => {
487
+
expect(() => updateStep("migrating")).not.toThrow();
488
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
489
+
});
490
+
});
491
+
492
+
describe("setError", () => {
493
+
it("sets error and errorStep in stored state", () => {
494
+
const state = createInboundState({ step: "migrating" });
495
+
saveMigrationState(state);
496
+
497
+
setError("Connection timeout", "migrating");
498
+
499
+
const loaded = loadMigrationState();
500
+
expect(loaded!.lastError).toBe("Connection timeout");
501
+
expect(loaded!.lastErrorStep).toBe("migrating");
502
+
});
503
+
504
+
it("does nothing when no state exists", () => {
505
+
expect(() => setError("Error message", "welcome")).not.toThrow();
506
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
507
+
});
508
+
});
509
+
});
+75
frontend/src/tests/migration/types.test.ts
+75
frontend/src/tests/migration/types.test.ts
···
···
1
+
import { describe, expect, it } from "vitest";
2
+
import { MigrationError } from "../../lib/migration/types";
3
+
4
+
describe("migration/types", () => {
5
+
describe("MigrationError", () => {
6
+
it("creates error with message and code", () => {
7
+
const error = new MigrationError("Something went wrong", "ERR_NETWORK");
8
+
9
+
expect(error.message).toBe("Something went wrong");
10
+
expect(error.code).toBe("ERR_NETWORK");
11
+
expect(error.name).toBe("MigrationError");
12
+
});
13
+
14
+
it("defaults recoverable to false", () => {
15
+
const error = new MigrationError("Error", "ERR_CODE");
16
+
17
+
expect(error.recoverable).toBe(false);
18
+
});
19
+
20
+
it("accepts recoverable flag", () => {
21
+
const error = new MigrationError("Temporary error", "ERR_TIMEOUT", true);
22
+
23
+
expect(error.recoverable).toBe(true);
24
+
});
25
+
26
+
it("accepts details object", () => {
27
+
const details = { status: 500, endpoint: "/api/test" };
28
+
const error = new MigrationError(
29
+
"Server error",
30
+
"ERR_SERVER",
31
+
false,
32
+
details,
33
+
);
34
+
35
+
expect(error.details).toEqual(details);
36
+
});
37
+
38
+
it("is instanceof Error", () => {
39
+
const error = new MigrationError("Test", "ERR_TEST");
40
+
41
+
expect(error).toBeInstanceOf(Error);
42
+
expect(error).toBeInstanceOf(MigrationError);
43
+
});
44
+
45
+
it("has proper stack trace", () => {
46
+
const error = new MigrationError("Test", "ERR_TEST");
47
+
48
+
expect(error.stack).toBeDefined();
49
+
expect(error.stack).toContain("MigrationError");
50
+
});
51
+
52
+
it("can be caught as Error", () => {
53
+
let caught: Error | null = null;
54
+
55
+
try {
56
+
throw new MigrationError("Test error", "ERR_TEST");
57
+
} catch (e) {
58
+
caught = e as Error;
59
+
}
60
+
61
+
expect(caught).not.toBeNull();
62
+
expect(caught!.message).toBe("Test error");
63
+
});
64
+
65
+
it("can check if error is MigrationError", () => {
66
+
const error = new MigrationError("Test", "ERR_TEST", true, { foo: "bar" });
67
+
68
+
if (error instanceof MigrationError) {
69
+
expect(error.code).toBe("ERR_TEST");
70
+
expect(error.recoverable).toBe(true);
71
+
expect(error.details).toEqual({ foo: "bar" });
72
+
}
73
+
});
74
+
});
75
+
});
+8
-2
frontend/src/tests/mocks.ts
+8
-2
frontend/src/tests/mocks.ts
···
29
return match ? match[1] : url;
30
}
31
export function setupFetchMock(): void {
32
-
global.fetch = vi.fn(
33
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
34
const url = typeof input === "string" ? input : input.toString();
35
const endpoint = extractEndpoint(url);
···
137
signalVerified: false,
138
...overrides,
139
}),
140
-
describeServer: () => ({
141
availableUserDomains: ["test.tranquil.dev"],
142
inviteCodeRequired: false,
143
links: {
···
145
termsOfService: "https://example.com/tos",
146
},
147
selfHostedDidWebEnabled: true,
148
}),
149
describeRepo: (did: string) => ({
150
handle: "testuser.test.tranquil.dev",
···
210
mockEndpoint(
211
"com.tranquil.account.updateNotificationPrefs",
212
() => jsonResponse({ success: true }),
213
);
214
mockEndpoint(
215
"com.atproto.server.requestEmailUpdate",
···
29
return match ? match[1] : url;
30
}
31
export function setupFetchMock(): void {
32
+
globalThis.fetch = vi.fn(
33
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
34
const url = typeof input === "string" ? input : input.toString();
35
const endpoint = extractEndpoint(url);
···
137
signalVerified: false,
138
...overrides,
139
}),
140
+
describeServer: (overrides?: Record<string, unknown>) => ({
141
availableUserDomains: ["test.tranquil.dev"],
142
inviteCodeRequired: false,
143
links: {
···
145
termsOfService: "https://example.com/tos",
146
},
147
selfHostedDidWebEnabled: true,
148
+
availableCommsChannels: ["email", "discord", "telegram", "signal"],
149
+
...overrides,
150
}),
151
describeRepo: (did: string) => ({
152
handle: "testuser.test.tranquil.dev",
···
212
mockEndpoint(
213
"com.tranquil.account.updateNotificationPrefs",
214
() => jsonResponse({ success: true }),
215
+
);
216
+
mockEndpoint(
217
+
"com.tranquil.account.getNotificationHistory",
218
+
() => jsonResponse({ notifications: [] }),
219
);
220
mockEndpoint(
221
"com.atproto.server.requestEmailUpdate",
+12
-5
frontend/src/tests/setup.ts
+12
-5
frontend/src/tests/setup.ts
···
1
import "@testing-library/jest-dom/vitest";
2
import { afterEach, beforeEach, vi } from "vitest";
3
-
import { _testReset } from "../lib/auth.svelte";
4
5
let locationHash = "";
6
···
24
configurable: true,
25
});
26
27
-
beforeEach(() => {
28
vi.clearAllMocks();
29
-
localStorage.clear();
30
-
sessionStorage.clear();
31
locationHash = "";
32
-
_testReset();
33
});
34
35
afterEach(() => {
···
1
import "@testing-library/jest-dom/vitest";
2
import { afterEach, beforeEach, vi } from "vitest";
3
+
import { init, register, waitLocale } from "svelte-i18n";
4
+
import { _testResetState } from "../lib/auth.svelte";
5
+
6
+
register("en", () => import("../locales/en.json"));
7
+
8
+
init({
9
+
fallbackLocale: "en",
10
+
initialLocale: "en",
11
+
});
12
13
let locationHash = "";
14
···
32
configurable: true,
33
});
34
35
+
beforeEach(async () => {
36
vi.clearAllMocks();
37
locationHash = "";
38
+
_testResetState();
39
+
await waitLocale();
40
});
41
42
afterEach(() => {
+1
frontend/svelte.config.js
+1
frontend/svelte.config.js
+1
frontend/vite.config.ts
+1
frontend/vite.config.ts
+27
-28
src/api/actor/preferences.rs
+27
-28
src/api/actor/preferences.rs
···
108
serde_json::from_value(row.value_json).ok()
109
})
110
.collect();
111
-
if let Some(ref pref) = personal_details_pref {
112
-
if let Some(birth_date) = pref.get("birthDate").and_then(|v| v.as_str()) {
113
-
if let Some(age) = get_age_from_datestring(birth_date) {
114
-
let declared_age_pref = json!({
115
-
"$type": DECLARED_AGE_PREF,
116
-
"isOverAge13": age >= 13,
117
-
"isOverAge16": age >= 16,
118
-
"isOverAge18": age >= 18,
119
-
});
120
-
preferences.push(declared_age_pref);
121
-
}
122
-
}
123
}
124
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
125
}
···
157
}
158
};
159
let has_full_access = auth_user.permissions().has_full_access();
160
-
let user_id: uuid::Uuid = match sqlx::query_scalar!(
161
-
"SELECT id FROM users WHERE did = $1",
162
-
auth_user.did
163
-
)
164
-
.fetch_optional(&state.db)
165
-
.await
166
-
{
167
-
Ok(Some(id)) => id,
168
-
_ => {
169
-
return (
170
-
StatusCode::INTERNAL_SERVER_ERROR,
171
-
Json(json!({"error": "InternalError", "message": "User not found"})),
172
-
)
173
-
.into_response();
174
-
}
175
-
};
176
if input.preferences.len() > MAX_PREFERENCES_COUNT {
177
return (
178
StatusCode::BAD_REQUEST,
···
108
serde_json::from_value(row.value_json).ok()
109
})
110
.collect();
111
+
if let Some(age) = personal_details_pref
112
+
.as_ref()
113
+
.and_then(|pref| pref.get("birthDate"))
114
+
.and_then(|v| v.as_str())
115
+
.and_then(get_age_from_datestring)
116
+
{
117
+
let declared_age_pref = json!({
118
+
"$type": DECLARED_AGE_PREF,
119
+
"isOverAge13": age >= 13,
120
+
"isOverAge16": age >= 16,
121
+
"isOverAge18": age >= 18,
122
+
});
123
+
preferences.push(declared_age_pref);
124
}
125
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
126
}
···
158
}
159
};
160
let has_full_access = auth_user.permissions().has_full_access();
161
+
let user_id: uuid::Uuid =
162
+
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
163
+
.fetch_optional(&state.db)
164
+
.await
165
+
{
166
+
Ok(Some(id)) => id,
167
+
_ => {
168
+
return (
169
+
StatusCode::INTERNAL_SERVER_ERROR,
170
+
Json(json!({"error": "InternalError", "message": "User not found"})),
171
+
)
172
+
.into_response();
173
+
}
174
+
};
175
if input.preferences.len() > MAX_PREFERENCES_COUNT {
176
return (
177
StatusCode::BAD_REQUEST,
+1
-4
src/api/admin/account/info.rs
+1
-4
src/api/admin/account/info.rs
+9
-1
src/api/admin/account/search.rs
+9
-1
src/api/admin/account/search.rs
+4
-1
src/api/admin/account/update.rs
+4
-1
src/api/admin/account/update.rs
···
131
if let Err(e) =
132
crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await
133
{
134
-
warn!("Failed to sequence identity event for admin handle update: {}", e);
135
}
136
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await
137
{
···
131
if let Err(e) =
132
crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await
133
{
134
+
warn!(
135
+
"Failed to sequence identity event for admin handle update: {}",
136
+
e
137
+
);
138
}
139
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await
140
{
+8
-3
src/api/identity/account.rs
+8
-3
src/api/identity/account.rs
···
1005
{
1006
warn!("Failed to sequence account event for {}: {}", did, e);
1007
}
1008
-
if let Err(e) =
1009
-
crate::api::repo::record::sequence_genesis_commit(&state, &did, &commit_cid, &mst_root, &rev_str).await
1010
{
1011
warn!("Failed to sequence commit event for {}: {}", did, e);
1012
}
···
1144
)
1145
.into_response()
1146
}
1147
-
···
1005
{
1006
warn!("Failed to sequence account event for {}: {}", did, e);
1007
}
1008
+
if let Err(e) = crate::api::repo::record::sequence_genesis_commit(
1009
+
&state,
1010
+
&did,
1011
+
&commit_cid,
1012
+
&mst_root,
1013
+
&rev_str,
1014
+
)
1015
+
.await
1016
{
1017
warn!("Failed to sequence commit event for {}: {}", did, e);
1018
}
···
1150
)
1151
.into_response()
1152
}
+74
-74
src/api/identity/did.rs
+74
-74
src/api/identity/did.rs
···
191
192
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
193
194
-
if let Some(ref ovr) = overrides {
195
-
if let Ok(parsed) =
196
-
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
197
-
{
198
-
if !parsed.is_empty() {
199
-
let also_known_as = if !ovr.also_known_as.is_empty() {
200
-
ovr.also_known_as.clone()
201
-
} else {
202
-
vec![format!("at://{}", full_handle)]
203
-
};
204
205
-
return Json(json!({
206
-
"@context": [
207
-
"https://www.w3.org/ns/did/v1",
208
-
"https://w3id.org/security/multikey/v1",
209
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
210
-
],
211
-
"id": did,
212
-
"alsoKnownAs": also_known_as,
213
-
"verificationMethod": parsed.iter().map(|m| json!({
214
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
215
-
"type": m.method_type,
216
-
"controller": did,
217
-
"publicKeyMultibase": m.public_key_multibase
218
-
})).collect::<Vec<_>>(),
219
-
"service": [{
220
-
"id": "#atproto_pds",
221
-
"type": "AtprotoPersonalDataServer",
222
-
"serviceEndpoint": service_endpoint
223
-
}]
224
-
}))
225
-
.into_response();
226
-
}
227
-
}
228
}
229
230
let key_row = sqlx::query!(
···
351
352
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
353
354
-
if let Some(ref ovr) = overrides {
355
-
if let Ok(parsed) =
356
-
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
357
-
{
358
-
if !parsed.is_empty() {
359
-
let also_known_as = if !ovr.also_known_as.is_empty() {
360
-
ovr.also_known_as.clone()
361
-
} else {
362
-
vec![format!("at://{}", full_handle)]
363
-
};
364
365
-
return Json(json!({
366
-
"@context": [
367
-
"https://www.w3.org/ns/did/v1",
368
-
"https://w3id.org/security/multikey/v1",
369
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
370
-
],
371
-
"id": did,
372
-
"alsoKnownAs": also_known_as,
373
-
"verificationMethod": parsed.iter().map(|m| json!({
374
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
375
-
"type": m.method_type,
376
-
"controller": did,
377
-
"publicKeyMultibase": m.public_key_multibase
378
-
})).collect::<Vec<_>>(),
379
-
"service": [{
380
-
"id": "#atproto_pds",
381
-
"type": "AtprotoPersonalDataServer",
382
-
"serviceEndpoint": service_endpoint
383
-
}]
384
-
}))
385
-
.into_response();
386
-
}
387
-
}
388
}
389
390
let key_row = sqlx::query!(
···
637
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
638
Ok(key) => key,
639
Err(_) => {
640
-
warn!("PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation");
641
did_key.clone()
642
}
643
};
···
709
)
710
.into_response();
711
}
712
-
let user_row = match sqlx::query!(
713
-
"SELECT id, handle FROM users WHERE did = $1",
714
-
did
715
-
)
716
-
.fetch_optional(&state.db)
717
-
.await
718
{
719
Ok(Some(row)) => row,
720
_ => return ApiError::InternalError.into_response(),
···
879
match result {
880
Ok(_) => {
881
if !current_handle.is_empty() {
882
-
let _ = state.cache.delete(&format!("handle:{}", current_handle)).await;
883
}
884
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
885
if let Err(e) =
···
191
192
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
193
194
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
195
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
196
+
.ok()
197
+
.filter(|p| !p.is_empty())
198
+
.map(|p| (ovr, p))
199
+
}) {
200
+
let also_known_as = if !ovr.also_known_as.is_empty() {
201
+
ovr.also_known_as.clone()
202
+
} else {
203
+
vec![format!("at://{}", full_handle)]
204
+
};
205
206
+
return Json(json!({
207
+
"@context": [
208
+
"https://www.w3.org/ns/did/v1",
209
+
"https://w3id.org/security/multikey/v1",
210
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
211
+
],
212
+
"id": did,
213
+
"alsoKnownAs": also_known_as,
214
+
"verificationMethod": parsed.iter().map(|m| json!({
215
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
216
+
"type": m.method_type,
217
+
"controller": did,
218
+
"publicKeyMultibase": m.public_key_multibase
219
+
})).collect::<Vec<_>>(),
220
+
"service": [{
221
+
"id": "#atproto_pds",
222
+
"type": "AtprotoPersonalDataServer",
223
+
"serviceEndpoint": service_endpoint
224
+
}]
225
+
}))
226
+
.into_response();
227
}
228
229
let key_row = sqlx::query!(
···
350
351
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
352
353
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
354
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
355
+
.ok()
356
+
.filter(|p| !p.is_empty())
357
+
.map(|p| (ovr, p))
358
+
}) {
359
+
let also_known_as = if !ovr.also_known_as.is_empty() {
360
+
ovr.also_known_as.clone()
361
+
} else {
362
+
vec![format!("at://{}", full_handle)]
363
+
};
364
365
+
return Json(json!({
366
+
"@context": [
367
+
"https://www.w3.org/ns/did/v1",
368
+
"https://w3id.org/security/multikey/v1",
369
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
370
+
],
371
+
"id": did,
372
+
"alsoKnownAs": also_known_as,
373
+
"verificationMethod": parsed.iter().map(|m| json!({
374
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
375
+
"type": m.method_type,
376
+
"controller": did,
377
+
"publicKeyMultibase": m.public_key_multibase
378
+
})).collect::<Vec<_>>(),
379
+
"service": [{
380
+
"id": "#atproto_pds",
381
+
"type": "AtprotoPersonalDataServer",
382
+
"serviceEndpoint": service_endpoint
383
+
}]
384
+
}))
385
+
.into_response();
386
}
387
388
let key_row = sqlx::query!(
···
635
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
636
Ok(key) => key,
637
Err(_) => {
638
+
warn!(
639
+
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
640
+
);
641
did_key.clone()
642
}
643
};
···
709
)
710
.into_response();
711
}
712
+
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
713
+
.fetch_optional(&state.db)
714
+
.await
715
{
716
Ok(Some(row)) => row,
717
_ => return ApiError::InternalError.into_response(),
···
876
match result {
877
Ok(_) => {
878
if !current_handle.is_empty() {
879
+
let _ = state
880
+
.cache
881
+
.delete(&format!("handle:{}", current_handle))
882
+
.await;
883
}
884
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
885
if let Err(e) =
+18
-20
src/api/identity/plc/submit.rs
+18
-20
src/api/identity/plc/submit.rs
···
58
let op = &input.operation;
59
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
60
let public_url = format!("https://{}", hostname);
61
-
let user = match sqlx::query!(
62
-
"SELECT id, handle FROM users WHERE did = $1",
63
-
did
64
-
)
65
-
.fetch_optional(&state.db)
66
-
.await
67
{
68
Ok(Some(row)) => row,
69
_ => {
···
170
)
171
.into_response();
172
}
173
-
if !user.handle.is_empty() {
174
-
if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) {
175
-
let expected_handle = format!("at://{}", user.handle);
176
-
let first_aka = also_known_as.first().and_then(|v| v.as_str());
177
-
if first_aka != Some(&expected_handle) {
178
-
return (
179
-
StatusCode::BAD_REQUEST,
180
-
Json(json!({
181
-
"error": "InvalidRequest",
182
-
"message": "Incorrect handle in alsoKnownAs"
183
-
})),
184
-
)
185
-
.into_response();
186
-
}
187
}
188
}
189
let plc_client = PlcClient::new(None);
···
58
let op = &input.operation;
59
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
60
let public_url = format!("https://{}", hostname);
61
+
let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
62
+
.fetch_optional(&state.db)
63
+
.await
64
{
65
Ok(Some(row)) => row,
66
_ => {
···
167
)
168
.into_response();
169
}
170
+
if let Some(also_known_as) = (!user.handle.is_empty())
171
+
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
172
+
.flatten()
173
+
{
174
+
let expected_handle = format!("at://{}", user.handle);
175
+
let first_aka = also_known_as.first().and_then(|v| v.as_str());
176
+
if first_aka != Some(&expected_handle) {
177
+
return (
178
+
StatusCode::BAD_REQUEST,
179
+
Json(json!({
180
+
"error": "InvalidRequest",
181
+
"message": "Incorrect handle in alsoKnownAs"
182
+
})),
183
+
)
184
+
.into_response();
185
}
186
}
187
let plc_client = PlcClient::new(None);
+7
-13
src/api/moderation/mod.rs
+7
-13
src/api/moderation/mod.rs
···
51
None => return ApiError::AuthenticationRequired.into_response(),
52
};
53
54
-
let auth_user = match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await
55
-
{
56
-
Ok(user) => user,
57
-
Err(e) => return ApiError::from(e).into_response(),
58
-
};
59
60
let did = &auth_user.did;
61
62
if let Some((service_url, service_did)) = get_report_service_config() {
63
-
return proxy_to_report_service(
64
-
&state,
65
-
&auth_user,
66
-
&service_url,
67
-
&service_did,
68
-
&input,
69
-
)
70
-
.await;
71
}
72
73
create_report_locally(&state, did, auth_user.is_takendown, input).await
···
51
None => return ApiError::AuthenticationRequired.into_response(),
52
};
53
54
+
let auth_user =
55
+
match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await {
56
+
Ok(user) => user,
57
+
Err(e) => return ApiError::from(e).into_response(),
58
+
};
59
60
let did = &auth_user.did;
61
62
if let Some((service_url, service_did)) = get_report_service_config() {
63
+
return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input)
64
+
.await;
65
}
66
67
create_report_locally(&state, did, auth_user.is_takendown, input).await
+4
-1
src/api/repo/import.rs
+4
-1
src/api/repo/import.rs
+1
-1
src/api/repo/record/batch.rs
+1
-1
src/api/repo/record/batch.rs
···
1
use super::validation::validate_record_with_status;
2
use super::write::has_verified_comms_channel;
3
-
use crate::validation::ValidationStatus;
4
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
5
use crate::delegation::{self, DelegationActionType};
6
use crate::repo::tracking::TrackingBlockStore;
7
use crate::state::AppState;
8
use axum::{
9
Json,
10
extract::State,
···
1
use super::validation::validate_record_with_status;
2
use super::write::has_verified_comms_channel;
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
use crate::delegation::{self, DelegationActionType};
5
use crate::repo::tracking::TrackingBlockStore;
6
use crate::state::AppState;
7
+
use crate::validation::ValidationStatus;
8
use axum::{
9
Json,
10
extract::State,
+1
-5
src/api/repo/record/delete.rs
+1
-5
src/api/repo/record/delete.rs
+1
-1
src/api/repo/record/write.rs
+1
-1
src/api/repo/record/write.rs
···
1
use super::validation::validate_record_with_status;
2
-
use crate::validation::ValidationStatus;
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
use crate::delegation::{self, DelegationActionType};
5
use crate::repo::tracking::TrackingBlockStore;
6
use crate::state::AppState;
7
use axum::{
8
Json,
9
extract::State,
···
1
use super::validation::validate_record_with_status;
2
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
3
use crate::delegation::{self, DelegationActionType};
4
use crate::repo::tracking::TrackingBlockStore;
5
use crate::state::AppState;
6
+
use crate::validation::ValidationStatus;
7
use axum::{
8
Json,
9
extract::State,
+11
-12
src/api/server/account_status.rs
+11
-12
src/api/server/account_status.rs
···
92
Ok(Some(row)) => (row.repo_root_cid, row.repo_rev),
93
_ => (String::new(), None),
94
};
95
-
let block_count: i64 =
96
-
sqlx::query_scalar!("SELECT COUNT(*) FROM user_blocks WHERE user_id = $1", user_id)
97
-
.fetch_one(&state.db)
98
-
.await
99
-
.unwrap_or(Some(0))
100
-
.unwrap_or(0);
101
let repo_rev = if let Some(rev) = repo_rev_from_db {
102
rev
103
} else if !repo_commit.is_empty() {
···
241
let rotation_keys = doc_data
242
.get("rotationKeys")
243
.and_then(|v| v.as_array())
244
-
.map(|arr| {
245
-
arr.iter()
246
-
.filter_map(|k| k.as_str())
247
-
.collect::<Vec<_>>()
248
-
})
249
.unwrap_or_default();
250
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
251
return Err((
···
440
did
441
);
442
let did_validation_start = std::time::Instant::now();
443
-
if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await {
444
info!(
445
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
446
did,
···
92
Ok(Some(row)) => (row.repo_root_cid, row.repo_rev),
93
_ => (String::new(), None),
94
};
95
+
let block_count: i64 = sqlx::query_scalar!(
96
+
"SELECT COUNT(*) FROM user_blocks WHERE user_id = $1",
97
+
user_id
98
+
)
99
+
.fetch_one(&state.db)
100
+
.await
101
+
.unwrap_or(Some(0))
102
+
.unwrap_or(0);
103
let repo_rev = if let Some(rev) = repo_rev_from_db {
104
rev
105
} else if !repo_commit.is_empty() {
···
243
let rotation_keys = doc_data
244
.get("rotationKeys")
245
.and_then(|v| v.as_array())
246
+
.map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>())
247
.unwrap_or_default();
248
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
249
return Err((
···
438
did
439
);
440
let did_validation_start = std::time::Instant::now();
441
+
if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await
442
+
{
443
info!(
444
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
445
did,
+10
-12
src/api/server/email.rs
+10
-12
src/api/server/email.rs
···
86
"email_update",
87
¤t_email.to_lowercase(),
88
);
89
-
let formatted_code =
90
-
crate::auth::verification_token::format_token_for_display(&code);
91
92
-
let hostname =
93
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
94
-
if let Err(e) = crate::comms::enqueue_email_update_token(
95
-
&state.db,
96
-
user.id,
97
-
&formatted_code,
98
-
&hostname,
99
-
)
100
-
.await
101
{
102
warn!("Failed to enqueue email update notification: {:?}", e);
103
}
104
}
105
106
info!("Email update requested for user {}", user.id);
107
-
(StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response()
108
}
109
110
#[derive(Deserialize)]
···
86
"email_update",
87
¤t_email.to_lowercase(),
88
);
89
+
let formatted_code = crate::auth::verification_token::format_token_for_display(&code);
90
91
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
92
+
if let Err(e) =
93
+
crate::comms::enqueue_email_update_token(&state.db, user.id, &formatted_code, &hostname)
94
+
.await
95
{
96
warn!("Failed to enqueue email update notification: {:?}", e);
97
}
98
}
99
100
info!("Email update requested for user {}", user.id);
101
+
(
102
+
StatusCode::OK,
103
+
Json(json!({ "tokenRequired": token_required })),
104
+
)
105
+
.into_response()
106
}
107
108
#[derive(Deserialize)]
+16
-17
src/api/server/invite.rs
+16
-17
src/api/server/invite.rs
···
1
use crate::api::ApiError;
2
-
use crate::auth::extractor::BearerAuthAdmin;
3
use crate::auth::BearerAuth;
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
114
.filter(|v| !v.is_empty())
115
.unwrap_or_else(|| vec![auth_user.did.clone()]);
116
117
-
let admin_user_id = match sqlx::query_scalar!(
118
-
"SELECT id FROM users WHERE is_admin = true LIMIT 1"
119
-
)
120
-
.fetch_optional(&state.db)
121
-
.await
122
-
{
123
-
Ok(Some(id)) => id,
124
-
Ok(None) => {
125
-
error!("No admin user found to create invite codes");
126
-
return ApiError::InternalError.into_response();
127
-
}
128
-
Err(e) => {
129
-
error!("DB error looking up admin user: {:?}", e);
130
-
return ApiError::InternalError.into_response();
131
-
}
132
-
};
133
134
let mut result_codes = Vec::new();
135
···
1
use crate::api::ApiError;
2
use crate::auth::BearerAuth;
3
+
use crate::auth::extractor::BearerAuthAdmin;
4
use crate::state::AppState;
5
use axum::{
6
Json,
···
114
.filter(|v| !v.is_empty())
115
.unwrap_or_else(|| vec![auth_user.did.clone()]);
116
117
+
let admin_user_id =
118
+
match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1")
119
+
.fetch_optional(&state.db)
120
+
.await
121
+
{
122
+
Ok(Some(id)) => id,
123
+
Ok(None) => {
124
+
error!("No admin user found to create invite codes");
125
+
return ApiError::InternalError.into_response();
126
+
}
127
+
Err(e) => {
128
+
error!("DB error looking up admin user: {:?}", e);
129
+
return ApiError::InternalError.into_response();
130
+
}
131
+
};
132
133
let mut result_codes = Vec::new();
134
+42
-49
src/api/server/migration.rs
+42
-49
src/api/server/migration.rs
···
332
333
if let Some(ref methods) = input.verification_methods {
334
if methods.is_empty() {
335
-
return ApiError::InvalidRequest(
336
-
"verification_methods cannot be empty".into(),
337
-
)
338
-
.into_response();
339
}
340
for method in methods {
341
if method.id.is_empty() {
···
366
if let Some(ref handles) = input.also_known_as {
367
for handle in handles {
368
if !handle.starts_with("at://") {
369
-
return ApiError::InvalidRequest(
370
-
"alsoKnownAs entries must be at:// URIs".into(),
371
-
)
372
-
.into_response();
373
}
374
}
375
}
···
377
if let Some(ref endpoint) = input.service_endpoint {
378
let endpoint = endpoint.trim();
379
if !endpoint.starts_with("https://") {
380
-
return ApiError::InvalidRequest(
381
-
"serviceEndpoint must start with https://".into(),
382
-
)
383
-
.into_response();
384
}
385
}
386
···
523
.migrated_to_pds
524
.unwrap_or_else(|| format!("https://{}", hostname));
525
526
-
if let Some(ref ovr) = overrides {
527
-
if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
528
-
if !parsed.is_empty() {
529
-
let also_known_as = if !ovr.also_known_as.is_empty() {
530
-
ovr.also_known_as.clone()
531
-
} else {
532
-
vec![format!("at://{}", user.handle)]
533
-
};
534
-
return json!({
535
-
"@context": [
536
-
"https://www.w3.org/ns/did/v1",
537
-
"https://w3id.org/security/multikey/v1",
538
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
539
-
],
540
-
"id": did,
541
-
"alsoKnownAs": also_known_as,
542
-
"verificationMethod": parsed.iter().map(|m| json!({
543
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
544
-
"type": m.method_type,
545
-
"controller": did,
546
-
"publicKeyMultibase": m.public_key_multibase
547
-
})).collect::<Vec<_>>(),
548
-
"service": [{
549
-
"id": "#atproto_pds",
550
-
"type": "AtprotoPersonalDataServer",
551
-
"serviceEndpoint": service_endpoint
552
-
}]
553
-
});
554
-
}
555
-
}
556
}
557
558
let key_row = sqlx::query!(
···
563
.await;
564
565
let public_key_multibase = match key_row {
566
-
Ok(Some(row)) => {
567
-
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
568
-
Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
569
-
.unwrap_or_else(|_| "error".to_string()),
570
-
Err(_) => "error".to_string(),
571
-
}
572
-
}
573
_ => "error".to_string(),
574
};
575
···
332
333
if let Some(ref methods) = input.verification_methods {
334
if methods.is_empty() {
335
+
return ApiError::InvalidRequest("verification_methods cannot be empty".into())
336
+
.into_response();
337
}
338
for method in methods {
339
if method.id.is_empty() {
···
364
if let Some(ref handles) = input.also_known_as {
365
for handle in handles {
366
if !handle.starts_with("at://") {
367
+
return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
368
+
.into_response();
369
}
370
}
371
}
···
373
if let Some(ref endpoint) = input.service_endpoint {
374
let endpoint = endpoint.trim();
375
if !endpoint.starts_with("https://") {
376
+
return ApiError::InvalidRequest("serviceEndpoint must start with https://".into())
377
+
.into_response();
378
}
379
}
380
···
517
.migrated_to_pds
518
.unwrap_or_else(|| format!("https://{}", hostname));
519
520
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
521
+
serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone())
522
+
.ok()
523
+
.filter(|p| !p.is_empty())
524
+
.map(|p| (ovr, p))
525
+
}) {
526
+
let also_known_as = if !ovr.also_known_as.is_empty() {
527
+
ovr.also_known_as.clone()
528
+
} else {
529
+
vec![format!("at://{}", user.handle)]
530
+
};
531
+
return json!({
532
+
"@context": [
533
+
"https://www.w3.org/ns/did/v1",
534
+
"https://w3id.org/security/multikey/v1",
535
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
536
+
],
537
+
"id": did,
538
+
"alsoKnownAs": also_known_as,
539
+
"verificationMethod": parsed.iter().map(|m| json!({
540
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
541
+
"type": m.method_type,
542
+
"controller": did,
543
+
"publicKeyMultibase": m.public_key_multibase
544
+
})).collect::<Vec<_>>(),
545
+
"service": [{
546
+
"id": "#atproto_pds",
547
+
"type": "AtprotoPersonalDataServer",
548
+
"serviceEndpoint": service_endpoint
549
+
}]
550
+
});
551
}
552
553
let key_row = sqlx::query!(
···
558
.await;
559
560
let public_key_multibase = match key_row {
561
+
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
562
+
Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
563
+
.unwrap_or_else(|_| "error".to_string()),
564
+
Err(_) => "error".to_string(),
565
+
},
566
_ => "error".to_string(),
567
};
568
+104
-30
src/api/server/passkey_account.rs
+104
-30
src/api/server/passkey_account.rs
···
84
pub handle: String,
85
pub setup_token: String,
86
pub setup_expires_at: chrono::DateTime<Utc>,
87
}
88
89
pub async fn create_passkey_account(
···
378
d.to_string()
379
}
380
_ => {
381
-
let rotation_key = std::env::var("PLC_ROTATION_KEY")
382
-
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
383
-
384
-
let genesis_result = match crate::plc::create_genesis_operation(
385
-
&secret_key,
386
-
&rotation_key,
387
-
&handle,
388
-
&pds_endpoint,
389
-
) {
390
-
Ok(r) => r,
391
-
Err(e) => {
392
-
error!("Error creating PLC genesis operation: {:?}", e);
393
return (
394
-
StatusCode::INTERNAL_SERVER_ERROR,
395
-
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
396
)
397
.into_response();
398
}
399
-
};
400
401
-
let plc_client = crate::plc::PlcClient::new(None);
402
-
if let Err(e) = plc_client
403
-
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
404
-
.await
405
-
{
406
-
error!("Failed to submit PLC genesis operation: {:?}", e);
407
-
return (
408
-
StatusCode::BAD_GATEWAY,
409
-
Json(json!({
410
-
"error": "UpstreamError",
411
-
"message": format!("Failed to register DID with PLC directory: {}", e)
412
-
})),
413
-
)
414
-
.into_response();
415
}
416
-
genesis_result.did
417
}
418
};
419
···
726
727
info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
728
729
Json(CreatePasskeyAccountResponse {
730
did,
731
handle,
732
setup_token,
733
setup_expires_at,
734
})
735
.into_response()
736
}
···
84
pub handle: String,
85
pub setup_token: String,
86
pub setup_expires_at: chrono::DateTime<Utc>,
87
+
#[serde(skip_serializing_if = "Option::is_none")]
88
+
pub access_jwt: Option<String>,
89
}
90
91
pub async fn create_passkey_account(
···
380
d.to_string()
381
}
382
_ => {
383
+
if let Some(ref auth_did) = byod_auth {
384
+
if let Some(ref provided_did) = input.did {
385
+
if provided_did.starts_with("did:plc:") {
386
+
if provided_did != auth_did {
387
+
return (
388
+
StatusCode::FORBIDDEN,
389
+
Json(json!({
390
+
"error": "AuthorizationError",
391
+
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
392
+
})),
393
+
)
394
+
.into_response();
395
+
}
396
+
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
397
+
provided_did.clone()
398
+
} else {
399
+
return (
400
+
StatusCode::BAD_REQUEST,
401
+
Json(json!({
402
+
"error": "InvalidRequest",
403
+
"message": "BYOD migration requires a did:plc or did:web DID"
404
+
})),
405
+
)
406
+
.into_response();
407
+
}
408
+
} else {
409
return (
410
+
StatusCode::BAD_REQUEST,
411
+
Json(json!({
412
+
"error": "InvalidRequest",
413
+
"message": "BYOD migration requires the 'did' field"
414
+
})),
415
)
416
.into_response();
417
}
418
+
} else {
419
+
let rotation_key = std::env::var("PLC_ROTATION_KEY")
420
+
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
421
422
+
let genesis_result = match crate::plc::create_genesis_operation(
423
+
&secret_key,
424
+
&rotation_key,
425
+
&handle,
426
+
&pds_endpoint,
427
+
) {
428
+
Ok(r) => r,
429
+
Err(e) => {
430
+
error!("Error creating PLC genesis operation: {:?}", e);
431
+
return (
432
+
StatusCode::INTERNAL_SERVER_ERROR,
433
+
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
434
+
)
435
+
.into_response();
436
+
}
437
+
};
438
+
439
+
let plc_client = crate::plc::PlcClient::new(None);
440
+
if let Err(e) = plc_client
441
+
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
442
+
.await
443
+
{
444
+
error!("Failed to submit PLC genesis operation: {:?}", e);
445
+
return (
446
+
StatusCode::BAD_GATEWAY,
447
+
Json(json!({
448
+
"error": "UpstreamError",
449
+
"message": format!("Failed to register DID with PLC directory: {}", e)
450
+
})),
451
+
)
452
+
.into_response();
453
+
}
454
+
genesis_result.did
455
}
456
}
457
};
458
···
765
766
info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
767
768
+
let access_jwt = if byod_auth.is_some() {
769
+
match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) {
770
+
Ok(token_meta) => {
771
+
let refresh_jti = uuid::Uuid::new_v4().to_string();
772
+
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
773
+
let no_scope: Option<String> = None;
774
+
if let Err(e) = sqlx::query!(
775
+
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
776
+
did,
777
+
token_meta.jti,
778
+
refresh_jti,
779
+
token_meta.expires_at,
780
+
refresh_expires,
781
+
false,
782
+
false,
783
+
no_scope
784
+
)
785
+
.execute(&state.db)
786
+
.await
787
+
{
788
+
warn!(did = %did, "Failed to insert migration session: {:?}", e);
789
+
}
790
+
info!(did = %did, "Generated migration access token for BYOD passkey account");
791
+
Some(token_meta.token)
792
+
}
793
+
Err(e) => {
794
+
warn!(did = %did, "Failed to generate migration access token: {:?}", e);
795
+
None
796
+
}
797
+
}
798
+
} else {
799
+
None
800
+
};
801
+
802
Json(CreatePasskeyAccountResponse {
803
did,
804
handle,
805
setup_token,
806
setup_expires_at,
807
+
access_jwt,
808
})
809
.into_response()
810
}
+3
-6
src/api/server/session.rs
+3
-6
src/api/server/session.rs
···
334
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
335
let handle = full_handle(&row.handle, &pds_hostname);
336
let is_takendown = row.takedown_ref.is_some();
337
-
let is_migrated =
338
-
row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
339
let is_active = row.deactivated_at.is_none() && !is_takendown;
340
let email_value = if can_read_email {
341
row.email.clone()
···
368
if let Some(doc) = did_doc {
369
response["didDoc"] = doc;
370
}
371
-
Json(response)
372
-
.into_response()
373
}
374
Ok(None) => ApiError::AuthenticationFailed.into_response(),
375
Err(e) => {
···
613
} else if u.deactivated_at.is_some() {
614
response["status"] = json!("deactivated");
615
}
616
-
Json(response)
617
-
.into_response()
618
}
619
Ok(None) => {
620
error!("User not found for existing session: {}", session_row.did);
···
334
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
335
let handle = full_handle(&row.handle, &pds_hostname);
336
let is_takendown = row.takedown_ref.is_some();
337
+
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
338
let is_active = row.deactivated_at.is_none() && !is_takendown;
339
let email_value = if can_read_email {
340
row.email.clone()
···
367
if let Some(doc) = did_doc {
368
response["didDoc"] = doc;
369
}
370
+
Json(response).into_response()
371
}
372
Ok(None) => ApiError::AuthenticationFailed.into_response(),
373
Err(e) => {
···
611
} else if u.deactivated_at.is_some() {
612
response["status"] = json!("deactivated");
613
}
614
+
Json(response).into_response()
615
}
616
Ok(None) => {
617
error!("User not found for existing session: {}", session_row.did);
+2
-14
src/handle/reserved.rs
+2
-14
src/handle/reserved.rs
+4
-1
src/main.rs
+4
-1
src/main.rs
···
5
use tracing::{error, info, warn};
6
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
-
use tranquil_pds::scheduled::{backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, start_scheduled_tasks};
9
use tranquil_pds::state::AppState;
10
11
#[tokio::main]
···
5
use tracing::{error, info, warn};
6
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
+
use tranquil_pds::scheduled::{
9
+
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
10
+
start_scheduled_tasks,
11
+
};
12
use tranquil_pds::state::AppState;
13
14
#[tokio::main]
+5
-2
src/oauth/endpoints/metadata.rs
+5
-2
src/oauth/endpoints/metadata.rs
···
167
client_id,
168
client_name: "PDS Account Manager".to_string(),
169
client_uri: base_url.clone(),
170
-
redirect_uris: vec![format!("{}/", base_url)],
171
grant_types: vec![
172
"authorization_code".to_string(),
173
"refresh_token".to_string(),
174
],
175
response_types: vec!["code".to_string()],
176
-
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
177
.to_string(),
178
token_endpoint_auth_method: "none".to_string(),
179
application_type: "web".to_string(),
···
167
client_id,
168
client_name: "PDS Account Manager".to_string(),
169
client_uri: base_url.clone(),
170
+
redirect_uris: vec![
171
+
format!("{}/", base_url),
172
+
format!("{}/migrate", base_url),
173
+
],
174
grant_types: vec![
175
"authorization_code".to_string(),
176
"refresh_token".to_string(),
177
],
178
response_types: vec!["code".to_string()],
179
+
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*"
180
.to_string(),
181
token_endpoint_auth_method: "none".to_string(),
182
application_type: "web".to_string(),
+33
-39
src/scheduled.rs
+33
-39
src/scheduled.rs
···
1
use cid::Cid;
2
use jacquard_repo::commit::Commit;
3
use jacquard_repo::storage::BlockStore;
4
-
use ipld_core::ipld::Ipld;
5
use sqlx::PgPool;
6
use std::str::FromStr;
7
use std::sync::Arc;
···
107
}
108
}
109
110
-
info!(success, failed, "Completed genesis commit blocks_cids backfill");
111
}
112
113
pub async fn backfill_repo_rev(db: &PgPool, block_store: PostgresBlockStore) {
114
-
let repos_missing_rev = match sqlx::query!(
115
-
"SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL"
116
-
)
117
-
.fetch_all(db)
118
-
.await
119
-
{
120
-
Ok(rows) => rows,
121
-
Err(e) => {
122
-
error!("Failed to query repos for backfill: {}", e);
123
-
return;
124
-
}
125
-
};
126
127
if repos_missing_rev.is_empty() {
128
debug!("No repos need repo_rev backfill");
···
244
if let Some(prev) = commit.prev {
245
to_visit.push(prev);
246
}
247
-
} else if let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
248
-
if let Ipld::Map(ref obj) = ipld {
249
-
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
250
-
to_visit.push(*left_cid);
251
-
}
252
-
if let Some(Ipld::List(entries)) = obj.get("e") {
253
-
for entry in entries {
254
-
if let Ipld::Map(entry_obj) = entry {
255
-
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
256
-
to_visit.push(*tree_cid);
257
-
}
258
-
if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") {
259
-
to_visit.push(*val_cid);
260
-
}
261
}
262
}
263
}
···
361
362
let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0);
363
for blob_ref in blob_refs {
364
-
let record_uri = format!(
365
-
"at://{}/{}/{}",
366
-
user.did, record.collection, record.rkey
367
-
);
368
if let Err(e) = sqlx::query!(
369
r#"
370
INSERT INTO record_blobs (repo_id, record_uri, blob_cid)
···
490
did: &str,
491
_handle: &str,
492
) -> Result<(), String> {
493
-
let user_id: uuid::Uuid = sqlx::query_scalar!(
494
-
"SELECT id FROM users WHERE did = $1",
495
-
did
496
-
)
497
-
.fetch_one(db)
498
-
.await
499
-
.map_err(|e| format!("DB error fetching user: {}", e))?;
500
501
let blob_storage_keys: Vec<String> = sqlx::query_scalar!(
502
r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
···
1
use cid::Cid;
2
+
use ipld_core::ipld::Ipld;
3
use jacquard_repo::commit::Commit;
4
use jacquard_repo::storage::BlockStore;
5
use sqlx::PgPool;
6
use std::str::FromStr;
7
use std::sync::Arc;
···
107
}
108
}
109
110
+
info!(
111
+
success,
112
+
failed, "Completed genesis commit blocks_cids backfill"
113
+
);
114
}
115
116
pub async fn backfill_repo_rev(db: &PgPool, block_store: PostgresBlockStore) {
117
+
let repos_missing_rev =
118
+
match sqlx::query!("SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL")
119
+
.fetch_all(db)
120
+
.await
121
+
{
122
+
Ok(rows) => rows,
123
+
Err(e) => {
124
+
error!("Failed to query repos for backfill: {}", e);
125
+
return;
126
+
}
127
+
};
128
129
if repos_missing_rev.is_empty() {
130
debug!("No repos need repo_rev backfill");
···
246
if let Some(prev) = commit.prev {
247
to_visit.push(prev);
248
}
249
+
} else if let Ok(Ipld::Map(ref obj)) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
250
+
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
251
+
to_visit.push(*left_cid);
252
+
}
253
+
if let Some(Ipld::List(entries)) = obj.get("e") {
254
+
for entry in entries {
255
+
if let Ipld::Map(entry_obj) = entry {
256
+
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
257
+
to_visit.push(*tree_cid);
258
+
}
259
+
if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") {
260
+
to_visit.push(*val_cid);
261
}
262
}
263
}
···
361
362
let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0);
363
for blob_ref in blob_refs {
364
+
let record_uri = format!("at://{}/{}/{}", user.did, record.collection, record.rkey);
365
if let Err(e) = sqlx::query!(
366
r#"
367
INSERT INTO record_blobs (repo_id, record_uri, blob_cid)
···
487
did: &str,
488
_handle: &str,
489
) -> Result<(), String> {
490
+
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
491
+
.fetch_one(db)
492
+
.await
493
+
.map_err(|e| format!("DB error fetching user: {}", e))?;
494
495
let blob_storage_keys: Vec<String> = sqlx::query_scalar!(
496
r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
+101
-28
tests/account_lifecycle.rs
+101
-28
tests/account_lifecycle.rs
···
11
let (access_jwt, did) = create_account_and_login(&client).await;
12
13
let status1 = client
14
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
15
.bearer_auth(&access_jwt)
16
.send()
17
.await
···
19
assert_eq!(status1.status(), StatusCode::OK);
20
let body1: Value = status1.json().await.unwrap();
21
let initial_blocks = body1["repoBlocks"].as_i64().unwrap();
22
-
assert!(initial_blocks >= 2, "New account should have at least 2 blocks (commit + empty MST)");
23
24
let create_res = client
25
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
···
38
.unwrap();
39
assert_eq!(create_res.status(), StatusCode::OK);
40
let create_body: Value = create_res.json().await.unwrap();
41
-
let rkey = create_body["uri"].as_str().unwrap().split('/').last().unwrap().to_string();
42
43
let status2 = client
44
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
45
.bearer_auth(&access_jwt)
46
.send()
47
.await
48
.unwrap();
49
let body2: Value = status2.json().await.unwrap();
50
let after_create_blocks = body2["repoBlocks"].as_i64().unwrap();
51
-
assert!(after_create_blocks > initial_blocks, "Block count should increase after creating a record");
52
53
let delete_res = client
54
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
···
64
assert_eq!(delete_res.status(), StatusCode::OK);
65
66
let status3 = client
67
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
68
.bearer_auth(&access_jwt)
69
.send()
70
.await
···
86
let (access_jwt, _) = create_account_and_login(&client).await;
87
88
let status = client
89
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
90
.bearer_auth(&access_jwt)
91
.send()
92
.await
···
96
97
let repo_rev = body["repoRev"].as_str().unwrap();
98
assert!(!repo_rev.is_empty(), "repoRev should not be empty");
99
-
assert!(repo_rev.chars().all(|c| c.is_alphanumeric()), "repoRev should be alphanumeric TID");
100
}
101
102
#[tokio::test]
···
106
let (access_jwt, _) = create_account_and_login(&client).await;
107
108
let status = client
109
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
110
.bearer_auth(&access_jwt)
111
.send()
112
.await
···
114
assert_eq!(status.status(), StatusCode::OK);
115
let body: Value = status.json().await.unwrap();
116
117
-
assert_eq!(body["validDid"], true, "validDid should be true for active account with correct DID document");
118
-
assert_eq!(body["activated"], true, "activated should be true for active account");
119
}
120
121
#[tokio::test]
···
128
let delete_after = future_time.to_rfc3339();
129
130
let deactivate = client
131
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
132
.bearer_auth(&access_jwt)
133
.json(&json!({
134
"deleteAfter": delete_after
···
139
assert_eq!(deactivate.status(), StatusCode::OK);
140
141
let status = client
142
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
143
.bearer_auth(&access_jwt)
144
.send()
145
.await
···
170
assert_eq!(create_res.status(), StatusCode::OK);
171
let body: Value = create_res.json().await.unwrap();
172
173
-
assert!(body["accessJwt"].is_string(), "accessJwt should always be returned");
174
-
assert!(body["refreshJwt"].is_string(), "refreshJwt should always be returned");
175
assert!(body["did"].is_string(), "did should be returned");
176
177
if body["didDoc"].is_object() {
···
201
assert_eq!(create_res.status(), StatusCode::OK);
202
let body: Value = create_res.json().await.unwrap();
203
204
-
let access_jwt = body["accessJwt"].as_str().expect("accessJwt should be present");
205
-
let refresh_jwt = body["refreshJwt"].as_str().expect("refreshJwt should be present");
206
207
assert!(!access_jwt.is_empty(), "accessJwt should not be empty");
208
assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty");
209
210
let parts: Vec<&str> = access_jwt.split('.').collect();
211
-
assert_eq!(parts.len(), 3, "accessJwt should be a valid JWT with 3 parts");
212
}
213
214
#[tokio::test]
···
224
assert_eq!(describe.status(), StatusCode::OK);
225
let body: Value = describe.json().await.unwrap();
226
227
-
assert!(body.get("links").is_some(), "describeServer should include links object");
228
-
assert!(body.get("contact").is_some(), "describeServer should include contact object");
229
230
let links = &body["links"];
231
-
assert!(links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(),
232
-
"links should have privacyPolicy field (can be null)");
233
-
assert!(links.get("termsOfService").is_some() || links["termsOfService"].is_null(),
234
-
"links should have termsOfService field (can be null)");
235
236
let contact = &body["contact"];
237
-
assert!(contact.get("email").is_some() || contact["email"].is_null(),
238
-
"contact should have email field (can be null)");
239
}
240
241
#[tokio::test]
···
274
275
assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
276
let error_body: Value = delete_res.json().await.unwrap();
277
-
assert!(error_body["message"].as_str().unwrap().contains("password length")
278
-
|| error_body["error"].as_str().unwrap() == "InvalidRequest");
279
}
···
11
let (access_jwt, did) = create_account_and_login(&client).await;
12
13
let status1 = client
14
+
.get(format!(
15
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
16
+
base
17
+
))
18
.bearer_auth(&access_jwt)
19
.send()
20
.await
···
22
assert_eq!(status1.status(), StatusCode::OK);
23
let body1: Value = status1.json().await.unwrap();
24
let initial_blocks = body1["repoBlocks"].as_i64().unwrap();
25
+
assert!(
26
+
initial_blocks >= 2,
27
+
"New account should have at least 2 blocks (commit + empty MST)"
28
+
);
29
30
let create_res = client
31
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
···
44
.unwrap();
45
assert_eq!(create_res.status(), StatusCode::OK);
46
let create_body: Value = create_res.json().await.unwrap();
47
+
let rkey = create_body["uri"]
48
+
.as_str()
49
+
.unwrap()
50
+
.split('/')
51
+
.last()
52
+
.unwrap()
53
+
.to_string();
54
55
let status2 = client
56
+
.get(format!(
57
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
58
+
base
59
+
))
60
.bearer_auth(&access_jwt)
61
.send()
62
.await
63
.unwrap();
64
let body2: Value = status2.json().await.unwrap();
65
let after_create_blocks = body2["repoBlocks"].as_i64().unwrap();
66
+
assert!(
67
+
after_create_blocks > initial_blocks,
68
+
"Block count should increase after creating a record"
69
+
);
70
71
let delete_res = client
72
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
···
82
assert_eq!(delete_res.status(), StatusCode::OK);
83
84
let status3 = client
85
+
.get(format!(
86
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
87
+
base
88
+
))
89
.bearer_auth(&access_jwt)
90
.send()
91
.await
···
107
let (access_jwt, _) = create_account_and_login(&client).await;
108
109
let status = client
110
+
.get(format!(
111
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
112
+
base
113
+
))
114
.bearer_auth(&access_jwt)
115
.send()
116
.await
···
120
121
let repo_rev = body["repoRev"].as_str().unwrap();
122
assert!(!repo_rev.is_empty(), "repoRev should not be empty");
123
+
assert!(
124
+
repo_rev.chars().all(|c| c.is_alphanumeric()),
125
+
"repoRev should be alphanumeric TID"
126
+
);
127
}
128
129
#[tokio::test]
···
133
let (access_jwt, _) = create_account_and_login(&client).await;
134
135
let status = client
136
+
.get(format!(
137
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
138
+
base
139
+
))
140
.bearer_auth(&access_jwt)
141
.send()
142
.await
···
144
assert_eq!(status.status(), StatusCode::OK);
145
let body: Value = status.json().await.unwrap();
146
147
+
assert_eq!(
148
+
body["validDid"], true,
149
+
"validDid should be true for active account with correct DID document"
150
+
);
151
+
assert_eq!(
152
+
body["activated"], true,
153
+
"activated should be true for active account"
154
+
);
155
}
156
157
#[tokio::test]
···
164
let delete_after = future_time.to_rfc3339();
165
166
let deactivate = client
167
+
.post(format!(
168
+
"{}/xrpc/com.atproto.server.deactivateAccount",
169
+
base
170
+
))
171
.bearer_auth(&access_jwt)
172
.json(&json!({
173
"deleteAfter": delete_after
···
178
assert_eq!(deactivate.status(), StatusCode::OK);
179
180
let status = client
181
+
.get(format!(
182
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
183
+
base
184
+
))
185
.bearer_auth(&access_jwt)
186
.send()
187
.await
···
212
assert_eq!(create_res.status(), StatusCode::OK);
213
let body: Value = create_res.json().await.unwrap();
214
215
+
assert!(
216
+
body["accessJwt"].is_string(),
217
+
"accessJwt should always be returned"
218
+
);
219
+
assert!(
220
+
body["refreshJwt"].is_string(),
221
+
"refreshJwt should always be returned"
222
+
);
223
assert!(body["did"].is_string(), "did should be returned");
224
225
if body["didDoc"].is_object() {
···
249
assert_eq!(create_res.status(), StatusCode::OK);
250
let body: Value = create_res.json().await.unwrap();
251
252
+
let access_jwt = body["accessJwt"]
253
+
.as_str()
254
+
.expect("accessJwt should be present");
255
+
let refresh_jwt = body["refreshJwt"]
256
+
.as_str()
257
+
.expect("refreshJwt should be present");
258
259
assert!(!access_jwt.is_empty(), "accessJwt should not be empty");
260
assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty");
261
262
let parts: Vec<&str> = access_jwt.split('.').collect();
263
+
assert_eq!(
264
+
parts.len(),
265
+
3,
266
+
"accessJwt should be a valid JWT with 3 parts"
267
+
);
268
}
269
270
#[tokio::test]
···
280
assert_eq!(describe.status(), StatusCode::OK);
281
let body: Value = describe.json().await.unwrap();
282
283
+
assert!(
284
+
body.get("links").is_some(),
285
+
"describeServer should include links object"
286
+
);
287
+
assert!(
288
+
body.get("contact").is_some(),
289
+
"describeServer should include contact object"
290
+
);
291
292
let links = &body["links"];
293
+
assert!(
294
+
links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(),
295
+
"links should have privacyPolicy field (can be null)"
296
+
);
297
+
assert!(
298
+
links.get("termsOfService").is_some() || links["termsOfService"].is_null(),
299
+
"links should have termsOfService field (can be null)"
300
+
);
301
302
let contact = &body["contact"];
303
+
assert!(
304
+
contact.get("email").is_some() || contact["email"].is_null(),
305
+
"contact should have email field (can be null)"
306
+
);
307
}
308
309
#[tokio::test]
···
342
343
assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
344
let error_body: Value = delete_res.json().await.unwrap();
345
+
assert!(
346
+
error_body["message"]
347
+
.as_str()
348
+
.unwrap()
349
+
.contains("password length")
350
+
|| error_body["error"].as_str().unwrap() == "InvalidRequest"
351
+
);
352
}
+1
-3
tests/account_notifications.rs
+1
-3
tests/account_notifications.rs
+9
-2
tests/actor.rs
+9
-2
tests/actor.rs
···
174
let body: Value = get_resp.json().await.unwrap();
175
let prefs_arr = body["preferences"].as_array().unwrap();
176
assert_eq!(prefs_arr.len(), 1);
177
-
assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#adultContentPref");
178
}
179
180
#[tokio::test]
···
393
let client = client();
394
let base = base_url().await;
395
let (token, _did) = create_account_and_login(&client).await;
396
-
let current_year = chrono::Utc::now().format("%Y").to_string().parse::<i32>().unwrap();
397
let birth_year = current_year - 15;
398
let prefs = json!({
399
"preferences": [
···
174
let body: Value = get_resp.json().await.unwrap();
175
let prefs_arr = body["preferences"].as_array().unwrap();
176
assert_eq!(prefs_arr.len(), 1);
177
+
assert_eq!(
178
+
prefs_arr[0]["$type"],
179
+
"app.bsky.actor.defs#adultContentPref"
180
+
);
181
}
182
183
#[tokio::test]
···
396
let client = client();
397
let base = base_url().await;
398
let (token, _did) = create_account_and_login(&client).await;
399
+
let current_year = chrono::Utc::now()
400
+
.format("%Y")
401
+
.to_string()
402
+
.parse::<i32>()
403
+
.unwrap();
404
let birth_year = current_year - 15;
405
let prefs = json!({
406
"preferences": [
+4
-1
tests/admin_invite.rs
+4
-1
tests/admin_invite.rs
···
217
.expect("Failed to get invite codes");
218
let list_body: Value = list_res.json().await.unwrap();
219
let codes = list_body["codes"].as_array().unwrap();
220
-
let admin_codes: Vec<_> = codes.iter().filter(|c| c["forAccount"].as_str() == Some(&did)).collect();
221
for code in admin_codes {
222
assert_eq!(code["disabled"], true);
223
}
···
217
.expect("Failed to get invite codes");
218
let list_body: Value = list_res.json().await.unwrap();
219
let codes = list_body["codes"].as_array().unwrap();
220
+
let admin_codes: Vec<_> = codes
221
+
.iter()
222
+
.filter(|c| c["forAccount"].as_str() == Some(&did))
223
+
.collect();
224
for code in admin_codes {
225
assert_eq!(code["disabled"], true);
226
}
+20
-5
tests/did_web.rs
+20
-5
tests/did_web.rs
···
569
let jwt = verify_new_account(&client, &did).await;
570
let target_pds = "https://pds2.example.com";
571
let res = client
572
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
573
.bearer_auth(&jwt)
574
.json(&json!({ "migratingTo": target_pds }))
575
.send()
···
633
.expect("Failed to send request");
634
assert_eq!(res.status(), StatusCode::OK);
635
let res = client
636
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
637
.bearer_auth(&jwt)
638
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
639
.send()
···
770
);
771
let target_pds = "https://pds3.example.com";
772
let res = client
773
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
774
.bearer_auth(&jwt)
775
.json(&json!({ "migratingTo": target_pds }))
776
.send()
···
785
.expect("Failed to send request");
786
assert_eq!(res.status(), StatusCode::OK);
787
let body: Value = res.json().await.expect("Response was not JSON");
788
-
assert_eq!(body["active"], false, "Migrated account should not be active");
789
assert_eq!(
790
body["status"], "migrated",
791
"Status should be 'migrated' after migration"
···
819
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
820
let jwt = verify_new_account(&client, &did).await;
821
let res = client
822
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
823
.bearer_auth(&jwt)
824
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
825
.send()
···
569
let jwt = verify_new_account(&client, &did).await;
570
let target_pds = "https://pds2.example.com";
571
let res = client
572
+
.post(format!(
573
+
"{}/xrpc/com.atproto.server.deactivateAccount",
574
+
base
575
+
))
576
.bearer_auth(&jwt)
577
.json(&json!({ "migratingTo": target_pds }))
578
.send()
···
636
.expect("Failed to send request");
637
assert_eq!(res.status(), StatusCode::OK);
638
let res = client
639
+
.post(format!(
640
+
"{}/xrpc/com.atproto.server.deactivateAccount",
641
+
base
642
+
))
643
.bearer_auth(&jwt)
644
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
645
.send()
···
776
);
777
let target_pds = "https://pds3.example.com";
778
let res = client
779
+
.post(format!(
780
+
"{}/xrpc/com.atproto.server.deactivateAccount",
781
+
base
782
+
))
783
.bearer_auth(&jwt)
784
.json(&json!({ "migratingTo": target_pds }))
785
.send()
···
794
.expect("Failed to send request");
795
assert_eq!(res.status(), StatusCode::OK);
796
let body: Value = res.json().await.expect("Response was not JSON");
797
+
assert_eq!(
798
+
body["active"], false,
799
+
"Migrated account should not be active"
800
+
);
801
assert_eq!(
802
body["status"], "migrated",
803
"Status should be 'migrated' after migration"
···
831
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
832
let jwt = verify_new_account(&client, &did).await;
833
let res = client
834
+
.post(format!(
835
+
"{}/xrpc/com.atproto.server.deactivateAccount",
836
+
base
837
+
))
838
.bearer_auth(&jwt)
839
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
840
.send()
+32
-19
tests/email_update.rs
+32
-19
tests/email_update.rs
···
112
.expect("Failed to update email");
113
assert_eq!(res.status(), StatusCode::OK);
114
115
-
let user_email: Option<String> = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did)
116
-
.fetch_one(pool)
117
-
.await
118
-
.expect("User not found");
119
assert_eq!(user_email, Some(new_email));
120
}
121
···
255
assert_eq!(res.status(), StatusCode::OK);
256
let body: Value = res.json().await.expect("Invalid JSON");
257
let did = body["did"].as_str().expect("No did").to_string();
258
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
259
260
let body_text: String = sqlx::query_scalar!(
261
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
283
.expect("Failed to confirm email");
284
assert_eq!(res.status(), StatusCode::OK);
285
286
-
let verified: bool = sqlx::query_scalar!(
287
-
"SELECT email_verified FROM users WHERE did = $1",
288
-
did
289
-
)
290
-
.fetch_one(pool)
291
-
.await
292
-
.expect("User not found");
293
assert!(verified);
294
}
295
···
317
assert_eq!(res.status(), StatusCode::OK);
318
let body: Value = res.json().await.expect("Invalid JSON");
319
let did = body["did"].as_str().expect("No did").to_string();
320
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
321
322
let body_text: String = sqlx::query_scalar!(
323
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
370
.expect("Failed to create account");
371
assert_eq!(res.status(), StatusCode::OK);
372
let body: Value = res.json().await.expect("Invalid JSON");
373
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
374
375
let res = client
376
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
···
411
assert_eq!(res.status(), StatusCode::OK);
412
let body: Value = res.json().await.expect("Invalid JSON");
413
let did = body["did"].as_str().expect("No did").to_string();
414
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
415
416
let res = client
417
.post(format!(
···
491
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
492
let body: Value = res.json().await.expect("Invalid JSON");
493
assert_eq!(body["error"], "InvalidRequest");
494
-
assert!(body["message"]
495
-
.as_str()
496
-
.unwrap_or("")
497
-
.contains("already in use"));
498
}
···
112
.expect("Failed to update email");
113
assert_eq!(res.status(), StatusCode::OK);
114
115
+
let user_email: Option<String> =
116
+
sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did)
117
+
.fetch_one(pool)
118
+
.await
119
+
.expect("User not found");
120
assert_eq!(user_email, Some(new_email));
121
}
122
···
256
assert_eq!(res.status(), StatusCode::OK);
257
let body: Value = res.json().await.expect("Invalid JSON");
258
let did = body["did"].as_str().expect("No did").to_string();
259
+
let access_jwt = body["accessJwt"]
260
+
.as_str()
261
+
.expect("No accessJwt")
262
+
.to_string();
263
264
let body_text: String = sqlx::query_scalar!(
265
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
287
.expect("Failed to confirm email");
288
assert_eq!(res.status(), StatusCode::OK);
289
290
+
let verified: bool =
291
+
sqlx::query_scalar!("SELECT email_verified FROM users WHERE did = $1", did)
292
+
.fetch_one(pool)
293
+
.await
294
+
.expect("User not found");
295
assert!(verified);
296
}
297
···
319
assert_eq!(res.status(), StatusCode::OK);
320
let body: Value = res.json().await.expect("Invalid JSON");
321
let did = body["did"].as_str().expect("No did").to_string();
322
+
let access_jwt = body["accessJwt"]
323
+
.as_str()
324
+
.expect("No accessJwt")
325
+
.to_string();
326
327
let body_text: String = sqlx::query_scalar!(
328
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
375
.expect("Failed to create account");
376
assert_eq!(res.status(), StatusCode::OK);
377
let body: Value = res.json().await.expect("Invalid JSON");
378
+
let access_jwt = body["accessJwt"]
379
+
.as_str()
380
+
.expect("No accessJwt")
381
+
.to_string();
382
383
let res = client
384
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
···
419
assert_eq!(res.status(), StatusCode::OK);
420
let body: Value = res.json().await.expect("Invalid JSON");
421
let did = body["did"].as_str().expect("No did").to_string();
422
+
let access_jwt = body["accessJwt"]
423
+
.as_str()
424
+
.expect("No accessJwt")
425
+
.to_string();
426
427
let res = client
428
.post(format!(
···
502
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
503
let body: Value = res.json().await.expect("Invalid JSON");
504
assert_eq!(body["error"], "InvalidRequest");
505
+
assert!(
506
+
body["message"]
507
+
.as_str()
508
+
.unwrap_or("")
509
+
.contains("already in use")
510
+
);
511
}
+4
-1
tests/identity.rs
+4
-1
tests/identity.rs
···
393
.await
394
.expect("Failed to get session");
395
let session_body: Value = session.json().await.expect("Invalid JSON");
396
-
let current_handle = session_body["handle"].as_str().expect("No handle").to_string();
397
let short_handle = current_handle.split('.').next().unwrap_or(¤t_handle);
398
let res = client
399
.post(format!(
···
393
.await
394
.expect("Failed to get session");
395
let session_body: Value = session.json().await.expect("Invalid JSON");
396
+
let current_handle = session_body["handle"]
397
+
.as_str()
398
+
.expect("No handle")
399
+
.to_string();
400
let short_handle = current_handle.split('.').next().unwrap_or(¤t_handle);
401
let res = client
402
.post(format!(
+13
-3
tests/invite.rs
+13
-3
tests/invite.rs
···
25
assert!(body["code"].is_string());
26
let code = body["code"].as_str().unwrap();
27
assert!(!code.is_empty());
28
-
assert!(code.contains('-'), "Code should be in hostname-xxxxx-xxxxx format");
29
let parts: Vec<&str> = code.split('-').collect();
30
-
assert!(parts.len() >= 3, "Code should have at least 3 parts (hostname + 2 random parts)");
31
}
32
33
#[tokio::test]
···
363
let body: Value = res.json().await.expect("Response was not valid JSON");
364
let codes = body["codes"].as_array().unwrap();
365
for c in codes {
366
-
assert_ne!(c["code"].as_str().unwrap(), code, "Disabled code should be filtered out");
367
}
368
}
···
25
assert!(body["code"].is_string());
26
let code = body["code"].as_str().unwrap();
27
assert!(!code.is_empty());
28
+
assert!(
29
+
code.contains('-'),
30
+
"Code should be in hostname-xxxxx-xxxxx format"
31
+
);
32
let parts: Vec<&str> = code.split('-').collect();
33
+
assert!(
34
+
parts.len() >= 3,
35
+
"Code should have at least 3 parts (hostname + 2 random parts)"
36
+
);
37
}
38
39
#[tokio::test]
···
369
let body: Value = res.json().await.expect("Response was not valid JSON");
370
let codes = body["codes"].as_array().unwrap();
371
for c in codes {
372
+
assert_ne!(
373
+
c["code"].as_str().unwrap(),
374
+
code,
375
+
"Disabled code should be filtered out"
376
+
);
377
}
378
}
+20
-5
tests/lifecycle_session.rs
+20
-5
tests/lifecycle_session.rs
···
291
let base = base_url().await;
292
let (jwt, _did) = create_account_and_login(&client).await;
293
let create_res = client
294
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
295
.bearer_auth(&jwt)
296
.json(&json!({ "name": "My App" }))
297
.send()
···
299
.expect("Failed to create app password");
300
assert_eq!(create_res.status(), StatusCode::OK);
301
let duplicate_res = client
302
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
303
.bearer_auth(&jwt)
304
.json(&json!({ "name": "My App" }))
305
.send()
···
320
let base = base_url().await;
321
let (jwt, _did) = create_account_and_login(&client).await;
322
let revoke_res = client
323
-
.post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base))
324
.bearer_auth(&jwt)
325
.json(&json!({ "name": "Does Not Exist" }))
326
.send()
···
356
let did = account["did"].as_str().unwrap();
357
let main_jwt = verify_new_account(&client, did).await;
358
let create_app_res = client
359
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
360
.bearer_auth(&main_jwt)
361
.json(&json!({ "name": "Session Test App" }))
362
.send()
···
389
"App password session should be valid before revocation"
390
);
391
let revoke_res = client
392
-
.post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base))
393
.bearer_auth(&main_jwt)
394
.json(&json!({ "name": "Session Test App" }))
395
.send()
···
291
let base = base_url().await;
292
let (jwt, _did) = create_account_and_login(&client).await;
293
let create_res = client
294
+
.post(format!(
295
+
"{}/xrpc/com.atproto.server.createAppPassword",
296
+
base
297
+
))
298
.bearer_auth(&jwt)
299
.json(&json!({ "name": "My App" }))
300
.send()
···
302
.expect("Failed to create app password");
303
assert_eq!(create_res.status(), StatusCode::OK);
304
let duplicate_res = client
305
+
.post(format!(
306
+
"{}/xrpc/com.atproto.server.createAppPassword",
307
+
base
308
+
))
309
.bearer_auth(&jwt)
310
.json(&json!({ "name": "My App" }))
311
.send()
···
326
let base = base_url().await;
327
let (jwt, _did) = create_account_and_login(&client).await;
328
let revoke_res = client
329
+
.post(format!(
330
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
331
+
base
332
+
))
333
.bearer_auth(&jwt)
334
.json(&json!({ "name": "Does Not Exist" }))
335
.send()
···
365
let did = account["did"].as_str().unwrap();
366
let main_jwt = verify_new_account(&client, did).await;
367
let create_app_res = client
368
+
.post(format!(
369
+
"{}/xrpc/com.atproto.server.createAppPassword",
370
+
base
371
+
))
372
.bearer_auth(&main_jwt)
373
.json(&json!({ "name": "Session Test App" }))
374
.send()
···
401
"App password session should be valid before revocation"
402
);
403
let revoke_res = client
404
+
.post(format!(
405
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
406
+
base
407
+
))
408
.bearer_auth(&main_jwt)
409
.json(&json!({ "name": "Session Test App" }))
410
.send()
+2
-8
tests/moderation.rs
+2
-8
tests/moderation.rs
···
85
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
86
let body: Value = res.json().await.unwrap();
87
assert_eq!(body["error"], "InvalidRequest");
88
-
assert!(body["message"]
89
-
.as_str()
90
-
.unwrap()
91
-
.contains("reasonType"));
92
}
93
94
#[tokio::test]
···
266
);
267
let body: Value = report_res.json().await.unwrap();
268
assert_eq!(body["error"], "InvalidRequest");
269
-
assert!(body["message"]
270
-
.as_str()
271
-
.unwrap()
272
-
.contains("takendown"));
273
}
···
85
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
86
let body: Value = res.json().await.unwrap();
87
assert_eq!(body["error"], "InvalidRequest");
88
+
assert!(body["message"].as_str().unwrap().contains("reasonType"));
89
}
90
91
#[tokio::test]
···
263
);
264
let body: Value = report_res.json().await.unwrap();
265
assert_eq!(body["error"], "InvalidRequest");
266
+
assert!(body["message"].as_str().unwrap().contains("takendown"));
267
}
+1
-2
tests/oauth_lifecycle.rs
+1
-2
tests/oauth_lifecycle.rs
···
949
let url = base_url().await;
950
let http_client = client();
951
let (alice, _mock_alice) =
952
-
create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback")
953
-
.await;
954
let (bob, _mock_bob) =
955
create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
956
let collection = "app.bsky.feed.post";
···
949
let url = base_url().await;
950
let http_client = client();
951
let (alice, _mock_alice) =
952
+
create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback").await;
953
let (bob, _mock_bob) =
954
create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
955
let collection = "app.bsky.feed.post";
+173
-42
tests/repo_conformance.rs
+173
-42
tests/repo_conformance.rs
···
23
});
24
25
let res = client
26
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
27
.bearer_auth(&jwt)
28
.json(&payload)
29
.send()
···
35
36
assert!(body["uri"].is_string(), "response must have uri");
37
assert!(body["cid"].is_string(), "response must have cid");
38
-
assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid");
39
40
-
assert!(body["commit"].is_object(), "response must have commit object");
41
let commit = &body["commit"];
42
assert!(commit["cid"].is_string(), "commit must have cid");
43
-
assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid");
44
assert!(commit["rev"].is_string(), "commit must have rev");
45
46
-
assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true");
47
-
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
48
}
49
50
#[tokio::test]
···
65
});
66
67
let res = client
68
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
69
.bearer_auth(&jwt)
70
.json(&payload)
71
.send()
···
77
78
assert!(body["uri"].is_string());
79
assert!(body["commit"].is_object());
80
-
assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false");
81
}
82
83
#[tokio::test]
···
98
});
99
100
let res = client
101
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
102
.bearer_auth(&jwt)
103
.json(&payload)
104
.send()
···
111
assert!(body["uri"].is_string(), "response must have uri");
112
assert!(body["cid"].is_string(), "response must have cid");
113
114
-
assert!(body["commit"].is_object(), "response must have commit object");
115
let commit = &body["commit"];
116
assert!(commit["cid"].is_string(), "commit must have cid");
117
assert!(commit["rev"].is_string(), "commit must have rev");
118
119
-
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
120
}
121
122
#[tokio::test]
···
136
}
137
});
138
let create_res = client
139
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
140
.bearer_auth(&jwt)
141
.json(&create_payload)
142
.send()
···
150
"rkey": "to-delete"
151
});
152
let delete_res = client
153
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
154
.bearer_auth(&jwt)
155
.json(&delete_payload)
156
.send()
···
160
assert_eq!(delete_res.status(), StatusCode::OK);
161
let body: Value = delete_res.json().await.unwrap();
162
163
-
assert!(body["commit"].is_object(), "response must have commit object when record was deleted");
164
let commit = &body["commit"];
165
assert!(commit["cid"].is_string(), "commit must have cid");
166
assert!(commit["rev"].is_string(), "commit must have rev");
···
177
"rkey": "nonexistent-record"
178
});
179
let delete_res = client
180
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
181
.bearer_auth(&jwt)
182
.json(&delete_payload)
183
.send()
···
187
assert_eq!(delete_res.status(), StatusCode::OK);
188
let body: Value = delete_res.json().await.unwrap();
189
190
-
assert!(body["commit"].is_null(), "commit should be omitted on no-op delete");
191
}
192
193
#[tokio::test]
···
223
});
224
225
let res = client
226
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
227
.bearer_auth(&jwt)
228
.json(&payload)
229
.send()
···
233
assert_eq!(res.status(), StatusCode::OK);
234
let body: Value = res.json().await.unwrap();
235
236
-
assert!(body["commit"].is_object(), "response must have commit object");
237
let commit = &body["commit"];
238
assert!(commit["cid"].is_string(), "commit must have cid");
239
assert!(commit["rev"].is_string(), "commit must have rev");
240
241
-
assert!(body["results"].is_array(), "response must have results array");
242
let results = body["results"].as_array().unwrap();
243
assert_eq!(results.len(), 2, "should have 2 results");
244
245
for result in results {
246
assert!(result["uri"].is_string(), "result must have uri");
247
assert!(result["cid"].is_string(), "result must have cid");
248
-
assert_eq!(result["validationStatus"], "valid", "result must have validationStatus");
249
assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult");
250
}
251
}
···
267
}
268
});
269
client
270
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
271
.bearer_auth(&jwt)
272
.json(&create_payload)
273
.send()
···
296
});
297
298
let res = client
299
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
300
.bearer_auth(&jwt)
301
.json(&payload)
302
.send()
···
310
assert_eq!(results.len(), 2);
311
312
let update_result = &results[0];
313
-
assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult");
314
assert!(update_result["uri"].is_string());
315
assert!(update_result["cid"].is_string());
316
assert_eq!(update_result["validationStatus"], "valid");
317
318
let delete_result = &results[1];
319
-
assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult");
320
-
assert!(delete_result["uri"].is_null(), "delete result should not have uri");
321
-
assert!(delete_result["cid"].is_null(), "delete result should not have cid");
322
-
assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus");
323
}
324
325
#[tokio::test]
···
328
let (did, _jwt) = setup_new_user("conform-get-err").await;
329
330
let res = client
331
-
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
332
.query(&[
333
("repo", did.as_str()),
334
("collection", "app.bsky.feed.post"),
···
340
341
assert_eq!(res.status(), StatusCode::NOT_FOUND);
342
let body: Value = res.json().await.unwrap();
343
-
assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec");
344
}
345
346
#[tokio::test]
···
358
});
359
360
let res = client
361
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
362
.bearer_auth(&jwt)
363
.json(&payload)
364
.send()
365
.await
366
.expect("Failed to create record");
367
368
-
assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation");
369
let body: Value = res.json().await.unwrap();
370
371
assert!(body["uri"].is_string());
372
assert!(body["cid"].is_string());
373
assert!(body["commit"].is_object());
374
-
assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons");
375
}
376
377
#[tokio::test]
···
390
});
391
392
let res = client
393
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
394
.bearer_auth(&jwt)
395
.json(&payload)
396
.send()
397
.await
398
.expect("Failed to send request");
399
400
-
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true");
401
let body: Value = res.json().await.unwrap();
402
assert_eq!(body["error"], "InvalidRecord");
403
-
assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found");
404
}
405
406
#[tokio::test]
···
423
});
424
425
let first_res = client
426
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
427
.bearer_auth(&jwt)
428
.json(&payload)
429
.send()
···
431
.expect("Failed to put record");
432
assert_eq!(first_res.status(), StatusCode::OK);
433
let first_body: Value = first_res.json().await.unwrap();
434
-
assert!(first_body["commit"].is_object(), "first put should have commit");
435
436
let second_res = client
437
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
438
.bearer_auth(&jwt)
439
.json(&payload)
440
.send()
···
443
assert_eq!(second_res.status(), StatusCode::OK);
444
let second_body: Value = second_res.json().await.unwrap();
445
446
-
assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)");
447
-
assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content");
448
}
449
450
#[tokio::test]
···
468
});
469
470
let res = client
471
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
472
.bearer_auth(&jwt)
473
.json(&payload)
474
.send()
···
480
481
let results = body["results"].as_array().unwrap();
482
assert_eq!(results.len(), 1);
483
-
assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status");
484
}
···
23
});
24
25
let res = client
26
+
.post(format!(
27
+
"{}/xrpc/com.atproto.repo.createRecord",
28
+
base_url().await
29
+
))
30
.bearer_auth(&jwt)
31
.json(&payload)
32
.send()
···
38
39
assert!(body["uri"].is_string(), "response must have uri");
40
assert!(body["cid"].is_string(), "response must have cid");
41
+
assert!(
42
+
body["cid"].as_str().unwrap().starts_with("bafy"),
43
+
"cid must be valid"
44
+
);
45
46
+
assert!(
47
+
body["commit"].is_object(),
48
+
"response must have commit object"
49
+
);
50
let commit = &body["commit"];
51
assert!(commit["cid"].is_string(), "commit must have cid");
52
+
assert!(
53
+
commit["cid"].as_str().unwrap().starts_with("bafy"),
54
+
"commit.cid must be valid"
55
+
);
56
assert!(commit["rev"].is_string(), "commit must have rev");
57
58
+
assert!(
59
+
body["validationStatus"].is_string(),
60
+
"response must have validationStatus when validate defaults to true"
61
+
);
62
+
assert_eq!(
63
+
body["validationStatus"], "valid",
64
+
"validationStatus should be 'valid'"
65
+
);
66
}
67
68
#[tokio::test]
···
83
});
84
85
let res = client
86
+
.post(format!(
87
+
"{}/xrpc/com.atproto.repo.createRecord",
88
+
base_url().await
89
+
))
90
.bearer_auth(&jwt)
91
.json(&payload)
92
.send()
···
98
99
assert!(body["uri"].is_string());
100
assert!(body["commit"].is_object());
101
+
assert!(
102
+
body["validationStatus"].is_null(),
103
+
"validationStatus should be omitted when validate=false"
104
+
);
105
}
106
107
#[tokio::test]
···
122
});
123
124
let res = client
125
+
.post(format!(
126
+
"{}/xrpc/com.atproto.repo.putRecord",
127
+
base_url().await
128
+
))
129
.bearer_auth(&jwt)
130
.json(&payload)
131
.send()
···
138
assert!(body["uri"].is_string(), "response must have uri");
139
assert!(body["cid"].is_string(), "response must have cid");
140
141
+
assert!(
142
+
body["commit"].is_object(),
143
+
"response must have commit object"
144
+
);
145
let commit = &body["commit"];
146
assert!(commit["cid"].is_string(), "commit must have cid");
147
assert!(commit["rev"].is_string(), "commit must have rev");
148
149
+
assert_eq!(
150
+
body["validationStatus"], "valid",
151
+
"validationStatus should be 'valid'"
152
+
);
153
}
154
155
#[tokio::test]
···
169
}
170
});
171
let create_res = client
172
+
.post(format!(
173
+
"{}/xrpc/com.atproto.repo.putRecord",
174
+
base_url().await
175
+
))
176
.bearer_auth(&jwt)
177
.json(&create_payload)
178
.send()
···
186
"rkey": "to-delete"
187
});
188
let delete_res = client
189
+
.post(format!(
190
+
"{}/xrpc/com.atproto.repo.deleteRecord",
191
+
base_url().await
192
+
))
193
.bearer_auth(&jwt)
194
.json(&delete_payload)
195
.send()
···
199
assert_eq!(delete_res.status(), StatusCode::OK);
200
let body: Value = delete_res.json().await.unwrap();
201
202
+
assert!(
203
+
body["commit"].is_object(),
204
+
"response must have commit object when record was deleted"
205
+
);
206
let commit = &body["commit"];
207
assert!(commit["cid"].is_string(), "commit must have cid");
208
assert!(commit["rev"].is_string(), "commit must have rev");
···
219
"rkey": "nonexistent-record"
220
});
221
let delete_res = client
222
+
.post(format!(
223
+
"{}/xrpc/com.atproto.repo.deleteRecord",
224
+
base_url().await
225
+
))
226
.bearer_auth(&jwt)
227
.json(&delete_payload)
228
.send()
···
232
assert_eq!(delete_res.status(), StatusCode::OK);
233
let body: Value = delete_res.json().await.unwrap();
234
235
+
assert!(
236
+
body["commit"].is_null(),
237
+
"commit should be omitted on no-op delete"
238
+
);
239
}
240
241
#[tokio::test]
···
271
});
272
273
let res = client
274
+
.post(format!(
275
+
"{}/xrpc/com.atproto.repo.applyWrites",
276
+
base_url().await
277
+
))
278
.bearer_auth(&jwt)
279
.json(&payload)
280
.send()
···
284
assert_eq!(res.status(), StatusCode::OK);
285
let body: Value = res.json().await.unwrap();
286
287
+
assert!(
288
+
body["commit"].is_object(),
289
+
"response must have commit object"
290
+
);
291
let commit = &body["commit"];
292
assert!(commit["cid"].is_string(), "commit must have cid");
293
assert!(commit["rev"].is_string(), "commit must have rev");
294
295
+
assert!(
296
+
body["results"].is_array(),
297
+
"response must have results array"
298
+
);
299
let results = body["results"].as_array().unwrap();
300
assert_eq!(results.len(), 2, "should have 2 results");
301
302
for result in results {
303
assert!(result["uri"].is_string(), "result must have uri");
304
assert!(result["cid"].is_string(), "result must have cid");
305
+
assert_eq!(
306
+
result["validationStatus"], "valid",
307
+
"result must have validationStatus"
308
+
);
309
assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult");
310
}
311
}
···
327
}
328
});
329
client
330
+
.post(format!(
331
+
"{}/xrpc/com.atproto.repo.putRecord",
332
+
base_url().await
333
+
))
334
.bearer_auth(&jwt)
335
.json(&create_payload)
336
.send()
···
359
});
360
361
let res = client
362
+
.post(format!(
363
+
"{}/xrpc/com.atproto.repo.applyWrites",
364
+
base_url().await
365
+
))
366
.bearer_auth(&jwt)
367
.json(&payload)
368
.send()
···
376
assert_eq!(results.len(), 2);
377
378
let update_result = &results[0];
379
+
assert_eq!(
380
+
update_result["$type"],
381
+
"com.atproto.repo.applyWrites#updateResult"
382
+
);
383
assert!(update_result["uri"].is_string());
384
assert!(update_result["cid"].is_string());
385
assert_eq!(update_result["validationStatus"], "valid");
386
387
let delete_result = &results[1];
388
+
assert_eq!(
389
+
delete_result["$type"],
390
+
"com.atproto.repo.applyWrites#deleteResult"
391
+
);
392
+
assert!(
393
+
delete_result["uri"].is_null(),
394
+
"delete result should not have uri"
395
+
);
396
+
assert!(
397
+
delete_result["cid"].is_null(),
398
+
"delete result should not have cid"
399
+
);
400
+
assert!(
401
+
delete_result["validationStatus"].is_null(),
402
+
"delete result should not have validationStatus"
403
+
);
404
}
405
406
#[tokio::test]
···
409
let (did, _jwt) = setup_new_user("conform-get-err").await;
410
411
let res = client
412
+
.get(format!(
413
+
"{}/xrpc/com.atproto.repo.getRecord",
414
+
base_url().await
415
+
))
416
.query(&[
417
("repo", did.as_str()),
418
("collection", "app.bsky.feed.post"),
···
424
425
assert_eq!(res.status(), StatusCode::NOT_FOUND);
426
let body: Value = res.json().await.unwrap();
427
+
assert_eq!(
428
+
body["error"], "RecordNotFound",
429
+
"error code should be RecordNotFound per atproto spec"
430
+
);
431
}
432
433
#[tokio::test]
···
445
});
446
447
let res = client
448
+
.post(format!(
449
+
"{}/xrpc/com.atproto.repo.createRecord",
450
+
base_url().await
451
+
))
452
.bearer_auth(&jwt)
453
.json(&payload)
454
.send()
455
.await
456
.expect("Failed to create record");
457
458
+
assert_eq!(
459
+
res.status(),
460
+
StatusCode::OK,
461
+
"unknown lexicon should be allowed with default validation"
462
+
);
463
let body: Value = res.json().await.unwrap();
464
465
assert!(body["uri"].is_string());
466
assert!(body["cid"].is_string());
467
assert!(body["commit"].is_object());
468
+
assert_eq!(
469
+
body["validationStatus"], "unknown",
470
+
"validationStatus should be 'unknown' for unknown lexicons"
471
+
);
472
}
473
474
#[tokio::test]
···
487
});
488
489
let res = client
490
+
.post(format!(
491
+
"{}/xrpc/com.atproto.repo.createRecord",
492
+
base_url().await
493
+
))
494
.bearer_auth(&jwt)
495
.json(&payload)
496
.send()
497
.await
498
.expect("Failed to send request");
499
500
+
assert_eq!(
501
+
res.status(),
502
+
StatusCode::BAD_REQUEST,
503
+
"unknown lexicon should fail with validate=true"
504
+
);
505
let body: Value = res.json().await.unwrap();
506
assert_eq!(body["error"], "InvalidRecord");
507
+
assert!(
508
+
body["message"]
509
+
.as_str()
510
+
.unwrap()
511
+
.contains("Lexicon not found"),
512
+
"error should mention lexicon not found"
513
+
);
514
}
515
516
#[tokio::test]
···
533
});
534
535
let first_res = client
536
+
.post(format!(
537
+
"{}/xrpc/com.atproto.repo.putRecord",
538
+
base_url().await
539
+
))
540
.bearer_auth(&jwt)
541
.json(&payload)
542
.send()
···
544
.expect("Failed to put record");
545
assert_eq!(first_res.status(), StatusCode::OK);
546
let first_body: Value = first_res.json().await.unwrap();
547
+
assert!(
548
+
first_body["commit"].is_object(),
549
+
"first put should have commit"
550
+
);
551
552
let second_res = client
553
+
.post(format!(
554
+
"{}/xrpc/com.atproto.repo.putRecord",
555
+
base_url().await
556
+
))
557
.bearer_auth(&jwt)
558
.json(&payload)
559
.send()
···
562
assert_eq!(second_res.status(), StatusCode::OK);
563
let second_body: Value = second_res.json().await.unwrap();
564
565
+
assert!(
566
+
second_body["commit"].is_null(),
567
+
"second put with same content should have no commit (no-op)"
568
+
);
569
+
assert_eq!(
570
+
first_body["cid"], second_body["cid"],
571
+
"CID should be the same for identical content"
572
+
);
573
}
574
575
#[tokio::test]
···
593
});
594
595
let res = client
596
+
.post(format!(
597
+
"{}/xrpc/com.atproto.repo.applyWrites",
598
+
base_url().await
599
+
))
600
.bearer_auth(&jwt)
601
.json(&payload)
602
.send()
···
608
609
let results = body["results"].as_array().unwrap();
610
assert_eq!(results.len(), 1);
611
+
assert_eq!(
612
+
results[0]["validationStatus"], "unknown",
613
+
"unknown lexicon should have 'unknown' status"
614
+
);
615
}
+20
-5
tests/sync_deprecated.rs
+20
-5
tests/sync_deprecated.rs
···
202
.unwrap();
203
assert_eq!(res.status(), StatusCode::OK);
204
client
205
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
206
.bearer_auth(&jwt)
207
.json(&serde_json::json!({}))
208
.send()
···
233
.unwrap();
234
assert_eq!(res.status(), StatusCode::OK);
235
client
236
-
.post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base))
237
.bearer_auth(&admin_jwt)
238
.json(&serde_json::json!({
239
"subject": {
···
266
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
267
let (user_jwt, did) = create_account_and_login(&client).await;
268
client
269
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
270
.bearer_auth(&user_jwt)
271
.json(&serde_json::json!({}))
272
.send()
···
295
.unwrap();
296
assert_eq!(res.status(), StatusCode::OK);
297
client
298
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
299
.bearer_auth(&jwt)
300
.json(&serde_json::json!({}))
301
.send()
···
326
.unwrap();
327
assert_eq!(res.status(), StatusCode::OK);
328
client
329
-
.post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base))
330
.bearer_auth(&admin_jwt)
331
.json(&serde_json::json!({
332
"subject": {
···
202
.unwrap();
203
assert_eq!(res.status(), StatusCode::OK);
204
client
205
+
.post(format!(
206
+
"{}/xrpc/com.atproto.server.deactivateAccount",
207
+
base
208
+
))
209
.bearer_auth(&jwt)
210
.json(&serde_json::json!({}))
211
.send()
···
236
.unwrap();
237
assert_eq!(res.status(), StatusCode::OK);
238
client
239
+
.post(format!(
240
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
241
+
base
242
+
))
243
.bearer_auth(&admin_jwt)
244
.json(&serde_json::json!({
245
"subject": {
···
272
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
273
let (user_jwt, did) = create_account_and_login(&client).await;
274
client
275
+
.post(format!(
276
+
"{}/xrpc/com.atproto.server.deactivateAccount",
277
+
base
278
+
))
279
.bearer_auth(&user_jwt)
280
.json(&serde_json::json!({}))
281
.send()
···
304
.unwrap();
305
assert_eq!(res.status(), StatusCode::OK);
306
client
307
+
.post(format!(
308
+
"{}/xrpc/com.atproto.server.deactivateAccount",
309
+
base
310
+
))
311
.bearer_auth(&jwt)
312
.json(&serde_json::json!({}))
313
.send()
···
338
.unwrap();
339
assert_eq!(res.status(), StatusCode::OK);
340
client
341
+
.post(format!(
342
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
343
+
base
344
+
))
345
.bearer_auth(&admin_jwt)
346
.json(&serde_json::json!({
347
"subject": {