+9
-1
frontend/deno.json
+9
-1
frontend/deno.json
···
9
9
"test:ui": "deno run -A npm:vitest --ui",
10
10
"test:coverage": "deno run -A npm:vitest run --coverage"
11
11
},
12
-
"nodeModulesDir": "auto"
12
+
"nodeModulesDir": "auto",
13
+
"lint": {
14
+
"rules": {
15
+
"exclude": [
16
+
"require-await",
17
+
"prefer-const"
18
+
]
19
+
}
20
+
}
13
21
}
+8
frontend/src/App.svelte
+8
frontend/src/App.svelte
···
36
36
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
37
import Home from './routes/Home.svelte'
38
38
39
+
if (window.location.pathname === '/migrate') {
40
+
const newUrl = `${window.location.origin}/${window.location.search}#/migrate`
41
+
window.location.replace(newUrl)
42
+
}
43
+
39
44
initI18n()
40
45
41
46
const auth = getAuthState()
···
43
48
let oauthCallbackPending = $state(hasOAuthCallback())
44
49
45
50
function hasOAuthCallback(): boolean {
51
+
if (window.location.hash === '#/migrate') {
52
+
return false
53
+
}
46
54
const params = new URLSearchParams(window.location.search)
47
55
return !!(params.get('code') && params.get('state'))
48
56
}
+12
-12
frontend/src/components/ReauthModal.svelte
+12
-12
frontend/src/components/ReauthModal.svelte
···
170
170
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
171
171
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
172
172
<div class="modal-header">
173
-
<h2>Re-authentication Required</h2>
173
+
<h2>{$_('reauth.title')}</h2>
174
174
<button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
175
175
</div>
176
176
177
177
<p class="modal-description">
178
-
This action requires you to verify your identity.
178
+
{$_('reauth.subtitle')}
179
179
</p>
180
180
181
181
{#if error}
···
190
190
class:active={activeMethod === 'password'}
191
191
onclick={() => activeMethod = 'password'}
192
192
>
193
-
Password
193
+
{$_('reauth.password')}
194
194
</button>
195
195
{/if}
196
196
{#if availableMethods.includes('totp')}
···
199
199
class:active={activeMethod === 'totp'}
200
200
onclick={() => activeMethod = 'totp'}
201
201
>
202
-
TOTP
202
+
{$_('reauth.totp')}
203
203
</button>
204
204
{/if}
205
205
{#if availableMethods.includes('passkey')}
···
208
208
class:active={activeMethod === 'passkey'}
209
209
onclick={() => activeMethod = 'passkey'}
210
210
>
211
-
Passkey
211
+
{$_('reauth.passkey')}
212
212
</button>
213
213
{/if}
214
214
</div>
···
218
218
{#if activeMethod === 'password'}
219
219
<form onsubmit={handlePasswordSubmit}>
220
220
<div class="form-group">
221
-
<label for="reauth-password">Password</label>
221
+
<label for="reauth-password">{$_('reauth.password')}</label>
222
222
<input
223
223
id="reauth-password"
224
224
type="password"
···
228
228
/>
229
229
</div>
230
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
-
{loading ? 'Verifying...' : 'Verify'}
231
+
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
232
232
</button>
233
233
</form>
234
234
{:else if activeMethod === 'totp'}
235
235
<form onsubmit={handleTotpSubmit}>
236
236
<div class="form-group">
237
-
<label for="reauth-totp">Authenticator Code</label>
237
+
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
238
238
<input
239
239
id="reauth-totp"
240
240
type="text"
···
247
247
/>
248
248
</div>
249
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
-
{loading ? 'Verifying...' : 'Verify'}
250
+
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
251
251
</button>
252
252
</form>
253
253
{:else if activeMethod === 'passkey'}
254
254
<div class="passkey-auth">
255
-
<p>Click the button below to authenticate with your passkey.</p>
255
+
<p>{$_('reauth.passkeyPrompt')}</p>
256
256
<button
257
257
class="btn-primary"
258
258
onclick={handlePasskeyAuth}
259
259
disabled={loading}
260
260
>
261
-
{loading ? 'Authenticating...' : 'Use Passkey'}
261
+
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
262
262
</button>
263
263
</div>
264
264
{/if}
···
266
266
267
267
<div class="modal-footer">
268
268
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
269
-
Cancel
269
+
{$_('reauth.cancel')}
270
270
</button>
271
271
</div>
272
272
</div>
+394
-569
frontend/src/components/migration/InboundWizard.svelte
+394
-569
frontend/src/components/migration/InboundWizard.svelte
···
1
1
<script lang="ts">
2
2
import type { InboundMigrationFlow } from '../../lib/migration'
3
-
import type { ServerDescription } from '../../lib/migration/types'
3
+
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
4
+
import { getErrorMessage } from '../../lib/migration/types'
5
+
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
4
6
import { _ } from '../../lib/i18n'
7
+
import '../../styles/migration.css'
8
+
9
+
interface ResumeInfo {
10
+
direction: 'inbound' | 'outbound'
11
+
sourceHandle: string
12
+
targetHandle: string
13
+
sourcePdsUrl: string
14
+
targetPdsUrl: string
15
+
targetEmail: string
16
+
authMethod?: AuthMethod
17
+
progressSummary: string
18
+
step: string
19
+
}
5
20
6
21
interface Props {
7
22
flow: InboundMigrationFlow
23
+
resumeInfo?: ResumeInfo | null
8
24
onBack: () => void
9
25
onComplete: () => void
10
26
}
11
27
12
-
let { flow, onBack, onComplete }: Props = $props()
28
+
let { flow, resumeInfo = null, onBack, onComplete }: Props = $props()
13
29
14
30
let serverInfo = $state<ServerDescription | null>(null)
15
31
let loading = $state(false)
16
32
let handleInput = $state('')
17
-
let passwordInput = $state('')
18
33
let localPasswordInput = $state('')
19
34
let understood = $state(false)
20
35
let selectedDomain = $state('')
21
36
let handleAvailable = $state<boolean | null>(null)
22
37
let checkingHandle = $state(false)
38
+
let selectedAuthMethod = $state<AuthMethod>('password')
39
+
let passkeyName = $state('')
40
+
let appPasswordCopied = $state(false)
41
+
let appPasswordAcknowledged = $state(false)
23
42
24
-
const isResumedMigration = $derived(flow.state.progress.repoImported)
43
+
const isResuming = $derived(flow.state.needsReauth === true)
25
44
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
26
45
27
46
$effect(() => {
28
47
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
29
48
loadServerInfo()
30
49
}
50
+
if (flow.state.step === 'choose-handle') {
51
+
handleInput = ''
52
+
handleAvailable = null
53
+
}
54
+
if (flow.state.step === 'source-handle' && resumeInfo) {
55
+
handleInput = resumeInfo.sourceHandle
56
+
selectedAuthMethod = resumeInfo.authMethod ?? 'password'
57
+
}
31
58
})
32
59
33
60
···
61
88
}
62
89
}
63
90
64
-
async function handleLogin(e: Event) {
65
-
e.preventDefault()
66
-
loading = true
67
-
flow.updateField('error', null)
68
-
69
-
try {
70
-
await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined)
71
-
const username = flow.state.sourceHandle.split('.')[0]
72
-
handleInput = username
73
-
flow.updateField('targetPassword', passwordInput)
74
-
75
-
if (flow.state.progress.repoImported) {
76
-
if (!localPasswordInput) {
77
-
flow.setError('Please enter your password for your new account on this PDS')
78
-
return
79
-
}
80
-
await flow.loadLocalServerInfo()
81
-
82
-
try {
83
-
await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput)
84
-
await flow.requestPlcToken()
85
-
flow.setStep('plc-token')
86
-
} catch (err) {
87
-
const error = err as Error & { error?: string }
88
-
if (error.error === 'AccountNotVerified') {
89
-
flow.setStep('email-verify')
90
-
} else {
91
-
throw err
92
-
}
93
-
}
94
-
} else {
95
-
flow.setStep('choose-handle')
96
-
}
97
-
} catch (err) {
98
-
flow.setError((err as Error).message)
99
-
} finally {
100
-
loading = false
101
-
}
102
-
}
103
-
104
91
async function checkHandle() {
105
92
if (!handleInput.trim()) return
106
93
···
134
121
try {
135
122
await flow.startMigration()
136
123
} catch (err) {
137
-
flow.setError((err as Error).message)
124
+
flow.setError(getErrorMessage(err))
138
125
} finally {
139
126
loading = false
140
127
}
···
146
133
try {
147
134
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined)
148
135
} catch (err) {
149
-
flow.setError((err as Error).message)
136
+
flow.setError(getErrorMessage(err))
150
137
} finally {
151
138
loading = false
152
139
}
···
158
145
await flow.resendEmailVerification()
159
146
flow.setError(null)
160
147
} catch (err) {
161
-
flow.setError((err as Error).message)
148
+
flow.setError(getErrorMessage(err))
162
149
} finally {
163
150
loading = false
164
151
}
···
170
157
try {
171
158
await flow.submitPlcToken(flow.state.plcToken)
172
159
} catch (err) {
173
-
flow.setError((err as Error).message)
160
+
flow.setError(getErrorMessage(err))
174
161
} finally {
175
162
loading = false
176
163
}
···
182
169
await flow.resendPlcToken()
183
170
flow.setError(null)
184
171
} catch (err) {
185
-
flow.setError((err as Error).message)
172
+
flow.setError(getErrorMessage(err))
186
173
} finally {
187
174
loading = false
188
175
}
···
193
180
try {
194
181
await flow.completeDidWebMigration()
195
182
} catch (err) {
196
-
flow.setError((err as Error).message)
183
+
flow.setError(getErrorMessage(err))
184
+
} finally {
185
+
loading = false
186
+
}
187
+
}
188
+
189
+
async function registerPasskey() {
190
+
loading = true
191
+
flow.setError(null)
192
+
193
+
try {
194
+
if (!window.PublicKeyCredential) {
195
+
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
196
+
}
197
+
198
+
const { options } = await flow.startPasskeyRegistration()
199
+
200
+
const publicKeyOptions = prepareWebAuthnCreationOptions(
201
+
options as { publicKey: Record<string, unknown> }
202
+
)
203
+
const credential = await navigator.credentials.create({
204
+
publicKey: publicKeyOptions,
205
+
})
206
+
207
+
if (!credential) {
208
+
throw new Error('Passkey creation was cancelled')
209
+
}
210
+
211
+
const publicKeyCredential = credential as PublicKeyCredential
212
+
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
213
+
214
+
const credentialData = {
215
+
id: publicKeyCredential.id,
216
+
rawId: base64UrlEncode(publicKeyCredential.rawId),
217
+
type: publicKeyCredential.type,
218
+
response: {
219
+
clientDataJSON: base64UrlEncode(response.clientDataJSON),
220
+
attestationObject: base64UrlEncode(response.attestationObject),
221
+
},
222
+
}
223
+
224
+
await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
225
+
} catch (err) {
226
+
const message = getErrorMessage(err)
227
+
if (message.includes('cancelled') || message.includes('AbortError')) {
228
+
flow.setError('Passkey registration was cancelled. Please try again.')
229
+
} else {
230
+
flow.setError(message)
231
+
}
197
232
} finally {
198
233
loading = false
199
234
}
200
235
}
201
236
237
+
function copyAppPassword() {
238
+
if (flow.state.generatedAppPassword) {
239
+
navigator.clipboard.writeText(flow.state.generatedAppPassword)
240
+
appPasswordCopied = true
241
+
}
242
+
}
243
+
244
+
async function handleProceedFromAppPassword() {
245
+
loading = true
246
+
try {
247
+
await flow.proceedFromAppPassword()
248
+
} catch (err) {
249
+
flow.setError(getErrorMessage(err))
250
+
} finally {
251
+
loading = false
252
+
}
253
+
}
254
+
255
+
async function handleSourceHandleSubmit(e: Event) {
256
+
e.preventDefault()
257
+
loading = true
258
+
flow.updateField('error', null)
259
+
260
+
try {
261
+
await flow.initiateOAuthLogin(handleInput)
262
+
} catch (err) {
263
+
flow.setError(getErrorMessage(err))
264
+
} finally {
265
+
loading = false
266
+
}
267
+
}
268
+
269
+
function proceedToReviewWithAuth() {
270
+
const fullHandle = handleInput.includes('.')
271
+
? handleInput
272
+
: `${handleInput}.${selectedDomain}`
273
+
274
+
flow.updateField('targetHandle', fullHandle)
275
+
flow.updateField('authMethod', selectedAuthMethod)
276
+
flow.setStep('review')
277
+
}
278
+
202
279
const steps = $derived(isDidWeb
203
-
? ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete']
204
-
: ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'])
280
+
? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Update DID', 'Complete']
281
+
: flow.state.authMethod === 'passkey'
282
+
? ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Passkey', 'App Password', 'Verify PLC', 'Complete']
283
+
: ['Authenticate', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete'])
284
+
205
285
function getCurrentStepIndex(): number {
286
+
const isPasskey = flow.state.authMethod === 'passkey'
206
287
switch (flow.state.step) {
207
288
case 'welcome':
208
-
case 'source-login': return 0
289
+
case 'source-handle': return 0
209
290
case 'choose-handle': return 1
210
291
case 'review': return 2
211
292
case 'migrating': return 3
212
293
case 'email-verify': return 4
294
+
case 'passkey-setup': return isPasskey ? 5 : 4
295
+
case 'app-password': return 6
213
296
case 'plc-token':
214
297
case 'did-web-update':
215
-
case 'finalizing': return 5
216
-
case 'success': return 6
298
+
case 'finalizing': return isPasskey ? 7 : 5
299
+
case 'success': return isPasskey ? 8 : 6
217
300
default: return 0
218
301
}
219
302
}
220
303
</script>
221
304
222
-
<div class="inbound-wizard">
305
+
<div class="migration-wizard">
223
306
<div class="step-indicator">
224
-
{#each steps as stepName, i}
307
+
{#each steps as _, i}
225
308
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
226
309
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
227
-
<span class="step-label">{stepName}</span>
228
310
</div>
229
311
{#if i < steps.length - 1}
230
312
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
231
313
{/if}
232
314
{/each}
233
315
</div>
316
+
<div class="current-step-label">
317
+
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
318
+
</div>
234
319
235
320
{#if flow.state.error}
236
321
<div class="message error">{flow.state.error}</div>
···
238
323
239
324
{#if flow.state.step === 'welcome'}
240
325
<div class="step-content">
241
-
<h2>Migrate Your Account Here</h2>
242
-
<p>This wizard will help you move your AT Protocol account from another PDS to this one.</p>
326
+
<h2>{$_('migration.inbound.welcome.title')}</h2>
327
+
<p>{$_('migration.inbound.welcome.desc')}</p>
243
328
244
329
<div class="info-box">
245
-
<h3>What will happen:</h3>
330
+
<h3>{$_('migration.inbound.common.whatWillHappen')}</h3>
246
331
<ol>
247
-
<li>Log in to your current PDS</li>
248
-
<li>Choose your new handle on this server</li>
249
-
<li>Your repository and blobs will be transferred</li>
250
-
<li>Verify the migration via email</li>
251
-
<li>Your identity will be updated to point here</li>
332
+
<li>{$_('migration.inbound.common.step1')}</li>
333
+
<li>{$_('migration.inbound.common.step2')}</li>
334
+
<li>{$_('migration.inbound.common.step3')}</li>
335
+
<li>{$_('migration.inbound.common.step4')}</li>
336
+
<li>{$_('migration.inbound.common.step5')}</li>
252
337
</ol>
253
338
</div>
254
339
255
340
<div class="warning-box">
256
-
<strong>Before you proceed:</strong>
341
+
<strong>{$_('migration.inbound.common.beforeProceed')}</strong>
257
342
<ul>
258
-
<li>You need access to the email registered with your current account</li>
259
-
<li>Large accounts may take several minutes to transfer</li>
260
-
<li>Your old account will be deactivated after migration</li>
343
+
<li>{$_('migration.inbound.common.warning1')}</li>
344
+
<li>{$_('migration.inbound.common.warning2')}</li>
345
+
<li>{$_('migration.inbound.common.warning3')}</li>
261
346
</ul>
262
347
</div>
263
348
264
349
<label class="checkbox-label">
265
350
<input type="checkbox" bind:checked={understood} />
266
-
<span>I understand the risks and want to proceed with migration</span>
351
+
<span>{$_('migration.inbound.welcome.understand')}</span>
267
352
</label>
268
353
269
354
<div class="button-row">
270
-
<button class="ghost" onclick={onBack}>Cancel</button>
271
-
<button disabled={!understood} onclick={() => flow.setStep('source-login')}>
272
-
Continue
355
+
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
356
+
<button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
357
+
{$_('migration.inbound.common.continue')}
273
358
</button>
274
359
</div>
275
360
</div>
276
361
277
-
{:else if flow.state.step === 'source-login'}
362
+
{:else if flow.state.step === 'source-handle'}
278
363
<div class="step-content">
279
-
<h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2>
280
-
<p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p>
364
+
<h2>{isResuming ? $_('migration.inbound.sourceAuth.titleResume') : $_('migration.inbound.sourceAuth.title')}</h2>
365
+
<p>{isResuming ? $_('migration.inbound.sourceAuth.descResume') : $_('migration.inbound.sourceAuth.desc')}</p>
281
366
282
-
{#if isResumedMigration}
283
-
<div class="info-box">
284
-
<p>Your migration was interrupted. Log in to both accounts to resume.</p>
285
-
<p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong> → <strong>{flow.state.targetHandle}</strong></p>
367
+
{#if isResuming && resumeInfo}
368
+
<div class="info-box resume-info">
369
+
<h3>{$_('migration.inbound.sourceAuth.resumeTitle')}</h3>
370
+
<div class="resume-details">
371
+
<div class="resume-row">
372
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeFrom')}:</span>
373
+
<span class="value">@{resumeInfo.sourceHandle}</span>
374
+
</div>
375
+
<div class="resume-row">
376
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeTo')}:</span>
377
+
<span class="value">@{resumeInfo.targetHandle}</span>
378
+
</div>
379
+
<div class="resume-row">
380
+
<span class="label">{$_('migration.inbound.sourceAuth.resumeProgress')}:</span>
381
+
<span class="value">{resumeInfo.progressSummary}</span>
382
+
</div>
383
+
</div>
384
+
<p class="resume-note">{$_('migration.inbound.sourceAuth.resumeOAuthNote')}</p>
286
385
</div>
287
386
{/if}
288
387
289
-
<form onsubmit={handleLogin}>
388
+
<form onsubmit={handleSourceHandleSubmit}>
290
389
<div class="field">
291
-
<label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label>
390
+
<label for="source-handle">{$_('migration.inbound.sourceAuth.handle')}</label>
292
391
<input
293
-
id="handle"
392
+
id="source-handle"
294
393
type="text"
295
-
placeholder="alice.bsky.social"
394
+
placeholder={$_('migration.inbound.sourceAuth.handlePlaceholder')}
296
395
bind:value={handleInput}
297
-
disabled={loading}
396
+
disabled={loading || isResuming}
298
397
required
299
398
/>
300
-
<p class="hint">Your current handle on your existing PDS</p>
399
+
<p class="hint">{$_('migration.inbound.sourceAuth.handleHint')}</p>
301
400
</div>
302
401
303
-
<div class="field">
304
-
<label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label>
305
-
<input
306
-
id="password"
307
-
type="password"
308
-
bind:value={passwordInput}
309
-
disabled={loading}
310
-
required
311
-
/>
312
-
<p class="hint">Your account password (not an app password)</p>
313
-
</div>
314
-
315
-
{#if flow.state.requires2FA}
316
-
<div class="field">
317
-
<label for="2fa">Two-Factor Code</label>
318
-
<input
319
-
id="2fa"
320
-
type="text"
321
-
placeholder="Enter code from email"
322
-
bind:value={flow.state.twoFactorCode}
323
-
disabled={loading}
324
-
required
325
-
/>
326
-
<p class="hint">Check your email for the verification code</p>
327
-
</div>
328
-
{/if}
329
-
330
-
{#if isResumedMigration}
331
-
<hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" />
332
-
333
-
<div class="field">
334
-
<label for="local-password">New Account Password</label>
335
-
<input
336
-
id="local-password"
337
-
type="password"
338
-
placeholder="Password for your new account"
339
-
bind:value={localPasswordInput}
340
-
disabled={loading}
341
-
required
342
-
/>
343
-
<p class="hint">The password you set for your account on this PDS</p>
344
-
</div>
345
-
{/if}
346
-
347
402
<div class="button-row">
348
-
<button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button>
349
-
<button type="submit" disabled={loading}>
350
-
{loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')}
403
+
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
404
+
<button type="submit" disabled={loading || !handleInput.trim()}>
405
+
{loading ? $_('migration.inbound.sourceAuth.connecting') : (isResuming ? $_('migration.inbound.sourceAuth.reauthenticate') : $_('migration.inbound.sourceAuth.continue'))}
351
406
</button>
352
407
</div>
353
408
</form>
···
355
410
356
411
{:else if flow.state.step === 'choose-handle'}
357
412
<div class="step-content">
358
-
<h2>Choose Your New Handle</h2>
359
-
<p>Select a handle for your account on this PDS.</p>
413
+
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
414
+
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
360
415
361
416
<div class="current-info">
362
-
<span class="label">Migrating from:</span>
417
+
<span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
363
418
<span class="value">{flow.state.sourceHandle}</span>
364
419
</div>
365
420
366
421
<div class="field">
367
-
<label for="new-handle">New Handle</label>
422
+
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
368
423
<div class="handle-input-group">
369
424
<input
370
425
id="new-handle"
···
383
438
</div>
384
439
385
440
{#if checkingHandle}
386
-
<p class="hint">Checking availability...</p>
441
+
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
387
442
{:else if handleAvailable === true}
388
-
<p class="hint success">Handle is available!</p>
443
+
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
389
444
{:else if handleAvailable === false}
390
-
<p class="hint error">Handle is already taken</p>
445
+
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
391
446
{:else}
392
-
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
447
+
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
393
448
{/if}
394
449
</div>
395
450
396
451
<div class="field">
397
-
<label for="email">Email Address</label>
452
+
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
398
453
<input
399
454
id="email"
400
455
type="email"
···
406
461
</div>
407
462
408
463
<div class="field">
409
-
<label for="new-password">Password</label>
410
-
<input
411
-
id="new-password"
412
-
type="password"
413
-
placeholder="Password for your new account"
414
-
bind:value={flow.state.targetPassword}
415
-
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
416
-
required
417
-
minlength="8"
418
-
/>
419
-
<p class="hint">At least 8 characters</p>
464
+
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
465
+
<div class="auth-method-options">
466
+
<label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
467
+
<input
468
+
type="radio"
469
+
name="auth-method"
470
+
value="password"
471
+
bind:group={selectedAuthMethod}
472
+
/>
473
+
<div class="auth-option-content">
474
+
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
475
+
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
476
+
</div>
477
+
</label>
478
+
<label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
479
+
<input
480
+
type="radio"
481
+
name="auth-method"
482
+
value="passkey"
483
+
bind:group={selectedAuthMethod}
484
+
/>
485
+
<div class="auth-option-content">
486
+
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
487
+
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
488
+
</div>
489
+
</label>
490
+
</div>
420
491
</div>
421
492
493
+
{#if selectedAuthMethod === 'password'}
494
+
<div class="field">
495
+
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
496
+
<input
497
+
id="new-password"
498
+
type="password"
499
+
placeholder="Password for your new account"
500
+
bind:value={flow.state.targetPassword}
501
+
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
502
+
required
503
+
minlength="8"
504
+
/>
505
+
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
506
+
</div>
507
+
{:else}
508
+
<div class="info-box">
509
+
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
510
+
</div>
511
+
{/if}
512
+
422
513
{#if serverInfo?.inviteCodeRequired}
423
514
<div class="field">
424
-
<label for="invite">Invite Code</label>
515
+
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
425
516
<input
426
517
id="invite"
427
518
type="text"
···
434
525
{/if}
435
526
436
527
<div class="button-row">
437
-
<button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button>
528
+
<button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
438
529
<button
439
-
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false}
440
-
onclick={proceedToReview}
530
+
disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
531
+
onclick={proceedToReviewWithAuth}
441
532
>
442
-
Continue
533
+
{$_('migration.inbound.common.continue')}
443
534
</button>
444
535
</div>
445
536
</div>
446
537
447
538
{:else if flow.state.step === 'review'}
448
539
<div class="step-content">
449
-
<h2>Review Migration</h2>
450
-
<p>Please confirm the details of your migration.</p>
540
+
<h2>{$_('migration.inbound.review.title')}</h2>
541
+
<p>{$_('migration.inbound.review.desc')}</p>
451
542
452
543
<div class="review-card">
453
544
<div class="review-row">
454
-
<span class="label">Current Handle:</span>
545
+
<span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
455
546
<span class="value">{flow.state.sourceHandle}</span>
456
547
</div>
457
548
<div class="review-row">
458
-
<span class="label">New Handle:</span>
549
+
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
459
550
<span class="value">{flow.state.targetHandle}</span>
460
551
</div>
461
552
<div class="review-row">
462
-
<span class="label">DID:</span>
553
+
<span class="label">{$_('migration.inbound.review.did')}:</span>
463
554
<span class="value mono">{flow.state.sourceDid}</span>
464
555
</div>
465
556
<div class="review-row">
466
-
<span class="label">From PDS:</span>
557
+
<span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
467
558
<span class="value">{flow.state.sourcePdsUrl}</span>
468
559
</div>
469
560
<div class="review-row">
470
-
<span class="label">To PDS:</span>
561
+
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
471
562
<span class="value">{window.location.origin}</span>
472
563
</div>
473
564
<div class="review-row">
474
-
<span class="label">Email:</span>
565
+
<span class="label">{$_('migration.inbound.review.email')}:</span>
475
566
<span class="value">{flow.state.targetEmail}</span>
476
567
</div>
568
+
<div class="review-row">
569
+
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
570
+
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
571
+
</div>
477
572
</div>
478
573
479
574
<div class="warning-box">
480
-
<strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin
481
-
transferring. This process cannot be easily undone.
575
+
{$_('migration.inbound.review.warning')}
482
576
</div>
483
577
484
578
<div class="button-row">
485
-
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button>
579
+
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
486
580
<button onclick={startMigration} disabled={loading}>
487
-
{loading ? 'Starting...' : 'Start Migration'}
581
+
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
488
582
</button>
489
583
</div>
490
584
</div>
491
585
492
586
{:else if flow.state.step === 'migrating'}
493
587
<div class="step-content">
494
-
<h2>Migration in Progress</h2>
495
-
<p>Please wait while your account is being transferred...</p>
588
+
<h2>{$_('migration.inbound.migrating.title')}</h2>
589
+
<p>{$_('migration.inbound.migrating.desc')}</p>
496
590
497
591
<div class="progress-section">
498
592
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
499
593
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
500
-
<span>Export repository</span>
594
+
<span>{$_('migration.inbound.migrating.exportRepo')}</span>
501
595
</div>
502
596
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
503
597
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
504
-
<span>Import repository</span>
598
+
<span>{$_('migration.inbound.migrating.importRepo')}</span>
505
599
</div>
506
600
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
507
601
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
508
-
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
602
+
<span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
509
603
</div>
510
604
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
511
605
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
512
-
<span>Migrate preferences</span>
606
+
<span>{$_('migration.inbound.migrating.migratePrefs')}</span>
513
607
</div>
514
608
</div>
515
609
···
525
619
<p class="status-text">{flow.state.progress.currentOperation}</p>
526
620
</div>
527
621
622
+
{:else if flow.state.step === 'passkey-setup'}
623
+
<div class="step-content">
624
+
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
625
+
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
626
+
627
+
{#if flow.state.error}
628
+
<div class="message error">
629
+
{flow.state.error}
630
+
</div>
631
+
{/if}
632
+
633
+
<div class="field">
634
+
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
635
+
<input
636
+
id="passkey-name"
637
+
type="text"
638
+
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
639
+
bind:value={passkeyName}
640
+
disabled={loading}
641
+
/>
642
+
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
643
+
</div>
644
+
645
+
<div class="passkey-section">
646
+
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
647
+
<button class="primary" onclick={registerPasskey} disabled={loading}>
648
+
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
649
+
</button>
650
+
</div>
651
+
</div>
652
+
653
+
{:else if flow.state.step === 'app-password'}
654
+
<div class="step-content">
655
+
<h2>{$_('migration.inbound.appPassword.title')}</h2>
656
+
<p>{$_('migration.inbound.appPassword.desc')}</p>
657
+
658
+
<div class="warning-box">
659
+
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
660
+
</div>
661
+
662
+
<div class="app-password-display">
663
+
<div class="app-password-label">
664
+
{$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
665
+
</div>
666
+
<code class="app-password-code">{flow.state.generatedAppPassword}</code>
667
+
<button type="button" class="copy-btn" onclick={copyAppPassword}>
668
+
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
669
+
</button>
670
+
</div>
671
+
672
+
<label class="checkbox-label">
673
+
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
674
+
<span>{$_('migration.inbound.appPassword.saved')}</span>
675
+
</label>
676
+
677
+
<div class="button-row">
678
+
<button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
679
+
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
680
+
</button>
681
+
</div>
682
+
</div>
683
+
528
684
{:else if flow.state.step === 'email-verify'}
529
685
<div class="step-content">
530
686
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
···
537
693
</div>
538
694
539
695
{#if flow.state.error}
540
-
<div class="error-box">
696
+
<div class="message error">
541
697
{flow.state.error}
542
698
</div>
543
699
{/if}
···
569
725
570
726
{:else if flow.state.step === 'plc-token'}
571
727
<div class="step-content">
572
-
<h2>Verify Migration</h2>
573
-
<p>A verification code has been sent to the email registered with your old account.</p>
728
+
<h2>{$_('migration.inbound.plcToken.title')}</h2>
729
+
<p>{$_('migration.inbound.plcToken.desc')}</p>
574
730
575
731
<div class="info-box">
576
-
<p>
577
-
This code confirms you have access to the account and authorizes updating your identity
578
-
to point to this PDS.
579
-
</p>
732
+
<p>{$_('migration.inbound.plcToken.info')}</p>
580
733
</div>
581
734
582
735
<form onsubmit={submitPlcToken}>
583
736
<div class="field">
584
-
<label for="plc-token">Verification Code</label>
737
+
<label for="plc-token">{$_('migration.inbound.plcToken.tokenLabel')}</label>
585
738
<input
586
739
id="plc-token"
587
740
type="text"
588
-
placeholder="Enter code from email"
741
+
placeholder={$_('migration.inbound.plcToken.tokenPlaceholder')}
589
742
bind:value={flow.state.plcToken}
590
743
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
591
744
disabled={loading}
···
595
748
596
749
<div class="button-row">
597
750
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
598
-
Resend Code
751
+
{$_('migration.inbound.plcToken.resend')}
599
752
</button>
600
753
<button type="submit" disabled={loading || !flow.state.plcToken}>
601
-
{loading ? 'Verifying...' : 'Complete Migration'}
754
+
{loading ? $_('migration.inbound.plcToken.completing') : $_('migration.inbound.plcToken.complete')}
602
755
</button>
603
756
</div>
604
757
</form>
···
653
806
</div>
654
807
655
808
<div class="button-row">
656
-
<button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>Back</button>
809
+
<button class="ghost" onclick={() => flow.setStep('email-verify')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
657
810
<button onclick={completeDidWeb} disabled={loading}>
658
811
{loading ? $_('migration.inbound.didWebUpdate.completing') : $_('migration.inbound.didWebUpdate.complete')}
659
812
</button>
···
662
815
663
816
{:else if flow.state.step === 'finalizing'}
664
817
<div class="step-content">
665
-
<h2>Finalizing Migration</h2>
666
-
<p>Please wait while we complete the migration...</p>
818
+
<h2>{$_('migration.inbound.finalizing.title')}</h2>
819
+
<p>{$_('migration.inbound.finalizing.desc')}</p>
667
820
668
821
<div class="progress-section">
669
822
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
670
823
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
671
-
<span>Sign identity update</span>
824
+
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
672
825
</div>
673
826
<div class="progress-item" class:completed={flow.state.progress.activated}>
674
827
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
675
-
<span>Activate new account</span>
828
+
<span>{$_('migration.inbound.finalizing.activating')}</span>
676
829
</div>
677
830
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
678
831
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
679
-
<span>Deactivate old account</span>
832
+
<span>{$_('migration.inbound.finalizing.deactivating')}</span>
680
833
</div>
681
834
</div>
682
835
···
686
839
{:else if flow.state.step === 'success'}
687
840
<div class="step-content success-content">
688
841
<div class="success-icon">✓</div>
689
-
<h2>Migration Complete!</h2>
690
-
<p>Your account has been successfully migrated to this PDS.</p>
842
+
<h2>{$_('migration.inbound.success.title')}</h2>
843
+
<p>{$_('migration.inbound.success.desc')}</p>
691
844
692
845
<div class="success-details">
693
846
<div class="detail-row">
694
-
<span class="label">Your new handle:</span>
847
+
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
695
848
<span class="value">{flow.state.targetHandle}</span>
696
849
</div>
697
850
<div class="detail-row">
698
-
<span class="label">DID:</span>
851
+
<span class="label">{$_('migration.inbound.success.did')}:</span>
699
852
<span class="value mono">{flow.state.sourceDid}</span>
700
853
</div>
701
854
</div>
702
855
703
856
{#if flow.state.progress.blobsFailed.length > 0}
704
-
<div class="warning-box">
705
-
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
706
-
These may be images or other media that are no longer available.
857
+
<div class="message warning">
858
+
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
707
859
</div>
708
860
{/if}
709
861
710
-
<p class="redirect-text">Redirecting to dashboard...</p>
862
+
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
711
863
</div>
712
864
713
865
{:else if flow.state.step === 'error'}
714
866
<div class="step-content">
715
-
<h2>Migration Error</h2>
716
-
<p>An error occurred during migration.</p>
867
+
<h2>{$_('migration.inbound.error.title')}</h2>
868
+
<p>{$_('migration.inbound.error.desc')}</p>
717
869
718
-
<div class="error-box">
719
-
{flow.state.error}
870
+
<div class="message error">
871
+
{flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
720
872
</div>
721
873
722
874
<div class="button-row">
723
-
<button class="ghost" onclick={onBack}>Start Over</button>
875
+
<button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
724
876
</div>
725
877
</div>
726
878
{/if}
727
879
</div>
728
880
729
881
<style>
730
-
.inbound-wizard {
731
-
max-width: 600px;
732
-
margin: 0 auto;
733
-
}
734
-
735
-
.step-indicator {
736
-
display: flex;
737
-
align-items: center;
738
-
justify-content: center;
739
-
margin-bottom: var(--space-8);
740
-
padding: 0 var(--space-4);
882
+
.passkey-section {
883
+
margin-top: 16px;
741
884
}
742
-
743
-
.step {
744
-
display: flex;
745
-
flex-direction: column;
746
-
align-items: center;
747
-
gap: var(--space-2);
885
+
.passkey-section button {
886
+
width: 100%;
887
+
margin-top: 12px;
748
888
}
749
-
750
-
.step-dot {
751
-
width: 32px;
752
-
height: 32px;
753
-
border-radius: 50%;
754
-
background: var(--bg-secondary);
755
-
border: 2px solid var(--border);
756
-
display: flex;
757
-
align-items: center;
758
-
justify-content: center;
759
-
font-size: var(--text-sm);
760
-
font-weight: var(--font-medium);
761
-
color: var(--text-secondary);
762
-
}
763
-
764
-
.step.active .step-dot {
765
-
background: var(--accent);
766
-
border-color: var(--accent);
767
-
color: var(--text-inverse);
768
-
}
769
-
770
-
.step.completed .step-dot {
771
-
background: var(--success-bg);
772
-
border-color: var(--success-text);
773
-
color: var(--success-text);
774
-
}
775
-
776
-
.step-label {
777
-
font-size: var(--text-xs);
778
-
color: var(--text-secondary);
779
-
}
780
-
781
-
.step.active .step-label {
782
-
color: var(--accent);
783
-
font-weight: var(--font-medium);
784
-
}
785
-
786
-
.step-line {
787
-
flex: 1;
788
-
height: 2px;
789
-
background: var(--border);
790
-
margin: 0 var(--space-2);
791
-
margin-bottom: var(--space-6);
792
-
min-width: 20px;
793
-
}
794
-
795
-
.step-line.completed {
796
-
background: var(--success-text);
797
-
}
798
-
799
-
.step-content {
800
-
background: var(--bg-secondary);
889
+
.app-password-display {
890
+
background: var(--bg-card);
891
+
border: 2px solid var(--accent);
801
892
border-radius: var(--radius-xl);
802
893
padding: var(--space-6);
803
-
}
804
-
805
-
.step-content h2 {
806
-
margin: 0 0 var(--space-3) 0;
894
+
text-align: center;
895
+
margin: var(--space-4) 0;
807
896
}
808
-
809
-
.step-content > p {
897
+
.app-password-label {
898
+
font-size: var(--text-sm);
810
899
color: var(--text-secondary);
811
-
margin: 0 0 var(--space-5) 0;
900
+
margin-bottom: var(--space-4);
812
901
}
813
-
814
-
.info-box {
815
-
background: var(--accent-muted);
816
-
border: 1px solid var(--accent);
817
-
border-radius: var(--radius-lg);
902
+
.app-password-code {
903
+
display: block;
904
+
font-size: var(--text-xl);
905
+
font-family: ui-monospace, monospace;
906
+
letter-spacing: 0.1em;
818
907
padding: var(--space-5);
819
-
margin-bottom: var(--space-5);
820
-
}
821
-
822
-
.info-box h3 {
823
-
margin: 0 0 var(--space-3) 0;
824
-
font-size: var(--text-base);
825
-
}
826
-
827
-
.info-box ol, .info-box ul {
828
-
margin: 0;
829
-
padding-left: var(--space-5);
830
-
}
831
-
832
-
.info-box li {
833
-
margin-bottom: var(--space-2);
834
-
color: var(--text-secondary);
908
+
background: var(--bg-input);
909
+
border-radius: var(--radius-md);
910
+
margin-bottom: var(--space-4);
911
+
user-select: all;
835
912
}
836
-
837
-
.info-box p {
838
-
margin: 0;
839
-
color: var(--text-secondary);
840
-
}
841
-
842
-
.warning-box {
843
-
background: var(--warning-bg);
844
-
border: 1px solid var(--warning-border);
845
-
border-radius: var(--radius-lg);
846
-
padding: var(--space-5);
847
-
margin-bottom: var(--space-5);
913
+
.copy-btn {
914
+
padding: var(--space-3) var(--space-5);
848
915
font-size: var(--text-sm);
849
916
}
850
-
851
-
.warning-box strong {
852
-
color: var(--warning-text);
853
-
}
854
-
855
-
.warning-box ul {
856
-
margin: var(--space-3) 0 0 0;
857
-
padding-left: var(--space-5);
858
-
}
859
-
860
-
.error-box {
861
-
background: var(--error-bg);
862
-
border: 1px solid var(--error-border);
863
-
border-radius: var(--radius-lg);
864
-
padding: var(--space-5);
917
+
.resume-info {
865
918
margin-bottom: var(--space-5);
866
-
color: var(--error-text);
867
919
}
868
-
869
-
.checkbox-label {
870
-
display: inline-flex;
871
-
align-items: flex-start;
872
-
gap: var(--space-3);
873
-
cursor: pointer;
874
-
margin-bottom: var(--space-5);
875
-
text-align: left;
920
+
.resume-info h3 {
921
+
margin: 0 0 var(--space-3) 0;
922
+
font-size: var(--text-base);
876
923
}
877
-
878
-
.checkbox-label input[type="checkbox"] {
879
-
width: 18px;
880
-
height: 18px;
881
-
margin: 0;
882
-
flex-shrink: 0;
883
-
}
884
-
885
-
.button-row {
886
-
display: flex;
887
-
gap: var(--space-3);
888
-
justify-content: flex-end;
889
-
margin-top: var(--space-5);
890
-
}
891
-
892
-
.field {
893
-
margin-bottom: var(--space-5);
894
-
}
895
-
896
-
.field label {
897
-
display: block;
898
-
margin-bottom: var(--space-2);
899
-
font-weight: var(--font-medium);
900
-
}
901
-
902
-
.field input, .field select {
903
-
width: 100%;
904
-
padding: var(--space-3);
905
-
border: 1px solid var(--border);
906
-
border-radius: var(--radius-md);
907
-
background: var(--bg-primary);
908
-
color: var(--text-primary);
909
-
}
910
-
911
-
.field input:focus, .field select:focus {
912
-
outline: none;
913
-
border-color: var(--accent);
914
-
}
915
-
916
-
.hint {
917
-
font-size: var(--text-sm);
918
-
color: var(--text-secondary);
919
-
margin: var(--space-2) 0 0 0;
920
-
}
921
-
922
-
.hint.success {
923
-
color: var(--success-text);
924
-
}
925
-
926
-
.hint.error {
927
-
color: var(--error-text);
928
-
}
929
-
930
-
.handle-input-group {
924
+
.resume-details {
931
925
display: flex;
926
+
flex-direction: column;
932
927
gap: var(--space-2);
933
928
}
934
-
935
-
.handle-input-group input {
936
-
flex: 1;
937
-
}
938
-
939
-
.handle-input-group select {
940
-
width: auto;
941
-
}
942
-
943
-
.current-info {
944
-
background: var(--bg-primary);
945
-
border-radius: var(--radius-lg);
946
-
padding: var(--space-4);
947
-
margin-bottom: var(--space-5);
948
-
display: flex;
949
-
justify-content: space-between;
950
-
}
951
-
952
-
.current-info .label {
953
-
color: var(--text-secondary);
954
-
}
955
-
956
-
.current-info .value {
957
-
font-weight: var(--font-medium);
958
-
}
959
-
960
-
.review-card {
961
-
background: var(--bg-primary);
962
-
border-radius: var(--radius-lg);
963
-
padding: var(--space-4);
964
-
margin-bottom: var(--space-5);
965
-
}
966
-
967
-
.review-row {
929
+
.resume-row {
968
930
display: flex;
969
931
justify-content: space-between;
970
-
padding: var(--space-3) 0;
971
-
border-bottom: 1px solid var(--border);
972
-
}
973
-
974
-
.review-row:last-child {
975
-
border-bottom: none;
976
-
}
977
-
978
-
.review-row .label {
979
-
color: var(--text-secondary);
980
-
}
981
-
982
-
.review-row .value {
983
-
font-weight: var(--font-medium);
984
-
text-align: right;
985
-
word-break: break-all;
986
-
}
987
-
988
-
.review-row .value.mono {
989
-
font-family: var(--font-mono);
990
932
font-size: var(--text-sm);
991
933
}
992
-
993
-
.progress-section {
994
-
margin-bottom: var(--space-5);
995
-
}
996
-
997
-
.progress-item {
998
-
display: flex;
999
-
align-items: center;
1000
-
gap: var(--space-3);
1001
-
padding: var(--space-3) 0;
1002
-
color: var(--text-secondary);
1003
-
}
1004
-
1005
-
.progress-item.completed {
1006
-
color: var(--success-text);
1007
-
}
1008
-
1009
-
.progress-item.active {
1010
-
color: var(--accent);
1011
-
}
1012
-
1013
-
.progress-item .icon {
1014
-
width: 24px;
1015
-
text-align: center;
1016
-
}
1017
-
1018
-
.progress-bar {
1019
-
height: 8px;
1020
-
background: var(--bg-primary);
1021
-
border-radius: 4px;
1022
-
overflow: hidden;
1023
-
margin-bottom: var(--space-4);
1024
-
}
1025
-
1026
-
.progress-fill {
1027
-
height: 100%;
1028
-
background: var(--accent);
1029
-
transition: width 0.3s ease;
1030
-
}
1031
-
1032
-
.status-text {
1033
-
text-align: center;
1034
-
color: var(--text-secondary);
1035
-
font-size: var(--text-sm);
1036
-
}
1037
-
1038
-
.success-content {
1039
-
text-align: center;
1040
-
}
1041
-
1042
-
.success-icon {
1043
-
width: 64px;
1044
-
height: 64px;
1045
-
background: var(--success-bg);
1046
-
color: var(--success-text);
1047
-
border-radius: 50%;
1048
-
display: flex;
1049
-
align-items: center;
1050
-
justify-content: center;
1051
-
font-size: var(--text-2xl);
1052
-
margin: 0 auto var(--space-5) auto;
1053
-
}
1054
-
1055
-
.success-details {
1056
-
background: var(--bg-primary);
1057
-
border-radius: var(--radius-lg);
1058
-
padding: var(--space-4);
1059
-
margin: var(--space-5) 0;
1060
-
text-align: left;
1061
-
}
1062
-
1063
-
.success-details .detail-row {
1064
-
display: flex;
1065
-
justify-content: space-between;
1066
-
padding: var(--space-2) 0;
1067
-
}
1068
-
1069
-
.success-details .label {
934
+
.resume-row .label {
1070
935
color: var(--text-secondary);
1071
936
}
1072
-
1073
-
.success-details .value {
937
+
.resume-row .value {
1074
938
font-weight: var(--font-medium);
1075
939
}
1076
-
1077
-
.success-details .value.mono {
1078
-
font-family: var(--font-mono);
940
+
.resume-note {
941
+
margin-top: var(--space-3);
1079
942
font-size: var(--text-sm);
1080
-
}
1081
-
1082
-
.redirect-text {
1083
-
color: var(--text-secondary);
1084
943
font-style: italic;
1085
-
}
1086
-
1087
-
.message.error {
1088
-
background: var(--error-bg);
1089
-
border: 1px solid var(--error-border);
1090
-
color: var(--error-text);
1091
-
padding: var(--space-4);
1092
-
border-radius: var(--radius-lg);
1093
-
margin-bottom: var(--space-5);
1094
-
}
1095
-
1096
-
.code-block {
1097
-
background: var(--bg-primary);
1098
-
border: 1px solid var(--border);
1099
-
border-radius: var(--radius-lg);
1100
-
padding: var(--space-4);
1101
-
margin-bottom: var(--space-5);
1102
-
overflow-x: auto;
1103
-
}
1104
-
1105
-
.code-block pre {
1106
-
margin: 0;
1107
-
font-family: var(--font-mono);
1108
-
font-size: var(--text-sm);
1109
-
white-space: pre-wrap;
1110
-
word-break: break-all;
1111
-
}
1112
-
1113
-
code {
1114
-
font-family: var(--font-mono);
1115
-
background: var(--bg-primary);
1116
-
padding: 2px 6px;
1117
-
border-radius: var(--radius-sm);
1118
-
font-size: 0.9em;
1119
944
}
1120
945
</style>
+20
-466
frontend/src/components/migration/OutboundWizard.svelte
+20
-466
frontend/src/components/migration/OutboundWizard.svelte
···
2
2
import type { OutboundMigrationFlow } from '../../lib/migration'
3
3
import type { ServerDescription } from '../../lib/migration/types'
4
4
import { getAuthState, logout } from '../../lib/auth.svelte'
5
+
import '../../styles/migration.css'
5
6
6
7
interface Props {
7
8
flow: OutboundMigrationFlow
···
119
120
}
120
121
</script>
121
122
122
-
<div class="outbound-wizard">
123
+
<div class="migration-wizard">
123
124
{#if flow.state.step !== 'welcome'}
124
125
<div class="step-indicator">
125
126
{#each steps as stepName, i}
···
135
136
{/if}
136
137
137
138
{#if flow.state.error}
138
-
<div class="message error">{flow.state.error}</div>
139
+
<div class="migration-message error">{flow.state.error}</div>
139
140
{/if}
140
141
141
142
{#if flow.state.step === 'welcome'}
···
149
150
</div>
150
151
151
152
{#if isDidWeb()}
152
-
<div class="warning-box">
153
+
<div class="migration-warning-box">
153
154
<strong>did:web Migration Notice</strong>
154
155
<p>
155
156
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
···
161
162
</div>
162
163
{/if}
163
164
164
-
<div class="info-box">
165
+
<div class="migration-info-box">
165
166
<h3>What will happen:</h3>
166
167
<ol>
167
168
<li>Choose your new PDS</li>
···
173
174
</ol>
174
175
</div>
175
176
176
-
<div class="warning-box">
177
+
<div class="migration-warning-box">
177
178
<strong>Before you proceed:</strong>
178
179
<ul>
179
180
<li>You need access to the email registered with this account</li>
···
202
203
<p>Enter the URL of the PDS you want to migrate to.</p>
203
204
204
205
<form onsubmit={validatePds}>
205
-
<div class="field">
206
+
<div class="migration-field">
206
207
<label for="pds-url">PDS URL</label>
207
208
<input
208
209
id="pds-url"
···
212
213
disabled={loading}
213
214
required
214
215
/>
215
-
<p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
216
+
<p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
216
217
</div>
217
218
218
219
<div class="button-row">
···
264
265
<span class="value">{flow.state.targetPdsUrl}</span>
265
266
</div>
266
267
267
-
<div class="field">
268
+
<div class="migration-field">
268
269
<label for="new-handle">New Handle</label>
269
270
<div class="handle-input-group">
270
271
<input
···
281
282
</select>
282
283
{/if}
283
284
</div>
284
-
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
285
+
<p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
285
286
</div>
286
287
287
-
<div class="field">
288
+
<div class="migration-field">
288
289
<label for="email">Email Address</label>
289
290
<input
290
291
id="email"
···
296
297
/>
297
298
</div>
298
299
299
-
<div class="field">
300
+
<div class="migration-field">
300
301
<label for="new-password">Password</label>
301
302
<input
302
303
id="new-password"
···
307
308
required
308
309
minlength="8"
309
310
/>
310
-
<p class="hint">At least 8 characters. This will be your password on the new PDS.</p>
311
+
<p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p>
311
312
</div>
312
313
313
314
{#if flow.state.targetServerInfo?.inviteCodeRequired}
314
-
<div class="field">
315
+
<div class="migration-field">
315
316
<label for="invite">Invite Code</label>
316
317
<input
317
318
id="invite"
···
321
322
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
322
323
required
323
324
/>
324
-
<p class="hint">Required by this PDS to create an account</p>
325
+
<p class="migration-hint">Required by this PDS to create an account</p>
325
326
</div>
326
327
{/if}
327
328
···
368
369
</div>
369
370
</div>
370
371
371
-
<div class="warning-box final-warning">
372
+
<div class="migration-warning-box final-warning">
372
373
<strong>This action cannot be easily undone!</strong>
373
374
<p>
374
375
After migration completes, your account on this PDS will be deactivated.
···
430
431
<h2>Verify Migration</h2>
431
432
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
432
433
433
-
<div class="info-box">
434
+
<div class="migration-info-box">
434
435
<p>
435
436
This code confirms you have access to the account and authorizes updating your identity
436
437
to point to the new PDS.
···
438
439
</div>
439
440
440
441
<form onsubmit={submitPlcToken}>
441
-
<div class="field">
442
+
<div class="migration-field">
442
443
<label for="plc-token">Verification Code</label>
443
444
<input
444
445
id="plc-token"
···
507
508
</div>
508
509
509
510
{#if flow.state.progress.blobsFailed.length > 0}
510
-
<div class="warning-box">
511
+
<div class="migration-warning-box">
511
512
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
512
513
These may be images or other media that are no longer available.
513
514
</div>
···
530
531
<h2>Migration Error</h2>
531
532
<p>An error occurred during migration.</p>
532
533
533
-
<div class="error-box">
534
+
<div class="migration-error-box">
534
535
{flow.state.error}
535
536
</div>
536
537
···
542
543
</div>
543
544
544
545
<style>
545
-
.outbound-wizard {
546
-
max-width: 600px;
547
-
margin: 0 auto;
548
-
}
549
-
550
-
.step-indicator {
551
-
display: flex;
552
-
align-items: center;
553
-
justify-content: center;
554
-
margin-bottom: var(--space-8);
555
-
padding: 0 var(--space-4);
556
-
}
557
-
558
-
.step {
559
-
display: flex;
560
-
flex-direction: column;
561
-
align-items: center;
562
-
gap: var(--space-2);
563
-
}
564
-
565
-
.step-dot {
566
-
width: 32px;
567
-
height: 32px;
568
-
border-radius: 50%;
569
-
background: var(--bg-secondary);
570
-
border: 2px solid var(--border);
571
-
display: flex;
572
-
align-items: center;
573
-
justify-content: center;
574
-
font-size: var(--text-sm);
575
-
font-weight: var(--font-medium);
576
-
color: var(--text-secondary);
577
-
}
578
-
579
-
.step.active .step-dot {
580
-
background: var(--accent);
581
-
border-color: var(--accent);
582
-
color: var(--text-inverse);
583
-
}
584
-
585
-
.step.completed .step-dot {
586
-
background: var(--success-bg);
587
-
border-color: var(--success-text);
588
-
color: var(--success-text);
589
-
}
590
-
591
-
.step-label {
592
-
font-size: var(--text-xs);
593
-
color: var(--text-secondary);
594
-
}
595
-
596
-
.step.active .step-label {
597
-
color: var(--accent);
598
-
font-weight: var(--font-medium);
599
-
}
600
-
601
-
.step-line {
602
-
flex: 1;
603
-
height: 2px;
604
-
background: var(--border);
605
-
margin: 0 var(--space-2);
606
-
margin-bottom: var(--space-6);
607
-
min-width: 20px;
608
-
}
609
-
610
-
.step-line.completed {
611
-
background: var(--success-text);
612
-
}
613
-
614
-
.step-content {
615
-
background: var(--bg-secondary);
616
-
border-radius: var(--radius-xl);
617
-
padding: var(--space-6);
618
-
}
619
-
620
-
.step-content h2 {
621
-
margin: 0 0 var(--space-3) 0;
622
-
}
623
-
624
-
.step-content > p {
625
-
color: var(--text-secondary);
626
-
margin: 0 0 var(--space-5) 0;
627
-
}
628
-
629
-
.current-account {
630
-
background: var(--bg-primary);
631
-
border-radius: var(--radius-lg);
632
-
padding: var(--space-4);
633
-
margin-bottom: var(--space-5);
634
-
display: flex;
635
-
justify-content: space-between;
636
-
align-items: center;
637
-
}
638
-
639
-
.current-account .label {
640
-
color: var(--text-secondary);
641
-
}
642
-
643
-
.current-account .value {
644
-
font-weight: var(--font-medium);
645
-
font-size: var(--text-lg);
646
-
}
647
-
648
-
.info-box {
649
-
background: var(--accent-muted);
650
-
border: 1px solid var(--accent);
651
-
border-radius: var(--radius-lg);
652
-
padding: var(--space-5);
653
-
margin-bottom: var(--space-5);
654
-
}
655
-
656
-
.info-box h3 {
657
-
margin: 0 0 var(--space-3) 0;
658
-
font-size: var(--text-base);
659
-
}
660
-
661
-
.info-box ol, .info-box ul {
662
-
margin: 0;
663
-
padding-left: var(--space-5);
664
-
}
665
-
666
-
.info-box li {
667
-
margin-bottom: var(--space-2);
668
-
color: var(--text-secondary);
669
-
}
670
-
671
-
.info-box p {
672
-
margin: 0;
673
-
color: var(--text-secondary);
674
-
}
675
-
676
-
.warning-box {
677
-
background: var(--warning-bg);
678
-
border: 1px solid var(--warning-border);
679
-
border-radius: var(--radius-lg);
680
-
padding: var(--space-5);
681
-
margin-bottom: var(--space-5);
682
-
font-size: var(--text-sm);
683
-
}
684
-
685
-
.warning-box strong {
686
-
color: var(--warning-text);
687
-
}
688
-
689
-
.warning-box p {
690
-
margin: var(--space-3) 0 0 0;
691
-
color: var(--text-secondary);
692
-
}
693
-
694
-
.warning-box ul {
695
-
margin: var(--space-3) 0 0 0;
696
-
padding-left: var(--space-5);
697
-
}
698
-
699
-
.final-warning {
700
-
background: var(--error-bg);
701
-
border-color: var(--error-border);
702
-
}
703
-
704
-
.final-warning strong {
705
-
color: var(--error-text);
706
-
}
707
-
708
-
.error-box {
709
-
background: var(--error-bg);
710
-
border: 1px solid var(--error-border);
711
-
border-radius: var(--radius-lg);
712
-
padding: var(--space-5);
713
-
margin-bottom: var(--space-5);
714
-
color: var(--error-text);
715
-
}
716
-
717
-
.checkbox-label {
718
-
display: inline-flex;
719
-
align-items: flex-start;
720
-
gap: var(--space-3);
721
-
cursor: pointer;
722
-
margin-bottom: var(--space-5);
723
-
text-align: left;
724
-
}
725
-
726
-
.checkbox-label input[type="checkbox"] {
727
-
width: 18px;
728
-
height: 18px;
729
-
margin: 0;
730
-
flex-shrink: 0;
731
-
}
732
-
733
-
.button-row {
734
-
display: flex;
735
-
gap: var(--space-3);
736
-
justify-content: flex-end;
737
-
margin-top: var(--space-5);
738
-
}
739
-
740
-
.field {
741
-
margin-bottom: var(--space-5);
742
-
}
743
-
744
-
.field label {
745
-
display: block;
746
-
margin-bottom: var(--space-2);
747
-
font-weight: var(--font-medium);
748
-
}
749
-
750
-
.field input, .field select {
751
-
width: 100%;
752
-
padding: var(--space-3);
753
-
border: 1px solid var(--border);
754
-
border-radius: var(--radius-md);
755
-
background: var(--bg-primary);
756
-
color: var(--text-primary);
757
-
}
758
-
759
-
.field input:focus, .field select:focus {
760
-
outline: none;
761
-
border-color: var(--accent);
762
-
}
763
-
764
-
.hint {
765
-
font-size: var(--text-sm);
766
-
color: var(--text-secondary);
767
-
margin: var(--space-2) 0 0 0;
768
-
}
769
-
770
-
.handle-input-group {
771
-
display: flex;
772
-
gap: var(--space-2);
773
-
}
774
-
775
-
.handle-input-group input {
776
-
flex: 1;
777
-
}
778
-
779
-
.handle-input-group select {
780
-
width: auto;
781
-
}
782
-
783
-
.current-info {
784
-
background: var(--bg-primary);
785
-
border-radius: var(--radius-lg);
786
-
padding: var(--space-4);
787
-
margin-bottom: var(--space-5);
788
-
display: flex;
789
-
justify-content: space-between;
790
-
}
791
-
792
-
.current-info .label {
793
-
color: var(--text-secondary);
794
-
}
795
-
796
-
.current-info .value {
797
-
font-weight: var(--font-medium);
798
-
}
799
-
800
-
.server-info {
801
-
background: var(--bg-primary);
802
-
border-radius: var(--radius-lg);
803
-
padding: var(--space-4);
804
-
margin-top: var(--space-5);
805
-
}
806
-
807
-
.server-info h3 {
808
-
margin: 0 0 var(--space-3) 0;
809
-
font-size: var(--text-base);
810
-
color: var(--success-text);
811
-
}
812
-
813
-
.server-info .info-row {
814
-
display: flex;
815
-
justify-content: space-between;
816
-
padding: var(--space-2) 0;
817
-
font-size: var(--text-sm);
818
-
}
819
-
820
-
.server-info .label {
821
-
color: var(--text-secondary);
822
-
}
823
-
824
-
.server-info a {
825
-
display: inline-block;
826
-
margin-top: var(--space-2);
827
-
margin-right: var(--space-3);
828
-
color: var(--accent);
829
-
font-size: var(--text-sm);
830
-
}
831
-
832
-
.review-card {
833
-
background: var(--bg-primary);
834
-
border-radius: var(--radius-lg);
835
-
padding: var(--space-4);
836
-
margin-bottom: var(--space-5);
837
-
}
838
-
839
-
.review-row {
840
-
display: flex;
841
-
justify-content: space-between;
842
-
padding: var(--space-3) 0;
843
-
border-bottom: 1px solid var(--border);
844
-
}
845
-
846
-
.review-row:last-child {
847
-
border-bottom: none;
848
-
}
849
-
850
-
.review-row .label {
851
-
color: var(--text-secondary);
852
-
}
853
-
854
-
.review-row .value {
855
-
font-weight: var(--font-medium);
856
-
text-align: right;
857
-
word-break: break-all;
858
-
}
859
-
860
-
.review-row .value.mono {
861
-
font-family: var(--font-mono);
862
-
font-size: var(--text-sm);
863
-
}
864
-
865
-
.progress-section {
866
-
margin-bottom: var(--space-5);
867
-
}
868
-
869
-
.progress-item {
870
-
display: flex;
871
-
align-items: center;
872
-
gap: var(--space-3);
873
-
padding: var(--space-3) 0;
874
-
color: var(--text-secondary);
875
-
}
876
-
877
-
.progress-item.completed {
878
-
color: var(--success-text);
879
-
}
880
-
881
-
.progress-item.active {
882
-
color: var(--accent);
883
-
}
884
-
885
-
.progress-item .icon {
886
-
width: 24px;
887
-
text-align: center;
888
-
}
889
-
890
-
.progress-bar {
891
-
height: 8px;
892
-
background: var(--bg-primary);
893
-
border-radius: 4px;
894
-
overflow: hidden;
895
-
margin-bottom: var(--space-4);
896
-
}
897
-
898
-
.progress-fill {
899
-
height: 100%;
900
-
background: var(--accent);
901
-
transition: width 0.3s ease;
902
-
}
903
-
904
-
.status-text {
905
-
text-align: center;
906
-
color: var(--text-secondary);
907
-
font-size: var(--text-sm);
908
-
}
909
-
910
-
.success-content {
911
-
text-align: center;
912
-
}
913
-
914
-
.success-icon {
915
-
width: 64px;
916
-
height: 64px;
917
-
background: var(--success-bg);
918
-
color: var(--success-text);
919
-
border-radius: 50%;
920
-
display: flex;
921
-
align-items: center;
922
-
justify-content: center;
923
-
font-size: var(--text-2xl);
924
-
margin: 0 auto var(--space-5) auto;
925
-
}
926
-
927
-
.success-details {
928
-
background: var(--bg-primary);
929
-
border-radius: var(--radius-lg);
930
-
padding: var(--space-4);
931
-
margin: var(--space-5) 0;
932
-
text-align: left;
933
-
}
934
-
935
-
.success-details .detail-row {
936
-
display: flex;
937
-
justify-content: space-between;
938
-
padding: var(--space-2) 0;
939
-
}
940
-
941
-
.success-details .label {
942
-
color: var(--text-secondary);
943
-
}
944
-
945
-
.success-details .value {
946
-
font-weight: var(--font-medium);
947
-
}
948
-
949
-
.success-details .value.mono {
950
-
font-family: var(--font-mono);
951
-
font-size: var(--text-sm);
952
-
}
953
-
954
-
.next-steps {
955
-
background: var(--accent-muted);
956
-
border-radius: var(--radius-lg);
957
-
padding: var(--space-5);
958
-
margin: var(--space-5) 0;
959
-
text-align: left;
960
-
}
961
-
962
-
.next-steps h3 {
963
-
margin: 0 0 var(--space-3) 0;
964
-
}
965
-
966
-
.next-steps ol {
967
-
margin: 0;
968
-
padding-left: var(--space-5);
969
-
}
970
-
971
-
.next-steps li {
972
-
margin-bottom: var(--space-2);
973
-
}
974
-
975
-
.next-steps a {
976
-
color: var(--accent);
977
-
}
978
-
979
-
.redirect-text {
980
-
color: var(--text-secondary);
981
-
font-style: italic;
982
-
}
983
-
984
-
.message.error {
985
-
background: var(--error-bg);
986
-
border: 1px solid var(--error-border);
987
-
color: var(--error-text);
988
-
padding: var(--space-4);
989
-
border-radius: var(--radius-lg);
990
-
margin-bottom: var(--space-5);
991
-
}
992
546
</style>
+5
-1
frontend/src/lib/auth.svelte.ts
+5
-1
frontend/src/lib/auth.svelte.ts
···
444
444
state.savedAccounts = newState.savedAccounts ?? [];
445
445
}
446
446
447
-
export function _testReset() {
447
+
export function _testResetState() {
448
448
state.session = null;
449
449
state.loading = true;
450
450
state.error = null;
451
451
state.savedAccounts = [];
452
+
}
453
+
454
+
export function _testReset() {
455
+
_testResetState();
452
456
localStorage.removeItem(STORAGE_KEY);
453
457
localStorage.removeItem(ACCOUNTS_KEY);
454
458
}
+1
-1
frontend/src/lib/crypto.ts
+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
1
import type {
2
2
AccountStatus,
3
3
BlobRef,
4
+
CompletePasskeySetupResponse,
4
5
CreateAccountParams,
6
+
CreatePasskeyAccountParams,
5
7
DidCredentials,
6
8
DidDocument,
7
-
MigrationError,
9
+
OAuthServerMetadata,
10
+
OAuthTokenResponse,
11
+
PasskeyAccountSetup,
8
12
PlcOperation,
9
13
Preferences,
10
14
ServerDescription,
11
15
Session,
16
+
StartPasskeyRegistrationResponse,
12
17
} from "./types";
13
18
14
19
function apiLog(
···
28
33
export class AtprotoClient {
29
34
private baseUrl: string;
30
35
private accessToken: string | null = null;
36
+
private dpopKeyPair: DPoPKeyPair | null = null;
37
+
private dpopNonce: string | null = null;
31
38
32
39
constructor(pdsUrl: string) {
33
40
this.baseUrl = pdsUrl.replace(/\/$/, "");
···
41
48
return this.accessToken;
42
49
}
43
50
51
+
setDPoPKeyPair(keyPair: DPoPKeyPair | null) {
52
+
this.dpopKeyPair = keyPair;
53
+
}
54
+
44
55
private async xrpc<T>(
45
56
method: string,
46
57
options?: {
···
67
78
url += `?${searchParams}`;
68
79
}
69
80
70
-
const headers: Record<string, string> = {};
71
-
const token = authToken ?? this.accessToken;
72
-
if (token) {
73
-
headers["Authorization"] = `Bearer ${token}`;
74
-
}
81
+
const makeRequest = async (nonce?: string): Promise<Response> => {
82
+
const headers: Record<string, string> = {};
83
+
const token = authToken ?? this.accessToken;
84
+
if (token) {
85
+
if (this.dpopKeyPair) {
86
+
headers["Authorization"] = `DPoP ${token}`;
87
+
const tokenHash = await computeAccessTokenHash(token);
88
+
const dpopProof = await createDPoPProof(
89
+
this.dpopKeyPair,
90
+
httpMethod,
91
+
url.split("?")[0],
92
+
nonce,
93
+
tokenHash,
94
+
);
95
+
headers["DPoP"] = dpopProof;
96
+
} else {
97
+
headers["Authorization"] = `Bearer ${token}`;
98
+
}
99
+
}
100
+
101
+
let requestBody: BodyInit | undefined;
102
+
if (rawBody) {
103
+
headers["Content-Type"] = contentType ?? "application/octet-stream";
104
+
requestBody = rawBody;
105
+
} else if (body) {
106
+
headers["Content-Type"] = "application/json";
107
+
requestBody = JSON.stringify(body);
108
+
} else if (httpMethod === "POST") {
109
+
headers["Content-Type"] = "application/json";
110
+
}
75
111
76
-
let requestBody: BodyInit | undefined;
77
-
if (rawBody) {
78
-
headers["Content-Type"] = contentType ?? "application/octet-stream";
79
-
requestBody = rawBody;
80
-
} else if (body) {
81
-
headers["Content-Type"] = "application/json";
82
-
requestBody = JSON.stringify(body);
83
-
} else if (httpMethod === "POST") {
84
-
headers["Content-Type"] = "application/json";
112
+
return fetch(url, {
113
+
method: httpMethod,
114
+
headers,
115
+
body: requestBody,
116
+
});
117
+
};
118
+
119
+
let res = await makeRequest(this.dpopNonce ?? undefined);
120
+
121
+
if (!res.ok && this.dpopKeyPair) {
122
+
const dpopNonce = res.headers.get("DPoP-Nonce");
123
+
if (dpopNonce && dpopNonce !== this.dpopNonce) {
124
+
this.dpopNonce = dpopNonce;
125
+
res = await makeRequest(dpopNonce);
126
+
}
85
127
}
86
128
87
-
const res = await fetch(url, {
88
-
method: httpMethod,
89
-
headers,
90
-
body: requestBody,
91
-
});
92
-
93
129
if (!res.ok) {
94
130
const err = await res.json().catch(() => ({
95
131
error: "Unknown",
96
132
message: res.statusText,
97
133
}));
98
-
const error = new Error(err.message) as Error & {
134
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
99
135
status: number;
100
136
error: string;
101
137
};
102
138
error.status = res.status;
103
139
error.error = err.error;
104
140
throw error;
141
+
}
142
+
143
+
const newNonce = res.headers.get("DPoP-Nonce");
144
+
if (newNonce) {
145
+
this.dpopNonce = newNonce;
105
146
}
106
147
107
148
const responseContentType = res.headers.get("content-type") ?? "";
···
231
272
error: "Unknown",
232
273
message: res.statusText,
233
274
}));
234
-
const error = new Error(err.message) as Error & {
275
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
235
276
status: number;
236
277
error: string;
237
278
};
···
436
477
httpMethod: "POST",
437
478
});
438
479
}
480
+
481
+
async createPasskeyAccount(
482
+
params: CreatePasskeyAccountParams,
483
+
serviceToken?: string,
484
+
): Promise<PasskeyAccountSetup> {
485
+
const headers: Record<string, string> = {
486
+
"Content-Type": "application/json",
487
+
};
488
+
if (serviceToken) {
489
+
headers["Authorization"] = `Bearer ${serviceToken}`;
490
+
}
491
+
492
+
const res = await fetch(
493
+
`${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
494
+
{
495
+
method: "POST",
496
+
headers,
497
+
body: JSON.stringify(params),
498
+
},
499
+
);
500
+
501
+
if (!res.ok) {
502
+
const err = await res.json().catch(() => ({
503
+
error: "Unknown",
504
+
message: res.statusText,
505
+
}));
506
+
const error = new Error(err.message || err.error || res.statusText) as Error & {
507
+
status: number;
508
+
error: string;
509
+
};
510
+
error.status = res.status;
511
+
error.error = err.error;
512
+
throw error;
513
+
}
514
+
515
+
return res.json();
516
+
}
517
+
518
+
async startPasskeyRegistrationForSetup(
519
+
did: string,
520
+
setupToken: string,
521
+
friendlyName?: string,
522
+
): Promise<StartPasskeyRegistrationResponse> {
523
+
return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
524
+
httpMethod: "POST",
525
+
body: { did, setupToken, friendlyName },
526
+
});
527
+
}
528
+
529
+
async completePasskeySetup(
530
+
did: string,
531
+
setupToken: string,
532
+
passkeyCredential: unknown,
533
+
passkeyFriendlyName?: string,
534
+
): Promise<CompletePasskeySetupResponse> {
535
+
return this.xrpc("com.tranquil.account.completePasskeySetup", {
536
+
httpMethod: "POST",
537
+
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
538
+
});
539
+
}
540
+
}
541
+
542
+
export async function getOAuthServerMetadata(
543
+
pdsUrl: string,
544
+
): Promise<OAuthServerMetadata | null> {
545
+
try {
546
+
const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
547
+
const directRes = await fetch(directUrl);
548
+
if (directRes.ok) {
549
+
return directRes.json();
550
+
}
551
+
552
+
const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`;
553
+
const protectedRes = await fetch(protectedResourceUrl);
554
+
if (!protectedRes.ok) {
555
+
return null;
556
+
}
557
+
558
+
const protectedMetadata = await protectedRes.json();
559
+
const authServers = protectedMetadata.authorization_servers;
560
+
if (!authServers || authServers.length === 0) {
561
+
return null;
562
+
}
563
+
564
+
const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`;
565
+
const authServerRes = await fetch(authServerUrl);
566
+
if (!authServerRes.ok) {
567
+
return null;
568
+
}
569
+
570
+
return authServerRes.json();
571
+
} catch {
572
+
return null;
573
+
}
574
+
}
575
+
576
+
export async function generatePKCE(): Promise<{
577
+
codeVerifier: string;
578
+
codeChallenge: string;
579
+
}> {
580
+
const array = new Uint8Array(32);
581
+
crypto.getRandomValues(array);
582
+
const codeVerifier = base64UrlEncode(array);
583
+
584
+
const encoder = new TextEncoder();
585
+
const data = encoder.encode(codeVerifier);
586
+
const digest = await crypto.subtle.digest("SHA-256", data);
587
+
const codeChallenge = base64UrlEncode(new Uint8Array(digest));
588
+
589
+
return { codeVerifier, codeChallenge };
590
+
}
591
+
592
+
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
593
+
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
594
+
let binary = "";
595
+
for (let i = 0; i < bytes.length; i++) {
596
+
binary += String.fromCharCode(bytes[i]);
597
+
}
598
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
599
+
}
600
+
601
+
export function base64UrlDecode(base64url: string): Uint8Array {
602
+
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
603
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
604
+
const binary = atob(padded);
605
+
const bytes = new Uint8Array(binary.length);
606
+
for (let i = 0; i < binary.length; i++) {
607
+
bytes[i] = binary.charCodeAt(i);
608
+
}
609
+
return bytes;
610
+
}
611
+
612
+
export function prepareWebAuthnCreationOptions(
613
+
options: { publicKey: Record<string, unknown> },
614
+
): PublicKeyCredentialCreationOptions {
615
+
const pk = options.publicKey;
616
+
return {
617
+
...pk,
618
+
challenge: base64UrlDecode(pk.challenge as string),
619
+
user: {
620
+
...(pk.user as Record<string, unknown>),
621
+
id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
622
+
},
623
+
excludeCredentials:
624
+
((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
625
+
(cred) => ({
626
+
...cred,
627
+
id: base64UrlDecode(cred.id as string),
628
+
}),
629
+
),
630
+
} as PublicKeyCredentialCreationOptions;
631
+
}
632
+
633
+
async function computeAccessTokenHash(accessToken: string): Promise<string> {
634
+
const encoder = new TextEncoder();
635
+
const data = encoder.encode(accessToken);
636
+
const hash = await crypto.subtle.digest("SHA-256", data);
637
+
return base64UrlEncode(new Uint8Array(hash));
638
+
}
639
+
640
+
export function generateOAuthState(): string {
641
+
const array = new Uint8Array(16);
642
+
crypto.getRandomValues(array);
643
+
return base64UrlEncode(array);
644
+
}
645
+
646
+
export function buildOAuthAuthorizationUrl(
647
+
metadata: OAuthServerMetadata,
648
+
params: {
649
+
clientId: string;
650
+
redirectUri: string;
651
+
codeChallenge: string;
652
+
state: string;
653
+
scope?: string;
654
+
dpopJkt?: string;
655
+
loginHint?: string;
656
+
},
657
+
): string {
658
+
const url = new URL(metadata.authorization_endpoint);
659
+
url.searchParams.set("response_type", "code");
660
+
url.searchParams.set("client_id", params.clientId);
661
+
url.searchParams.set("redirect_uri", params.redirectUri);
662
+
url.searchParams.set("code_challenge", params.codeChallenge);
663
+
url.searchParams.set("code_challenge_method", "S256");
664
+
url.searchParams.set("state", params.state);
665
+
url.searchParams.set("scope", params.scope ?? "atproto");
666
+
if (params.dpopJkt) {
667
+
url.searchParams.set("dpop_jkt", params.dpopJkt);
668
+
}
669
+
if (params.loginHint) {
670
+
url.searchParams.set("login_hint", params.loginHint);
671
+
}
672
+
return url.toString();
673
+
}
674
+
675
+
export async function exchangeOAuthCode(
676
+
metadata: OAuthServerMetadata,
677
+
params: {
678
+
code: string;
679
+
codeVerifier: string;
680
+
clientId: string;
681
+
redirectUri: string;
682
+
dpopKeyPair?: DPoPKeyPair;
683
+
},
684
+
): Promise<OAuthTokenResponse> {
685
+
const body = new URLSearchParams({
686
+
grant_type: "authorization_code",
687
+
code: params.code,
688
+
code_verifier: params.codeVerifier,
689
+
client_id: params.clientId,
690
+
redirect_uri: params.redirectUri,
691
+
});
692
+
693
+
const makeRequest = async (nonce?: string): Promise<Response> => {
694
+
const headers: Record<string, string> = {
695
+
"Content-Type": "application/x-www-form-urlencoded",
696
+
};
697
+
698
+
if (params.dpopKeyPair) {
699
+
const dpopProof = await createDPoPProof(
700
+
params.dpopKeyPair,
701
+
"POST",
702
+
metadata.token_endpoint,
703
+
nonce,
704
+
);
705
+
headers["DPoP"] = dpopProof;
706
+
}
707
+
708
+
return fetch(metadata.token_endpoint, {
709
+
method: "POST",
710
+
headers,
711
+
body: body.toString(),
712
+
});
713
+
};
714
+
715
+
let res = await makeRequest();
716
+
717
+
if (!res.ok) {
718
+
const err = await res.json().catch(() => ({
719
+
error: "token_error",
720
+
error_description: res.statusText,
721
+
}));
722
+
723
+
if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
724
+
const dpopNonce = res.headers.get("DPoP-Nonce");
725
+
if (dpopNonce) {
726
+
res = await makeRequest(dpopNonce);
727
+
if (!res.ok) {
728
+
const retryErr = await res.json().catch(() => ({
729
+
error: "token_error",
730
+
error_description: res.statusText,
731
+
}));
732
+
throw new Error(
733
+
retryErr.error_description || retryErr.error || "Token exchange failed",
734
+
);
735
+
}
736
+
return res.json();
737
+
}
738
+
}
739
+
740
+
throw new Error(err.error_description || err.error || "Token exchange failed");
741
+
}
742
+
743
+
return res.json();
439
744
}
440
745
441
746
export async function resolveDidDocument(did: string): Promise<DidDocument> {
···
466
771
export async function resolvePdsUrl(
467
772
handleOrDid: string,
468
773
): Promise<{ did: string; pdsUrl: string }> {
469
-
let did: string;
774
+
let did: string | undefined;
470
775
471
776
if (handleOrDid.startsWith("did:")) {
472
777
did = handleOrDid;
···
515
820
}
516
821
}
517
822
823
+
if (!did) {
824
+
throw new Error("Could not resolve DID");
825
+
}
826
+
518
827
const didDoc = await resolveDidDocument(did);
519
828
520
829
const pdsService = didDoc.service?.find(
···
529
838
}
530
839
531
840
export function createLocalClient(): AtprotoClient {
532
-
return new AtprotoClient(window.location.origin);
841
+
return new AtprotoClient(globalThis.location.origin);
842
+
}
843
+
844
+
export function getMigrationOAuthClientId(): string {
845
+
return `${globalThis.location.origin}/oauth/client-metadata.json`;
846
+
}
847
+
848
+
export function getMigrationOAuthRedirectUri(): string {
849
+
return `${globalThis.location.origin}/migrate`;
850
+
}
851
+
852
+
export interface DPoPKeyPair {
853
+
privateKey: CryptoKey;
854
+
publicKey: CryptoKey;
855
+
jwk: JsonWebKey;
856
+
thumbprint: string;
857
+
}
858
+
859
+
const DPOP_KEY_STORAGE = "migration_dpop_key";
860
+
const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
861
+
862
+
export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
863
+
const keyPair = await crypto.subtle.generateKey(
864
+
{
865
+
name: "ECDSA",
866
+
namedCurve: "P-256",
867
+
},
868
+
true,
869
+
["sign", "verify"],
870
+
);
871
+
872
+
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
873
+
const thumbprint = await computeJwkThumbprint(publicJwk);
874
+
875
+
return {
876
+
privateKey: keyPair.privateKey,
877
+
publicKey: keyPair.publicKey,
878
+
jwk: publicJwk,
879
+
thumbprint,
880
+
};
881
+
}
882
+
883
+
async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
884
+
const thumbprintInput = JSON.stringify({
885
+
crv: jwk.crv,
886
+
kty: jwk.kty,
887
+
x: jwk.x,
888
+
y: jwk.y,
889
+
});
890
+
891
+
const encoder = new TextEncoder();
892
+
const data = encoder.encode(thumbprintInput);
893
+
const hash = await crypto.subtle.digest("SHA-256", data);
894
+
return base64UrlEncode(new Uint8Array(hash));
895
+
}
896
+
897
+
export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
898
+
const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
899
+
const stored = {
900
+
privateJwk,
901
+
publicJwk: keyPair.jwk,
902
+
thumbprint: keyPair.thumbprint,
903
+
createdAt: Date.now(),
904
+
};
905
+
localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
906
+
}
907
+
908
+
export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
909
+
const stored = localStorage.getItem(DPOP_KEY_STORAGE);
910
+
if (!stored) return null;
911
+
912
+
try {
913
+
const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
914
+
915
+
if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
916
+
localStorage.removeItem(DPOP_KEY_STORAGE);
917
+
return null;
918
+
}
919
+
920
+
const privateKey = await crypto.subtle.importKey(
921
+
"jwk",
922
+
privateJwk,
923
+
{ name: "ECDSA", namedCurve: "P-256" },
924
+
true,
925
+
["sign"],
926
+
);
927
+
928
+
const publicKey = await crypto.subtle.importKey(
929
+
"jwk",
930
+
publicJwk,
931
+
{ name: "ECDSA", namedCurve: "P-256" },
932
+
true,
933
+
["verify"],
934
+
);
935
+
936
+
return { privateKey, publicKey, jwk: publicJwk, thumbprint };
937
+
} catch {
938
+
localStorage.removeItem(DPOP_KEY_STORAGE);
939
+
return null;
940
+
}
941
+
}
942
+
943
+
export function clearDPoPKey(): void {
944
+
localStorage.removeItem(DPOP_KEY_STORAGE);
945
+
}
946
+
947
+
export async function createDPoPProof(
948
+
keyPair: DPoPKeyPair,
949
+
httpMethod: string,
950
+
httpUri: string,
951
+
nonce?: string,
952
+
accessTokenHash?: string,
953
+
): Promise<string> {
954
+
const header = {
955
+
typ: "dpop+jwt",
956
+
alg: "ES256",
957
+
jwk: {
958
+
kty: keyPair.jwk.kty,
959
+
crv: keyPair.jwk.crv,
960
+
x: keyPair.jwk.x,
961
+
y: keyPair.jwk.y,
962
+
},
963
+
};
964
+
965
+
const payload: Record<string, unknown> = {
966
+
jti: crypto.randomUUID(),
967
+
htm: httpMethod,
968
+
htu: httpUri,
969
+
iat: Math.floor(Date.now() / 1000),
970
+
};
971
+
972
+
if (nonce) {
973
+
payload.nonce = nonce;
974
+
}
975
+
976
+
if (accessTokenHash) {
977
+
payload.ath = accessTokenHash;
978
+
}
979
+
980
+
const headerB64 = base64UrlEncode(
981
+
new TextEncoder().encode(JSON.stringify(header)),
982
+
);
983
+
const payloadB64 = base64UrlEncode(
984
+
new TextEncoder().encode(JSON.stringify(payload)),
985
+
);
986
+
987
+
const signingInput = `${headerB64}.${payloadB64}`;
988
+
const signature = await crypto.subtle.sign(
989
+
{ name: "ECDSA", hash: "SHA-256" },
990
+
keyPair.privateKey,
991
+
new TextEncoder().encode(signingInput),
992
+
);
993
+
994
+
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
995
+
return `${headerB64}.${payloadB64}.${signatureB64}`;
533
996
}
+350
-78
frontend/src/lib/migration/flow.svelte.ts
+350
-78
frontend/src/lib/migration/flow.svelte.ts
···
2
2
InboundMigrationState,
3
3
InboundStep,
4
4
MigrationProgress,
5
+
OAuthServerMetadata,
5
6
OutboundMigrationState,
6
7
OutboundStep,
8
+
PasskeyAccountSetup,
7
9
ServerDescription,
8
10
StoredMigrationState,
9
11
} from "./types";
10
12
import {
11
13
AtprotoClient,
14
+
buildOAuthAuthorizationUrl,
15
+
clearDPoPKey,
12
16
createLocalClient,
17
+
exchangeOAuthCode,
18
+
generateDPoPKeyPair,
19
+
generateOAuthState,
20
+
generatePKCE,
21
+
getMigrationOAuthClientId,
22
+
getMigrationOAuthRedirectUri,
23
+
getOAuthServerMetadata,
24
+
loadDPoPKey,
13
25
resolvePdsUrl,
26
+
saveDPoPKey,
14
27
} from "./atproto-client";
15
28
import {
16
29
clearMigrationState,
17
-
loadMigrationState,
18
30
saveMigrationState,
19
31
updateProgress,
20
32
updateStep,
···
63
75
plcToken: "",
64
76
progress: createInitialProgress(),
65
77
error: null,
66
-
requires2FA: false,
67
-
twoFactorCode: "",
68
78
targetVerificationMethod: null,
79
+
authMethod: "password",
80
+
passkeySetupToken: null,
81
+
oauthCodeVerifier: null,
82
+
generatedAppPassword: null,
83
+
generatedAppPasswordName: null,
69
84
});
70
85
71
86
let sourceClient: AtprotoClient | null = null;
72
87
let localClient: AtprotoClient | null = null;
73
88
let localServerInfo: ServerDescription | null = null;
89
+
let sourceOAuthMetadata: OAuthServerMetadata | null = null;
74
90
75
91
function setStep(step: InboundStep) {
76
92
state.step = step;
···
111
127
}
112
128
}
113
129
114
-
async function loginToSource(
115
-
handle: string,
116
-
password: string,
117
-
twoFactorCode?: string,
118
-
): Promise<void> {
119
-
migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode });
130
+
async function initiateOAuthLogin(handle: string): Promise<void> {
131
+
migrationLog("initiateOAuthLogin START", { handle });
120
132
121
133
if (!state.sourcePdsUrl) {
122
134
await resolveSourcePds(handle);
123
135
}
124
136
125
-
if (!sourceClient) {
126
-
sourceClient = new AtprotoClient(state.sourcePdsUrl);
137
+
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
138
+
if (!metadata) {
139
+
throw new Error(
140
+
"Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.",
141
+
);
142
+
}
143
+
sourceOAuthMetadata = metadata;
144
+
145
+
const { codeVerifier, codeChallenge } = await generatePKCE();
146
+
const oauthState = generateOAuthState();
147
+
148
+
const dpopKeyPair = await generateDPoPKeyPair();
149
+
await saveDPoPKey(dpopKeyPair);
150
+
151
+
localStorage.setItem("migration_oauth_state", oauthState);
152
+
localStorage.setItem("migration_oauth_code_verifier", codeVerifier);
153
+
localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl);
154
+
localStorage.setItem("migration_source_did", state.sourceDid);
155
+
localStorage.setItem("migration_source_handle", state.sourceHandle);
156
+
localStorage.setItem("migration_oauth_issuer", metadata.issuer);
157
+
158
+
const authUrl = buildOAuthAuthorizationUrl(metadata, {
159
+
clientId: getMigrationOAuthClientId(),
160
+
redirectUri: getMigrationOAuthRedirectUri(),
161
+
codeChallenge,
162
+
state: oauthState,
163
+
scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*",
164
+
dpopJkt: dpopKeyPair.thumbprint,
165
+
loginHint: state.sourceHandle,
166
+
});
167
+
168
+
migrationLog("initiateOAuthLogin: Redirecting to authorization", {
169
+
sourcePdsUrl: state.sourcePdsUrl,
170
+
authEndpoint: metadata.authorization_endpoint,
171
+
dpopJkt: dpopKeyPair.thumbprint,
172
+
});
173
+
174
+
state.oauthCodeVerifier = codeVerifier;
175
+
saveMigrationState(state);
176
+
177
+
globalThis.location.href = authUrl;
178
+
}
179
+
180
+
function cleanupOAuthSessionData(): void {
181
+
localStorage.removeItem("migration_oauth_state");
182
+
localStorage.removeItem("migration_oauth_code_verifier");
183
+
localStorage.removeItem("migration_source_pds_url");
184
+
localStorage.removeItem("migration_source_did");
185
+
localStorage.removeItem("migration_source_handle");
186
+
localStorage.removeItem("migration_oauth_issuer");
187
+
}
188
+
189
+
async function handleOAuthCallback(
190
+
code: string,
191
+
returnedState: string,
192
+
): Promise<void> {
193
+
migrationLog("handleOAuthCallback START");
194
+
195
+
const savedState = localStorage.getItem("migration_oauth_state");
196
+
const codeVerifier = localStorage.getItem("migration_oauth_code_verifier");
197
+
const sourcePdsUrl = localStorage.getItem("migration_source_pds_url");
198
+
const sourceDid = localStorage.getItem("migration_source_did");
199
+
const sourceHandle = localStorage.getItem("migration_source_handle");
200
+
const oauthIssuer = localStorage.getItem("migration_oauth_issuer");
201
+
202
+
if (returnedState !== savedState) {
203
+
cleanupOAuthSessionData();
204
+
throw new Error("OAuth state mismatch - possible CSRF attack");
127
205
}
128
206
207
+
if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) {
208
+
cleanupOAuthSessionData();
209
+
throw new Error("Missing OAuth session data");
210
+
}
211
+
212
+
const dpopKeyPair = await loadDPoPKey();
213
+
if (!dpopKeyPair) {
214
+
cleanupOAuthSessionData();
215
+
throw new Error("Missing DPoP key - please restart the migration");
216
+
}
217
+
218
+
state.sourcePdsUrl = sourcePdsUrl;
219
+
state.sourceDid = sourceDid;
220
+
state.sourceHandle = sourceHandle;
221
+
sourceClient = new AtprotoClient(sourcePdsUrl);
222
+
223
+
let metadata = await getOAuthServerMetadata(sourcePdsUrl);
224
+
if (!metadata && oauthIssuer) {
225
+
metadata = await getOAuthServerMetadata(oauthIssuer);
226
+
}
227
+
if (!metadata) {
228
+
cleanupOAuthSessionData();
229
+
throw new Error("Could not fetch OAuth server metadata");
230
+
}
231
+
sourceOAuthMetadata = metadata;
232
+
233
+
migrationLog("handleOAuthCallback: Exchanging code for tokens");
234
+
235
+
let tokenResponse;
129
236
try {
130
-
migrationLog("loginToSource: Calling createSession on OLD PDS", {
131
-
pdsUrl: state.sourcePdsUrl,
237
+
tokenResponse = await exchangeOAuthCode(metadata, {
238
+
code,
239
+
codeVerifier,
240
+
clientId: getMigrationOAuthClientId(),
241
+
redirectUri: getMigrationOAuthRedirectUri(),
242
+
dpopKeyPair,
132
243
});
133
-
const session = await sourceClient.login(handle, password, twoFactorCode);
134
-
migrationLog("loginToSource SUCCESS", {
135
-
did: session.did,
136
-
handle: session.handle,
137
-
pdsUrl: state.sourcePdsUrl,
138
-
});
139
-
state.sourceAccessToken = session.accessJwt;
140
-
state.sourceRefreshToken = session.refreshJwt;
141
-
state.sourceDid = session.did;
142
-
state.sourceHandle = session.handle;
143
-
state.requires2FA = false;
144
-
saveMigrationState(state);
145
-
} catch (e) {
146
-
const err = e as Error & { error?: string };
147
-
migrationLog("loginToSource FAILED", {
148
-
error: err.message,
149
-
errorCode: err.error,
150
-
});
151
-
if (err.error === "AuthFactorTokenRequired") {
152
-
state.requires2FA = true;
153
-
throw new Error(
154
-
"Two-factor authentication required. Please enter the code sent to your email.",
155
-
);
244
+
} catch (err) {
245
+
cleanupOAuthSessionData();
246
+
throw err;
247
+
}
248
+
249
+
migrationLog("handleOAuthCallback: Got access token");
250
+
251
+
state.sourceAccessToken = tokenResponse.access_token;
252
+
state.sourceRefreshToken = tokenResponse.refresh_token ?? null;
253
+
sourceClient.setAccessToken(tokenResponse.access_token);
254
+
sourceClient.setDPoPKeyPair(dpopKeyPair);
255
+
256
+
cleanupOAuthSessionData();
257
+
258
+
if (state.needsReauth && state.resumeToStep) {
259
+
const targetStep = state.resumeToStep;
260
+
state.needsReauth = false;
261
+
state.resumeToStep = undefined;
262
+
263
+
const postEmailSteps = [
264
+
"plc-token",
265
+
"did-web-update",
266
+
"finalizing",
267
+
"app-password",
268
+
];
269
+
270
+
if (postEmailSteps.includes(targetStep)) {
271
+
if (state.authMethod === "passkey" && state.passkeySetupToken) {
272
+
localClient = createLocalClient();
273
+
setStep("passkey-setup");
274
+
migrationLog("handleOAuthCallback: Resuming passkey flow at passkey-setup");
275
+
} else {
276
+
setStep("email-verify");
277
+
migrationLog("handleOAuthCallback: Resuming at email-verify for re-auth");
278
+
}
279
+
} else {
280
+
setStep(targetStep);
156
281
}
157
-
throw e;
282
+
} else {
283
+
setStep("choose-handle");
158
284
}
285
+
saveMigrationState(state);
159
286
}
160
287
161
288
async function checkHandleAvailability(handle: string): Promise<boolean> {
···
180
307
await localClient.loginDeactivated(email, password);
181
308
}
182
309
310
+
let passkeySetup: PasskeyAccountSetup | null = null;
311
+
183
312
async function startMigration(): Promise<void> {
184
313
migrationLog("startMigration START", {
185
314
sourceDid: state.sourceDid,
186
315
sourceHandle: state.sourceHandle,
187
316
targetHandle: state.targetHandle,
188
317
sourcePdsUrl: state.sourcePdsUrl,
318
+
authMethod: state.authMethod,
189
319
});
190
320
191
321
if (!sourceClient || !state.sourceAccessToken) {
192
-
migrationLog("startMigration ERROR: Not logged in to source PDS");
193
-
throw new Error("Not logged in to source PDS");
322
+
migrationLog("startMigration ERROR: Not authenticated to source PDS");
323
+
throw new Error("Not authenticated to source PDS");
194
324
}
195
325
196
326
if (!localClient) {
···
198
328
}
199
329
200
330
setStep("migrating");
201
-
setProgress({ currentOperation: "Getting service auth token..." });
202
331
203
332
try {
333
+
setProgress({ currentOperation: "Getting service auth token..." });
204
334
migrationLog("startMigration: Loading local server info");
205
335
const serverInfo = await loadLocalServerInfo();
206
336
migrationLog("startMigration: Got server info", {
207
337
serverDid: serverInfo.did,
208
338
});
209
339
210
-
migrationLog("startMigration: Getting service auth token from OLD PDS");
340
+
migrationLog("startMigration: Getting service auth token from source PDS");
211
341
const { token } = await sourceClient.getServiceAuth(
212
342
serverInfo.did,
213
343
"com.atproto.server.createAccount",
···
217
347
218
348
setProgress({ currentOperation: "Creating account on new PDS..." });
219
349
220
-
const accountParams = {
221
-
did: state.sourceDid,
222
-
handle: state.targetHandle,
223
-
email: state.targetEmail,
224
-
password: state.targetPassword,
225
-
inviteCode: state.inviteCode || undefined,
226
-
};
350
+
if (state.authMethod === "passkey") {
351
+
const passkeyParams = {
352
+
did: state.sourceDid,
353
+
handle: state.targetHandle,
354
+
email: state.targetEmail,
355
+
inviteCode: state.inviteCode || undefined,
356
+
};
227
357
228
-
migrationLog("startMigration: Creating account on NEW PDS", {
229
-
did: accountParams.did,
230
-
handle: accountParams.handle,
231
-
});
232
-
const session = await localClient.createAccount(accountParams, token);
233
-
migrationLog("startMigration: Account created on NEW PDS", {
234
-
did: session.did,
235
-
});
236
-
localClient.setAccessToken(session.accessJwt);
358
+
migrationLog("startMigration: Creating passkey account on NEW PDS", {
359
+
did: passkeyParams.did,
360
+
handle: passkeyParams.handle,
361
+
inviteCode: passkeyParams.inviteCode,
362
+
stateInviteCode: state.inviteCode,
363
+
});
364
+
passkeySetup = await localClient.createPasskeyAccount(passkeyParams, token);
365
+
migrationLog("startMigration: Passkey account created on NEW PDS", {
366
+
did: passkeySetup.did,
367
+
hasAccessJwt: !!passkeySetup.accessJwt,
368
+
});
369
+
state.passkeySetupToken = passkeySetup.setupToken;
370
+
if (passkeySetup.accessJwt) {
371
+
localClient.setAccessToken(passkeySetup.accessJwt);
372
+
}
373
+
} else {
374
+
const accountParams = {
375
+
did: state.sourceDid,
376
+
handle: state.targetHandle,
377
+
email: state.targetEmail,
378
+
password: state.targetPassword,
379
+
inviteCode: state.inviteCode || undefined,
380
+
};
381
+
382
+
migrationLog("startMigration: Creating account on NEW PDS", {
383
+
did: accountParams.did,
384
+
handle: accountParams.handle,
385
+
});
386
+
const session = await localClient.createAccount(accountParams, token);
387
+
migrationLog("startMigration: Account created on NEW PDS", {
388
+
did: session.did,
389
+
});
390
+
localClient.setAccessToken(session.accessJwt);
391
+
}
237
392
238
393
setProgress({ currentOperation: "Exporting repository..." });
239
-
migrationLog("startMigration: Exporting repo from OLD PDS");
394
+
migrationLog("startMigration: Exporting repo from source PDS");
240
395
const exportStart = Date.now();
241
396
const car = await sourceClient.getRepo(state.sourceDid);
242
397
migrationLog("startMigration: Repo exported", {
···
320
475
await localClient.uploadBlob(blobData, "application/octet-stream");
321
476
migrated++;
322
477
setProgress({ blobsMigrated: migrated });
323
-
} catch (e) {
478
+
} catch {
324
479
state.progress.blobsFailed.push(blob.cid);
325
480
}
326
481
}
···
336
491
const prefs = await sourceClient.getPreferences();
337
492
await localClient.putPreferences(prefs);
338
493
setProgress({ prefsMigrated: true });
339
-
} catch {
340
-
}
494
+
} catch { /* optional, best-effort */ }
341
495
}
342
496
343
497
async function submitEmailVerifyToken(
···
355
509
await localClient.verifyToken(token, state.targetEmail);
356
510
357
511
if (!sourceClient) {
358
-
setStep("source-login");
512
+
setStep("source-handle");
359
513
setError(
360
514
"Email verified! Please log in to your old account again to complete the migration.",
361
515
);
362
516
return;
363
517
}
364
518
519
+
if (state.authMethod === "passkey") {
520
+
migrationLog(
521
+
"submitEmailVerifyToken: Email verified, proceeding to passkey setup",
522
+
);
523
+
setStep("passkey-setup");
524
+
return;
525
+
}
526
+
365
527
if (localPassword) {
366
528
setProgress({ currentOperation: "Authenticating to new PDS..." });
367
529
await localClient.loginDeactivated(state.targetEmail, localPassword);
···
403
565
if (checkingEmailVerification) return false;
404
566
if (!sourceClient || !localClient) return false;
405
567
568
+
if (state.authMethod === "passkey") {
569
+
return false;
570
+
}
571
+
406
572
checkingEmailVerification = true;
407
573
try {
408
574
await localClient.loginDeactivated(
···
460
626
services: credentials.services,
461
627
});
462
628
463
-
migrationLog("Step 2: Signing PLC operation on OLD PDS", {
629
+
migrationLog("Step 2: Signing PLC operation on source PDS", {
464
630
sourcePdsUrl: state.sourcePdsUrl,
465
631
});
466
632
const signStart = Date.now();
···
497
663
setProgress({ activated: true });
498
664
499
665
setProgress({ currentOperation: "Deactivating old account..." });
500
-
migrationLog("Step 5: Deactivating account on OLD PDS", {
666
+
migrationLog("Step 5: Deactivating account on source PDS", {
501
667
sourcePdsUrl: state.sourcePdsUrl,
502
668
});
503
669
const deactivateStart = Date.now();
504
670
try {
505
671
await sourceClient.deactivateAccount();
506
-
migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", {
672
+
migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", {
507
673
durationMs: Date.now() - deactivateStart,
508
674
success: true,
509
675
});
···
513
679
error?: string;
514
680
status?: number;
515
681
};
516
-
migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", {
682
+
migrationLog("Step 5 FAILED: Could not deactivate on source PDS", {
517
683
durationMs: Date.now() - deactivateStart,
518
684
error: err.message,
519
685
errorCode: err.error,
···
581
747
setProgress({ activated: true });
582
748
583
749
setProgress({ currentOperation: "Deactivating old account..." });
584
-
migrationLog("Deactivating account on OLD PDS");
750
+
migrationLog("Deactivating account on source PDS");
585
751
const deactivateStart = Date.now();
586
752
try {
587
753
await sourceClient.deactivateAccount();
588
-
migrationLog("Account deactivated on OLD PDS", {
754
+
migrationLog("Account deactivated on source PDS", {
589
755
durationMs: Date.now() - deactivateStart,
590
756
});
591
757
setProgress({ deactivated: true });
592
758
} catch (deactivateErr) {
593
759
const err = deactivateErr as Error & { error?: string };
594
-
migrationLog("Could not deactivate on OLD PDS", { error: err.message });
760
+
migrationLog("Could not deactivate on source PDS", { error: err.message });
595
761
}
596
762
597
763
migrationLog("completeDidWebMigration SUCCESS");
···
607
773
}
608
774
}
609
775
776
+
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
777
+
if (!localClient || !state.passkeySetupToken) {
778
+
throw new Error("Not ready for passkey registration");
779
+
}
780
+
781
+
migrationLog("startPasskeyRegistration START", { did: state.sourceDid });
782
+
const result = await localClient.startPasskeyRegistrationForSetup(
783
+
state.sourceDid,
784
+
state.passkeySetupToken,
785
+
);
786
+
migrationLog("startPasskeyRegistration: Got WebAuthn options");
787
+
return result;
788
+
}
789
+
790
+
async function completePasskeyRegistration(
791
+
credential: unknown,
792
+
friendlyName?: string,
793
+
): Promise<void> {
794
+
if (!localClient || !state.passkeySetupToken || !sourceClient) {
795
+
throw new Error("Not ready for passkey registration");
796
+
}
797
+
798
+
migrationLog("completePasskeyRegistration START", { did: state.sourceDid });
799
+
800
+
const result = await localClient.completePasskeySetup(
801
+
state.sourceDid,
802
+
state.passkeySetupToken,
803
+
credential,
804
+
friendlyName,
805
+
);
806
+
migrationLog("completePasskeyRegistration: Passkey registered", {
807
+
appPassword: "***",
808
+
});
809
+
810
+
setProgress({ currentOperation: "Authenticating with app password..." });
811
+
await localClient.loginDeactivated(state.targetEmail, result.appPassword);
812
+
migrationLog("completePasskeyRegistration: Authenticated to new PDS");
813
+
814
+
state.generatedAppPassword = result.appPassword;
815
+
state.generatedAppPasswordName = result.appPasswordName;
816
+
setStep("app-password");
817
+
}
818
+
819
+
async function proceedFromAppPassword(): Promise<void> {
820
+
if (!sourceClient || !localClient) {
821
+
throw new Error("Clients not initialized");
822
+
}
823
+
824
+
migrationLog("proceedFromAppPassword: Starting");
825
+
826
+
if (state.sourceDid.startsWith("did:web:")) {
827
+
const credentials = await localClient.getRecommendedDidCredentials();
828
+
state.targetVerificationMethod =
829
+
credentials.verificationMethods?.atproto || null;
830
+
setStep("did-web-update");
831
+
} else {
832
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
833
+
await sourceClient.requestPlcOperationSignature();
834
+
setStep("plc-token");
835
+
}
836
+
}
837
+
610
838
function reset(): void {
611
839
state = {
612
840
direction: "inbound",
···
625
853
plcToken: "",
626
854
progress: createInitialProgress(),
627
855
error: null,
628
-
requires2FA: false,
629
-
twoFactorCode: "",
630
856
targetVerificationMethod: null,
857
+
authMethod: "password",
858
+
passkeySetupToken: null,
859
+
oauthCodeVerifier: null,
860
+
generatedAppPassword: null,
861
+
generatedAppPasswordName: null,
631
862
};
632
863
sourceClient = null;
864
+
passkeySetup = null;
865
+
sourceOAuthMetadata = null;
633
866
clearMigrationState();
867
+
clearDPoPKey();
634
868
}
635
869
636
870
async function resumeFromState(stored: StoredMigrationState): Promise<void> {
···
641
875
state.sourceHandle = stored.sourceHandle;
642
876
state.targetHandle = stored.targetHandle;
643
877
state.targetEmail = stored.targetEmail;
878
+
state.authMethod = stored.authMethod ?? "password";
644
879
state.progress = {
645
880
...createInitialProgress(),
646
881
...stored.progress,
647
882
};
648
883
649
-
state.step = "source-login";
884
+
const stepsRequiringSourceAuth = [
885
+
"choose-handle",
886
+
"review",
887
+
"migrating",
888
+
"email-verify",
889
+
"plc-token",
890
+
"did-web-update",
891
+
"finalizing",
892
+
"app-password",
893
+
];
894
+
895
+
if (stepsRequiringSourceAuth.includes(stored.step)) {
896
+
state.step = "source-handle";
897
+
state.needsReauth = true;
898
+
state.resumeToStep = stored.step as InboundMigrationState["step"];
899
+
migrationLog("resumeFromState: Requiring re-auth for step", {
900
+
originalStep: stored.step,
901
+
});
902
+
} else if (stored.step === "passkey-setup" && stored.passkeySetupToken) {
903
+
state.passkeySetupToken = stored.passkeySetupToken;
904
+
localClient = createLocalClient();
905
+
state.step = "passkey-setup";
906
+
migrationLog("resumeFromState: Restored passkey-setup with token");
907
+
} else if (stored.step === "success") {
908
+
state.step = "success";
909
+
} else if (stored.step === "error") {
910
+
state.step = "source-handle";
911
+
state.needsReauth = true;
912
+
migrationLog("resumeFromState: Error state, requiring re-auth");
913
+
} else {
914
+
state.step = stored.step as InboundMigrationState["step"];
915
+
}
650
916
}
651
917
652
918
function getLocalSession():
···
666
932
get state() {
667
933
return state;
668
934
},
935
+
get passkeySetup() {
936
+
return passkeySetup;
937
+
},
669
938
setStep,
670
939
setError,
671
940
loadLocalServerInfo,
672
-
loginToSource,
941
+
resolveSourcePds,
942
+
initiateOAuthLogin,
943
+
handleOAuthCallback,
673
944
authenticateToLocal,
674
945
checkHandleAvailability,
675
946
startMigration,
···
680
951
submitPlcToken,
681
952
resendPlcToken,
682
953
completeDidWebMigration,
954
+
startPasskeyRegistration,
955
+
completePasskeyRegistration,
956
+
proceedFromAppPassword,
683
957
reset,
684
958
resumeFromState,
685
959
getLocalSession,
···
856
1130
await targetClient.uploadBlob(blobData, "application/octet-stream");
857
1131
migrated++;
858
1132
setProgress({ blobsMigrated: migrated });
859
-
} catch (e) {
1133
+
} catch {
860
1134
state.progress.blobsFailed.push(blob.cid);
861
1135
}
862
1136
}
···
872
1146
const prefs = await localClient.getPreferences();
873
1147
await targetClient.putPreferences(prefs);
874
1148
setProgress({ prefsMigrated: true });
875
-
} catch {
876
-
}
1149
+
} catch { /* optional, best-effort */ }
877
1150
}
878
1151
879
1152
async function submitPlcToken(token: string): Promise<void> {
···
908
1181
try {
909
1182
await localClient.deactivateAccount(state.targetPdsUrl);
910
1183
setProgress({ deactivated: true });
911
-
} catch {
912
-
}
1184
+
} catch { /* optional, best-effort */ }
913
1185
914
1186
setStep("success");
915
1187
clearMigrationState();
+28
-19
frontend/src/lib/migration/storage.ts
+28
-19
frontend/src/lib/migration/storage.ts
···
3
3
MigrationState,
4
4
StoredMigrationState,
5
5
} from "./types";
6
+
import { clearDPoPKey } from "./atproto-client";
6
7
7
8
const STORAGE_KEY = "tranquil_migration_state";
8
9
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
···
15
16
startedAt: new Date().toISOString(),
16
17
sourcePdsUrl: state.direction === "inbound"
17
18
? state.sourcePdsUrl
18
-
: window.location.origin,
19
+
: globalThis.location.origin,
19
20
targetPdsUrl: state.direction === "inbound"
20
-
? window.location.origin
21
+
? globalThis.location.origin
21
22
: state.targetPdsUrl,
22
23
sourceDid: state.direction === "inbound" ? state.sourceDid : "",
23
24
sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
24
25
targetHandle: state.targetHandle,
25
26
targetEmail: state.targetEmail,
27
+
authMethod: state.direction === "inbound" ? state.authMethod : undefined,
28
+
passkeySetupToken: state.direction === "inbound"
29
+
? state.passkeySetupToken ?? undefined
30
+
: undefined,
26
31
progress: {
27
32
repoExported: state.progress.repoExported,
28
33
repoImported: state.progress.repoImported,
···
36
41
};
37
42
38
43
try {
39
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
40
-
} catch {
41
-
}
44
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
45
+
} catch { /* localStorage unavailable */ }
42
46
}
43
47
44
48
export function loadMigrationState(): StoredMigrationState | null {
45
49
try {
46
-
const stored = sessionStorage.getItem(STORAGE_KEY);
50
+
const stored = localStorage.getItem(STORAGE_KEY);
47
51
if (!stored) return null;
48
52
49
53
const state = JSON.parse(stored) as StoredMigrationState;
50
54
51
-
if (state.version !== 1) return null;
55
+
if (state.version !== 1) {
56
+
clearMigrationState();
57
+
return null;
58
+
}
52
59
53
60
const startedAt = new Date(state.startedAt).getTime();
54
61
if (Date.now() - startedAt > MAX_AGE_MS) {
···
58
65
59
66
return state;
60
67
} catch {
68
+
clearMigrationState();
61
69
return null;
62
70
}
63
71
}
64
72
65
73
export function clearMigrationState(): void {
66
74
try {
67
-
sessionStorage.removeItem(STORAGE_KEY);
68
-
} catch {
69
-
}
75
+
localStorage.removeItem(STORAGE_KEY);
76
+
clearDPoPKey();
77
+
} catch { /* localStorage unavailable */ }
70
78
}
71
79
72
80
export function hasPendingMigration(): boolean {
···
79
87
targetHandle: string;
80
88
sourcePdsUrl: string;
81
89
targetPdsUrl: string;
90
+
targetEmail: string;
91
+
authMethod?: "password" | "passkey";
82
92
progressSummary: string;
83
93
step: string;
84
94
} | null {
···
102
112
targetHandle: state.targetHandle,
103
113
sourcePdsUrl: state.sourcePdsUrl,
104
114
targetPdsUrl: state.targetPdsUrl,
115
+
targetEmail: state.targetEmail,
116
+
authMethod: state.authMethod,
105
117
progressSummary: progressParts.length > 0
106
118
? progressParts.join(", ")
107
119
: "just started",
···
117
129
118
130
state.progress = { ...state.progress, ...updates };
119
131
try {
120
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
121
-
} catch {
122
-
}
132
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
133
+
} catch { /* localStorage unavailable */ }
123
134
}
124
135
125
136
export function updateStep(step: string): void {
···
128
139
129
140
state.step = step;
130
141
try {
131
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
132
-
} catch {
133
-
}
142
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
143
+
} catch { /* localStorage unavailable */ }
134
144
}
135
145
136
146
export function setError(error: string, step: string): void {
···
140
150
state.lastError = error;
141
151
state.lastErrorStep = step;
142
152
try {
143
-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
144
-
} catch {
145
-
}
153
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
154
+
} catch { /* localStorage unavailable */ }
146
155
}
+69
-3
frontend/src/lib/migration/types.ts
+69
-3
frontend/src/lib/migration/types.ts
···
1
1
export type InboundStep =
2
2
| "welcome"
3
-
| "source-login"
3
+
| "source-handle"
4
4
| "choose-handle"
5
5
| "review"
6
6
| "migrating"
7
+
| "passkey-setup"
8
+
| "app-password"
7
9
| "email-verify"
8
10
| "plc-token"
9
11
| "did-web-update"
10
12
| "finalizing"
11
13
| "success"
12
14
| "error";
15
+
16
+
export type AuthMethod = "password" | "passkey";
13
17
14
18
export type OutboundStep =
15
19
| "welcome"
···
54
58
plcToken: string;
55
59
progress: MigrationProgress;
56
60
error: string | null;
57
-
requires2FA: boolean;
58
-
twoFactorCode: string;
59
61
targetVerificationMethod: string | null;
62
+
authMethod: AuthMethod;
63
+
passkeySetupToken: string | null;
64
+
oauthCodeVerifier: string | null;
65
+
generatedAppPassword: string | null;
66
+
generatedAppPasswordName: string | null;
67
+
needsReauth?: boolean;
68
+
resumeToStep?: InboundStep;
60
69
}
61
70
62
71
export interface OutboundMigrationState {
···
92
101
sourceHandle: string;
93
102
targetHandle: string;
94
103
targetEmail: string;
104
+
authMethod?: AuthMethod;
105
+
passkeySetupToken?: string;
95
106
progress: {
96
107
repoExported: boolean;
97
108
repoImported: boolean;
···
199
210
recoveryKey?: string;
200
211
}
201
212
213
+
export interface CreatePasskeyAccountParams {
214
+
did?: string;
215
+
handle: string;
216
+
email: string;
217
+
inviteCode?: string;
218
+
}
219
+
220
+
export interface PasskeyAccountSetup {
221
+
setupToken: string;
222
+
did: string;
223
+
handle: string;
224
+
setupExpiresAt: string;
225
+
accessJwt?: string;
226
+
}
227
+
228
+
export interface CompletePasskeySetupResponse {
229
+
did: string;
230
+
handle: string;
231
+
appPassword: string;
232
+
appPasswordName: string;
233
+
}
234
+
235
+
export interface StartPasskeyRegistrationResponse {
236
+
options: unknown;
237
+
}
238
+
239
+
export interface OAuthServerMetadata {
240
+
issuer: string;
241
+
authorization_endpoint: string;
242
+
token_endpoint: string;
243
+
scopes_supported?: string[];
244
+
response_types_supported?: string[];
245
+
grant_types_supported?: string[];
246
+
code_challenge_methods_supported?: string[];
247
+
dpop_signing_alg_values_supported?: string[];
248
+
}
249
+
250
+
export interface OAuthTokenResponse {
251
+
access_token: string;
252
+
token_type: string;
253
+
expires_in?: number;
254
+
refresh_token?: string;
255
+
scope?: string;
256
+
}
257
+
202
258
export interface Preferences {
203
259
preferences: unknown[];
204
260
}
···
214
270
this.name = "MigrationError";
215
271
}
216
272
}
273
+
274
+
export function getErrorMessage(err: unknown): string {
275
+
if (err instanceof Error) {
276
+
return err.message;
277
+
}
278
+
if (typeof err === "string") {
279
+
return err;
280
+
}
281
+
return String(err);
282
+
}
+11
-7
frontend/src/lib/oauth.ts
+11
-7
frontend/src/lib/oauth.ts
···
8
8
"blob:*/*",
9
9
].join(" ");
10
10
const CLIENT_ID = !(import.meta.env.DEV)
11
-
? `${window.location.origin}/oauth/client-metadata.json`
11
+
? `${globalThis.location.origin}/oauth/client-metadata.json`
12
12
: `http://localhost/?scope=${SCOPES}`;
13
-
const REDIRECT_URI = `${window.location.origin}/`;
13
+
const REDIRECT_URI = `${globalThis.location.origin}/`;
14
14
15
15
interface OAuthState {
16
16
state: string;
···
106
106
107
107
const { request_uri } = await parResponse.json();
108
108
109
-
const authorizeUrl = new URL("/oauth/authorize", window.location.origin);
109
+
const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin);
110
110
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
111
111
authorizeUrl.searchParams.set("request_uri", request_uri);
112
112
113
-
window.location.href = authorizeUrl.toString();
113
+
globalThis.location.href = authorizeUrl.toString();
114
114
}
115
115
116
116
export interface OAuthTokens {
···
191
191
export function checkForOAuthCallback():
192
192
| { code: string; state: string }
193
193
| null {
194
-
const params = new URLSearchParams(window.location.search);
194
+
if (globalThis.location.hash === "#/migrate") {
195
+
return null;
196
+
}
197
+
198
+
const params = new URLSearchParams(globalThis.location.search);
195
199
const code = params.get("code");
196
200
const state = params.get("state");
197
201
···
203
207
}
204
208
205
209
export function clearOAuthCallbackParams(): void {
206
-
const url = new URL(window.location.href);
210
+
const url = new URL(globalThis.location.href);
207
211
url.search = "";
208
-
window.history.replaceState({}, "", url.toString());
212
+
globalThis.history.replaceState({}, "", url.toString());
209
213
}
+1
-1
frontend/src/lib/registration/flow.svelte.ts
+1
-1
frontend/src/lib/registration/flow.svelte.ts
···
104
104
state.externalDidWeb.reservedSigningKey = result.signingKey;
105
105
publicKeyMultibase = result.signingKey.replace("did:key:", "");
106
106
} else {
107
-
const keypair = await generateKeypair();
107
+
const keypair = generateKeypair();
108
108
state.externalDidWeb.byodPrivateKey = keypair.privateKey;
109
109
state.externalDidWeb.byodPublicKeyMultibase =
110
110
keypair.publicKeyMultibase;
+4
-4
frontend/src/lib/router.svelte.ts
+4
-4
frontend/src/lib/router.svelte.ts
···
1
1
let currentPath = $state(
2
-
getPathWithoutQuery(window.location.hash.slice(1) || "/"),
2
+
getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"),
3
3
);
4
4
5
5
function getPathWithoutQuery(hash: string): string {
···
7
7
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
8
8
}
9
9
10
-
window.addEventListener("hashchange", () => {
11
-
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/");
10
+
globalThis.addEventListener("hashchange", () => {
11
+
currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/");
12
12
});
13
13
14
14
export function navigate(path: string) {
15
15
currentPath = path;
16
-
window.location.hash = path;
16
+
globalThis.location.hash = path;
17
17
}
18
18
19
19
export function getCurrentPath() {
+1
-1
frontend/src/lib/serverConfig.svelte.ts
+1
-1
frontend/src/lib/serverConfig.svelte.ts
+106
-32
frontend/src/locales/en.json
+106
-32
frontend/src/locales/en.json
···
902
902
"reauth": {
903
903
"title": "Re-authentication Required",
904
904
"subtitle": "Please verify your identity to continue.",
905
+
"password": "Password",
906
+
"totp": "TOTP",
907
+
"passkey": "Passkey",
908
+
"authenticatorCode": "Authenticator Code",
905
909
"usePassword": "Use Password",
906
910
"usePasskey": "Use Passkey",
907
911
"useTotp": "Use Authenticator",
···
909
913
"totpPlaceholder": "Enter 6-digit code",
910
914
"verify": "Verify",
911
915
"verifying": "Verifying...",
916
+
"authenticating": "Authenticating...",
917
+
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
912
918
"cancel": "Cancel"
913
919
},
914
920
"delegation": {
···
1071
1077
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1072
1078
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1073
1079
"learnMore": "Learn more about migration risks",
1080
+
"comingSoon": "Coming soon",
1081
+
"oauthCompleting": "Completing authentication...",
1082
+
"oauthFailed": "Authentication Failed",
1083
+
"tryAgain": "Try Again",
1074
1084
"resume": {
1075
1085
"title": "Resume Migration?",
1076
1086
"incomplete": "You have an incomplete migration in progress:",
···
1090
1100
"desc": "Move your existing AT Protocol account to this server.",
1091
1101
"understand": "I understand the risks and want to proceed"
1092
1102
},
1093
-
"sourceLogin": {
1094
-
"title": "Sign In to Your Current PDS",
1095
-
"desc": "Enter your credentials for the account you want to migrate.",
1103
+
"sourceAuth": {
1104
+
"title": "Enter Your Current Handle",
1105
+
"titleResume": "Resume Migration",
1106
+
"desc": "Enter the handle of the account you want to migrate.",
1107
+
"descResume": "Re-authenticate to your source PDS to continue the migration.",
1096
1108
"handle": "Handle",
1097
-
"handlePlaceholder": "you.bsky.social",
1098
-
"password": "Password",
1099
-
"twoFactorCode": "Two-Factor Code",
1100
-
"twoFactorRequired": "Two-factor authentication required",
1101
-
"signIn": "Sign In & Continue"
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "Your current handle on your existing PDS",
1111
+
"continue": "Continue",
1112
+
"connecting": "Connecting...",
1113
+
"reauthenticate": "Re-authenticate",
1114
+
"resumeTitle": "Migration in Progress",
1115
+
"resumeFrom": "From",
1116
+
"resumeTo": "To",
1117
+
"resumeProgress": "Progress",
1118
+
"resumeOAuthNote": "You need to re-authenticate via OAuth to continue."
1102
1119
},
1103
1120
"chooseHandle": {
1104
1121
"title": "Choose Your New Handle",
1105
1122
"desc": "Select a handle for your account on this PDS.",
1106
-
"handleHint": "Your full handle will be: @{handle}"
1123
+
"migratingFrom": "Migrating from",
1124
+
"newHandle": "New Handle",
1125
+
"checkingAvailability": "Checking availability...",
1126
+
"handleAvailable": "Handle is available!",
1127
+
"handleTaken": "Handle is already taken",
1128
+
"handleHint": "You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)",
1129
+
"email": "Email Address",
1130
+
"authMethod": "Authentication Method",
1131
+
"authPassword": "Password",
1132
+
"authPasswordDesc": "Traditional password-based login",
1133
+
"authPasskey": "Passkey",
1134
+
"authPasskeyDesc": "Passwordless login using biometrics or security key",
1135
+
"password": "Password",
1136
+
"passwordHint": "At least 8 characters",
1137
+
"passkeyInfo": "You'll set up a passkey after your account is created. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.",
1138
+
"inviteCode": "Invite Code"
1107
1139
},
1108
1140
"review": {
1109
1141
"title": "Review Migration",
1110
-
"desc": "Please review and confirm your migration details.",
1142
+
"desc": "Please confirm the details of your migration.",
1111
1143
"currentHandle": "Current Handle",
1112
1144
"newHandle": "New Handle",
1113
-
"sourcePds": "Source PDS",
1114
-
"targetPds": "This PDS",
1145
+
"did": "DID",
1146
+
"sourcePds": "From PDS",
1147
+
"targetPds": "To PDS",
1115
1148
"email": "Email",
1149
+
"authentication": "Authentication",
1150
+
"authPasskey": "Passkey (passwordless)",
1151
+
"authPassword": "Password",
1116
1152
"inviteCode": "Invite Code",
1117
-
"confirm": "I confirm I want to migrate my account",
1118
-
"startMigration": "Start Migration"
1153
+
"warning": "After you click \"Start Migration\", your repository and data will begin transferring. This process cannot be easily undone.",
1154
+
"startMigration": "Start Migration",
1155
+
"starting": "Starting..."
1119
1156
},
1120
1157
"migrating": {
1121
-
"title": "Migrating Your Account",
1122
-
"desc": "Please wait while we transfer your data...",
1123
-
"gettingServiceAuth": "Getting service authorization...",
1124
-
"creatingAccount": "Creating account on new PDS...",
1125
-
"exportingRepo": "Exporting repository...",
1126
-
"importingRepo": "Importing repository...",
1127
-
"countingBlobs": "Counting blobs...",
1128
-
"migratingBlobs": "Migrating blobs ({current}/{total})...",
1129
-
"migratingPrefs": "Migrating preferences...",
1130
-
"requestingPlc": "Requesting PLC operation..."
1158
+
"title": "Migration in Progress",
1159
+
"desc": "Please wait while your account is being transferred...",
1160
+
"exportRepo": "Export repository",
1161
+
"importRepo": "Import repository",
1162
+
"migrateBlobs": "Migrate blobs",
1163
+
"migratePrefs": "Migrate preferences"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Set Up Your Passkey",
1167
+
"desc": "Your email has been verified. Now set up your passkey for secure, passwordless login.",
1168
+
"nameLabel": "Passkey Name (optional)",
1169
+
"namePlaceholder": "e.g., MacBook Pro, iPhone",
1170
+
"nameHint": "A friendly name to identify this passkey",
1171
+
"instructions": "Click the button below to register your passkey. Your device will prompt you to use biometrics (fingerprint, Face ID) or a security key.",
1172
+
"register": "Register Passkey",
1173
+
"registering": "Registering..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Save Your App Password",
1177
+
"desc": "Your passkey has been created. An app password has been generated for you to use with apps that don't support passkeys yet.",
1178
+
"warning": "This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.",
1179
+
"label": "App Password for",
1180
+
"saved": "I have saved my app password in a secure location",
1181
+
"continue": "Continue"
1131
1182
},
1132
1183
"emailVerify": {
1133
1184
"title": "Verify Your Email",
···
1140
1191
"verifying": "Verifying..."
1141
1192
},
1142
1193
"plcToken": {
1143
-
"title": "Verify Your Identity",
1144
-
"desc": "A verification code has been sent to your email on your current PDS.",
1145
-
"tokenLabel": "Verification Token",
1146
-
"tokenPlaceholder": "Enter the token from your email",
1147
-
"resend": "Resend Token",
1148
-
"resending": "Resending..."
1194
+
"title": "Verify Migration",
1195
+
"desc": "A verification code has been sent to the email registered with your old account.",
1196
+
"info": "This code confirms you have access to the account and authorizes updating your identity to point to this PDS.",
1197
+
"tokenLabel": "Verification Code",
1198
+
"tokenPlaceholder": "Enter code from email",
1199
+
"resend": "Resend Code",
1200
+
"complete": "Complete Migration",
1201
+
"completing": "Verifying..."
1149
1202
},
1150
1203
"didWebUpdate": {
1151
1204
"title": "Update Your DID Document",
···
1168
1221
"success": {
1169
1222
"title": "Migration Complete!",
1170
1223
"desc": "Your account has been successfully migrated to this PDS.",
1171
-
"newHandle": "New Handle",
1224
+
"yourNewHandle": "Your new handle",
1172
1225
"did": "DID",
1173
-
"goToDashboard": "Go to Dashboard"
1226
+
"blobsWarning": "{count} blobs could not be migrated. These may be images or other media that are no longer available.",
1227
+
"redirecting": "Redirecting to dashboard..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Migration Error",
1231
+
"desc": "An error occurred during migration.",
1232
+
"startOver": "Start Over"
1233
+
},
1234
+
"common": {
1235
+
"back": "Back",
1236
+
"cancel": "Cancel",
1237
+
"continue": "Continue",
1238
+
"whatWillHappen": "What will happen:",
1239
+
"step1": "Log in to your current PDS",
1240
+
"step2": "Choose your new handle on this server",
1241
+
"step3": "Your repository and blobs will be transferred",
1242
+
"step4": "Verify the migration via email",
1243
+
"step5": "Your identity will be updated to point here",
1244
+
"beforeProceed": "Before you proceed:",
1245
+
"warning1": "You need access to the email registered with your current account",
1246
+
"warning2": "Large accounts may take several minutes to transfer",
1247
+
"warning3": "Your old account will be deactivated after migration"
1174
1248
}
1175
1249
},
1176
1250
"outbound": {
+103
-29
frontend/src/locales/fi.json
+103
-29
frontend/src/locales/fi.json
···
902
902
"reauth": {
903
903
"title": "Uudelleentodennus vaaditaan",
904
904
"subtitle": "Vahvista henkilöllisyytesi jatkaaksesi.",
905
+
"password": "Salasana",
906
+
"totp": "TOTP",
907
+
"passkey": "Pääsyavain",
908
+
"authenticatorCode": "Todentajan koodi",
905
909
"usePassword": "Käytä salasanaa",
906
910
"usePasskey": "Käytä pääsyavainta",
907
911
"useTotp": "Käytä todentajaa",
···
909
913
"totpPlaceholder": "Syötä 6-numeroinen koodi",
910
914
"verify": "Vahvista",
911
915
"verifying": "Vahvistetaan...",
916
+
"authenticating": "Todennetaan...",
917
+
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
912
918
"cancel": "Peruuta"
913
919
},
914
920
"verifyChannel": {
···
1071
1077
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1072
1078
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1073
1079
"learnMore": "Lue lisää siirron riskeistä",
1080
+
"comingSoon": "Tulossa pian",
1081
+
"oauthCompleting": "Viimeistellään todennusta...",
1082
+
"oauthFailed": "Todennus epäonnistui",
1083
+
"tryAgain": "Yritä uudelleen",
1074
1084
"resume": {
1075
1085
"title": "Jatka siirtoa?",
1076
1086
"incomplete": "Sinulla on keskeneräinen siirto:",
···
1090
1100
"desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.",
1091
1101
"understand": "Ymmärrän riskit ja haluan jatkaa"
1092
1102
},
1093
-
"sourceLogin": {
1094
-
"title": "Kirjaudu nykyiseen PDS:ääsi",
1095
-
"desc": "Syötä siirrettävän tilin tunnukset.",
1103
+
"sourceAuth": {
1104
+
"title": "Syötä nykyinen käyttäjätunnuksesi",
1105
+
"titleResume": "Jatka siirtoa",
1106
+
"desc": "Syötä siirrettävän tilin käyttäjätunnus.",
1107
+
"descResume": "Tunnistaudu uudelleen lähde-PDS:ään jatkaaksesi siirtoa.",
1096
1108
"handle": "Käyttäjätunnus",
1097
-
"handlePlaceholder": "sinä.bsky.social",
1098
-
"password": "Salasana",
1099
-
"twoFactorCode": "Kaksivaiheinen koodi",
1100
-
"twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
1101
-
"signIn": "Kirjaudu ja jatka"
1109
+
"handlePlaceholder": "maija.bsky.social",
1110
+
"handleHint": "Nykyinen käyttäjätunnuksesi nykyisessä PDS:ssäsi",
1111
+
"continue": "Jatka",
1112
+
"connecting": "Yhdistetään...",
1113
+
"reauthenticate": "Tunnistaudu uudelleen",
1114
+
"resumeTitle": "Siirto käynnissä",
1115
+
"resumeFrom": "Mistä",
1116
+
"resumeTo": "Minne",
1117
+
"resumeProgress": "Edistyminen",
1118
+
"resumeOAuthNote": "Sinun täytyy tunnistautua uudelleen OAuth:n kautta jatkaaksesi."
1102
1119
},
1103
1120
"chooseHandle": {
1104
1121
"title": "Valitse uusi käyttäjätunnuksesi",
1105
1122
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
1106
-
"handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}"
1123
+
"migratingFrom": "Siirretään tililtä",
1124
+
"newHandle": "Uusi käyttäjätunnus",
1125
+
"checkingAvailability": "Tarkistetaan saatavuutta...",
1126
+
"handleAvailable": "Käyttäjätunnus on saatavilla!",
1127
+
"handleTaken": "Käyttäjätunnus on jo varattu",
1128
+
"handleHint": "Voit myös käyttää omaa verkkotunnustasi syöttämällä täydellisen käyttäjätunnuksen (esim. maija.omadomain.fi)",
1129
+
"email": "Sähköpostiosoite",
1130
+
"authMethod": "Tunnistautumistapa",
1131
+
"authPassword": "Salasana",
1132
+
"authPasswordDesc": "Perinteinen salasanapohjainen kirjautuminen",
1133
+
"authPasskey": "Pääsyavain",
1134
+
"authPasskeyDesc": "Salasanaton kirjautuminen biometriikalla tai suojausavaimella",
1135
+
"password": "Salasana",
1136
+
"passwordHint": "Vähintään 8 merkkiä",
1137
+
"passkeyInfo": "Määrität pääsyavaimen tilisi luomisen jälkeen. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.",
1138
+
"inviteCode": "Kutsukoodi"
1107
1139
},
1108
1140
"review": {
1109
1141
"title": "Tarkista siirto",
1110
-
"desc": "Tarkista ja vahvista siirtotietosi.",
1142
+
"desc": "Vahvista siirtosi tiedot.",
1111
1143
"currentHandle": "Nykyinen käyttäjätunnus",
1112
1144
"newHandle": "Uusi käyttäjätunnus",
1145
+
"did": "DID",
1113
1146
"sourcePds": "Lähde-PDS",
1114
-
"targetPds": "Tämä PDS",
1147
+
"targetPds": "Kohde-PDS",
1115
1148
"email": "Sähköposti",
1149
+
"authentication": "Tunnistautuminen",
1150
+
"authPasskey": "Pääsyavain (salasanaton)",
1151
+
"authPassword": "Salasana",
1116
1152
"inviteCode": "Kutsukoodi",
1117
-
"confirm": "Vahvistan haluavani siirtää tilini",
1118
-
"startMigration": "Aloita siirto"
1153
+
"warning": "Kun klikkaat \"Aloita siirto\", tietovarastosi ja datasi alkavat siirtyä. Tätä prosessia ei voi helposti peruuttaa.",
1154
+
"startMigration": "Aloita siirto",
1155
+
"starting": "Aloitetaan..."
1119
1156
},
1120
1157
"migrating": {
1121
-
"title": "Siirretään tiliäsi",
1122
-
"desc": "Odota, kun siirrämme tietojasi...",
1123
-
"gettingServiceAuth": "Haetaan palveluvaltuutusta...",
1124
-
"creatingAccount": "Luodaan tiliä uuteen PDS:ään...",
1125
-
"exportingRepo": "Viedään tietovarastoa...",
1126
-
"importingRepo": "Tuodaan tietovarastoa...",
1127
-
"countingBlobs": "Lasketaan blob-tiedostoja...",
1128
-
"migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...",
1129
-
"migratingPrefs": "Siirretään asetuksia...",
1130
-
"requestingPlc": "Pyydetään PLC-toimintoa..."
1158
+
"title": "Siirto käynnissä",
1159
+
"desc": "Odota, kun tiliäsi siirretään...",
1160
+
"exportRepo": "Vie tietovarasto",
1161
+
"importRepo": "Tuo tietovarasto",
1162
+
"migrateBlobs": "Siirrä blob-tiedostot",
1163
+
"migratePrefs": "Siirrä asetukset"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Määritä pääsyavaimesi",
1167
+
"desc": "Sähköpostisi on vahvistettu. Määritä nyt pääsyavaimesi turvallista, salasanatonta kirjautumista varten.",
1168
+
"nameLabel": "Pääsyavaimen nimi (valinnainen)",
1169
+
"namePlaceholder": "esim. MacBook Pro, iPhone",
1170
+
"nameHint": "Kutsumanimi tämän pääsyavaimen tunnistamiseen",
1171
+
"instructions": "Klikkaa alla olevaa painiketta rekisteröidäksesi pääsyavaimesi. Laitteesi pyytää käyttämään biometriikkaa (sormenjälki, Face ID) tai suojausavainta.",
1172
+
"register": "Rekisteröi pääsyavain",
1173
+
"registering": "Rekisteröidään..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Tallenna sovellussalasanasi",
1177
+
"desc": "Pääsyavaimesi on luotu. Sovellussalasana on luotu sinulle käytettäväksi sovellusten kanssa, jotka eivät vielä tue pääsyavaimia.",
1178
+
"warning": "Tämä sovellussalasana vaaditaan kirjautumiseen sovelluksissa, jotka eivät vielä tue pääsyavaimia (kuten bsky.app). Näet tämän salasanan vain kerran.",
1179
+
"label": "Sovellussalasana kohteelle",
1180
+
"saved": "Olen tallentanut sovellussalasanani turvalliseen paikkaan",
1181
+
"continue": "Jatka"
1131
1182
},
1132
1183
"emailVerify": {
1133
1184
"title": "Vahvista sähköpostisi",
···
1140
1191
"verifying": "Vahvistetaan..."
1141
1192
},
1142
1193
"plcToken": {
1143
-
"title": "Vahvista henkilöllisyytesi",
1144
-
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.",
1194
+
"title": "Vahvista siirto",
1195
+
"desc": "Vahvistuskoodi on lähetetty vanhaan tiliisi rekisteröityyn sähköpostiin.",
1196
+
"info": "Tämä koodi vahvistaa, että sinulla on pääsy tiliin ja valtuuttaa identiteettisi päivityksen osoittamaan tähän PDS:ään.",
1145
1197
"tokenLabel": "Vahvistuskoodi",
1146
1198
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
1147
-
"resend": "Lähetä uudelleen",
1148
-
"resending": "Lähetetään..."
1199
+
"resend": "Lähetä koodi uudelleen",
1200
+
"complete": "Viimeistele siirto",
1201
+
"completing": "Vahvistetaan..."
1149
1202
},
1150
1203
"didWebUpdate": {
1151
1204
"title": "Päivitä DID-dokumenttisi",
···
1168
1221
"success": {
1169
1222
"title": "Siirto valmis!",
1170
1223
"desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.",
1171
-
"newHandle": "Uusi käyttäjätunnus",
1224
+
"yourNewHandle": "Uusi käyttäjätunnuksesi",
1172
1225
"did": "DID",
1173
-
"goToDashboard": "Siirry hallintapaneeliin"
1226
+
"blobsWarning": "{count} blob-tiedostoa ei voitu siirtää. Nämä voivat olla kuvia tai muuta mediaa, jotka eivät ole enää saatavilla.",
1227
+
"redirecting": "Uudelleenohjataan hallintapaneeliin..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Siirtovirhe",
1231
+
"desc": "Siirron aikana tapahtui virhe.",
1232
+
"startOver": "Aloita alusta"
1233
+
},
1234
+
"common": {
1235
+
"back": "Takaisin",
1236
+
"cancel": "Peruuta",
1237
+
"continue": "Jatka",
1238
+
"whatWillHappen": "Mitä tapahtuu:",
1239
+
"step1": "Kirjaudu nykyiseen PDS:ääsi",
1240
+
"step2": "Valitse uusi käyttäjätunnus tällä palvelimella",
1241
+
"step3": "Tietovarastosi ja blob-tiedostosi siirretään",
1242
+
"step4": "Vahvista siirto sähköpostilla",
1243
+
"step5": "Identiteettisi päivitetään osoittamaan tänne",
1244
+
"beforeProceed": "Ennen kuin jatkat:",
1245
+
"warning1": "Tarvitset pääsyn nykyiseen tiliisi rekisteröityyn sähköpostiin",
1246
+
"warning2": "Suurten tilien siirto voi kestää useita minuutteja",
1247
+
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
1174
1248
}
1175
1249
},
1176
1250
"outbound": {
+117
-31
frontend/src/locales/ja.json
+117
-31
frontend/src/locales/ja.json
···
189
189
"title": "DID ドキュメントエディター",
190
190
"preview": "現在の DID ドキュメント",
191
191
"verificationMethods": "検証方法(署名キー)",
192
+
"verificationMethodsDesc": "DIDの代わりに動作できる署名キー。新しいPDSに移行する際は、そのPDSの署名キーをここに追加してください。",
192
193
"addKey": "キーを追加",
193
194
"removeKey": "削除",
194
195
"keyId": "キー ID",
195
196
"keyIdPlaceholder": "#atproto",
196
197
"publicKey": "公開キー(Multibase)",
197
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "検証方法が設定されていません。ローカルPDSキーを使用しています。",
198
200
"alsoKnownAs": "別名(ハンドル)",
201
+
"alsoKnownAsDesc": "DIDを指すハンドル。新しいPDSでハンドルが変更されたら更新してください。",
199
202
"addHandle": "ハンドルを追加",
203
+
"removeHandle": "削除",
204
+
"handle": "ハンドル",
200
205
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "サービスエンドポイント(現在の PDS)",
206
+
"noHandles": "ハンドルが設定されていません。ローカルハンドルを使用しています。",
207
+
"serviceEndpoint": "サービスエンドポイント",
208
+
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
209
+
"currentPds": "現在のPDS URL",
202
210
"save": "変更を保存",
203
211
"saving": "保存中...",
204
212
"success": "DID ドキュメントを更新しました",
213
+
"saveFailed": "DIDドキュメントの保存に失敗しました",
214
+
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
215
+
"invalidMultibase": "公開キーは'z'で始まる有効なmultibase文字列である必要があります",
216
+
"invalidHandle": "ハンドルはat:// URIである必要があります(例:at://handle.example.com)",
205
217
"helpTitle": "これは何ですか?",
206
218
"helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。"
207
219
},
···
890
902
"reauth": {
891
903
"title": "再認証が必要です",
892
904
"subtitle": "続行するには本人確認を行ってください。",
905
+
"password": "パスワード",
906
+
"totp": "TOTP",
907
+
"passkey": "パスキー",
908
+
"authenticatorCode": "認証コード",
893
909
"usePassword": "パスワードを使用",
894
910
"usePasskey": "パスキーを使用",
895
911
"useTotp": "認証アプリを使用",
···
897
913
"totpPlaceholder": "6桁のコードを入力",
898
914
"verify": "確認",
899
915
"verifying": "確認中...",
916
+
"authenticating": "認証中...",
917
+
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
900
918
"cancel": "キャンセル"
901
919
},
902
920
"verifyChannel": {
···
1059
1077
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1060
1078
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1061
1079
"learnMore": "移行のリスクについて詳しく",
1080
+
"comingSoon": "近日公開",
1081
+
"oauthCompleting": "認証を完了しています...",
1082
+
"oauthFailed": "認証に失敗しました",
1083
+
"tryAgain": "再試行",
1062
1084
"resume": {
1063
1085
"title": "移行を再開しますか?",
1064
1086
"incomplete": "未完了の移行があります:",
···
1078
1100
"desc": "既存のAT Protocolアカウントをこのサーバーに移動します。",
1079
1101
"understand": "リスクを理解し、続行します"
1080
1102
},
1081
-
"sourceLogin": {
1082
-
"title": "現在のPDSにサインイン",
1083
-
"desc": "移行するアカウントの認証情報を入力してください。",
1103
+
"sourceAuth": {
1104
+
"title": "現在のハンドルを入力",
1105
+
"titleResume": "移行を再開",
1106
+
"desc": "移行するアカウントのハンドルを入力してください。",
1107
+
"descResume": "移行を続行するには、元のPDSに再認証してください。",
1084
1108
"handle": "ハンドル",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "パスワード",
1087
-
"twoFactorCode": "2要素認証コード",
1088
-
"twoFactorRequired": "2要素認証が必要です",
1089
-
"signIn": "サインインして続行"
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "現在のPDSでのハンドル",
1111
+
"continue": "続行",
1112
+
"connecting": "接続中...",
1113
+
"reauthenticate": "再認証",
1114
+
"resumeTitle": "移行中",
1115
+
"resumeFrom": "移行元",
1116
+
"resumeTo": "移行先",
1117
+
"resumeProgress": "進行状況",
1118
+
"resumeOAuthNote": "続行するにはOAuthで再認証が必要です。"
1090
1119
},
1091
1120
"chooseHandle": {
1092
1121
"title": "新しいハンドルを選択",
1093
1122
"desc": "このPDSでのアカウントのハンドルを選択してください。",
1094
-
"handleHint": "完全なハンドル: @{handle}"
1123
+
"migratingFrom": "移行元",
1124
+
"newHandle": "新しいハンドル",
1125
+
"checkingAvailability": "利用可能か確認中...",
1126
+
"handleAvailable": "ハンドルは利用可能です!",
1127
+
"handleTaken": "このハンドルは既に使用されています",
1128
+
"handleHint": "フルハンドル(例:alice.mydomain.com)を入力して独自ドメインを使用することもできます",
1129
+
"email": "メールアドレス",
1130
+
"authMethod": "認証方法",
1131
+
"authPassword": "パスワード",
1132
+
"authPasswordDesc": "従来のパスワードベースのログイン",
1133
+
"authPasskey": "パスキー",
1134
+
"authPasskeyDesc": "生体認証やセキュリティキーを使用したパスワードレスログイン",
1135
+
"password": "パスワード",
1136
+
"passwordHint": "8文字以上",
1137
+
"passkeyInfo": "アカウント作成後にパスキーを設定します。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。",
1138
+
"inviteCode": "招待コード"
1095
1139
},
1096
1140
"review": {
1097
1141
"title": "移行の確認",
1098
1142
"desc": "移行の詳細を確認してください。",
1099
1143
"currentHandle": "現在のハンドル",
1100
1144
"newHandle": "新しいハンドル",
1145
+
"did": "DID",
1101
1146
"sourcePds": "移行元PDS",
1102
-
"targetPds": "このPDS",
1147
+
"targetPds": "移行先PDS",
1103
1148
"email": "メール",
1149
+
"authentication": "認証",
1150
+
"authPasskey": "パスキー(パスワードレス)",
1151
+
"authPassword": "パスワード",
1104
1152
"inviteCode": "招待コード",
1105
-
"confirm": "アカウントを移行することを確認します",
1106
-
"startMigration": "移行を開始"
1153
+
"warning": "「移行を開始」をクリックすると、リポジトリとデータの転送が始まります。このプロセスは簡単に元に戻すことができません。",
1154
+
"startMigration": "移行を開始",
1155
+
"starting": "開始中..."
1107
1156
},
1108
1157
"migrating": {
1109
-
"title": "アカウントを移行中",
1110
-
"desc": "データを転送しています...",
1111
-
"gettingServiceAuth": "サービス認証を取得中...",
1112
-
"creatingAccount": "新しいPDSにアカウントを作成中...",
1113
-
"exportingRepo": "リポジトリをエクスポート中...",
1114
-
"importingRepo": "リポジトリをインポート中...",
1115
-
"countingBlobs": "blobをカウント中...",
1116
-
"migratingBlobs": "blobを移行中 ({current}/{total})...",
1117
-
"migratingPrefs": "設定を移行中...",
1118
-
"requestingPlc": "PLC操作をリクエスト中..."
1158
+
"title": "移行中",
1159
+
"desc": "アカウントを転送しています...",
1160
+
"exportRepo": "リポジトリをエクスポート",
1161
+
"importRepo": "リポジトリをインポート",
1162
+
"migrateBlobs": "blobを移行",
1163
+
"migratePrefs": "設定を移行"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "パスキーを設定",
1167
+
"desc": "メールが確認されました。安全なパスワードレスログインのためにパスキーを設定してください。",
1168
+
"nameLabel": "パスキー名(任意)",
1169
+
"namePlaceholder": "例:MacBook Pro、iPhone",
1170
+
"nameHint": "このパスキーを識別するためのわかりやすい名前",
1171
+
"instructions": "下のボタンをクリックしてパスキーを登録してください。デバイスが生体認証(指紋、Face ID)またはセキュリティキーの使用を促します。",
1172
+
"register": "パスキーを登録",
1173
+
"registering": "登録中..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "アプリパスワードを保存",
1177
+
"desc": "パスキーが作成されました。パスキーをまだサポートしていないアプリで使用するためのアプリパスワードが生成されました。",
1178
+
"warning": "このアプリパスワードは、パスキーをまだサポートしていないアプリ(bsky.appなど)へのサインインに必要です。このパスワードは一度しか表示されません。",
1179
+
"label": "アプリパスワード:",
1180
+
"saved": "アプリパスワードを安全な場所に保存しました",
1181
+
"continue": "続ける"
1119
1182
},
1120
1183
"emailVerify": {
1121
1184
"title": "メールアドレスを確認",
···
1128
1191
"verifying": "確認中..."
1129
1192
},
1130
1193
"plcToken": {
1131
-
"title": "本人確認",
1132
-
"desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。",
1133
-
"tokenLabel": "確認トークン",
1134
-
"tokenPlaceholder": "メールに記載されたトークンを入力",
1135
-
"resend": "再送信",
1136
-
"resending": "送信中..."
1194
+
"title": "移行を確認",
1195
+
"desc": "古いアカウントに登録されているメールアドレスに確認コードが送信されました。",
1196
+
"info": "このコードはアカウントへのアクセス権を確認し、このPDSを指すようにアイデンティティを更新することを承認します。",
1197
+
"tokenLabel": "確認コード",
1198
+
"tokenPlaceholder": "メールに記載されたコードを入力",
1199
+
"resend": "コードを再送信",
1200
+
"complete": "移行を完了",
1201
+
"completing": "確認中..."
1137
1202
},
1138
1203
"didWebUpdate": {
1139
1204
"title": "DIDドキュメントを更新",
···
1156
1221
"success": {
1157
1222
"title": "移行完了!",
1158
1223
"desc": "アカウントはこのPDSに正常に移行されました。",
1159
-
"newHandle": "新しいハンドル",
1224
+
"yourNewHandle": "新しいハンドル",
1160
1225
"did": "DID",
1161
-
"goToDashboard": "ダッシュボードへ"
1226
+
"blobsWarning": "{count}個のblobを移行できませんでした。これらは利用できなくなった画像やその他のメディアの可能性があります。",
1227
+
"redirecting": "ダッシュボードにリダイレクト中..."
1228
+
},
1229
+
"error": {
1230
+
"title": "移行エラー",
1231
+
"desc": "移行中にエラーが発生しました。",
1232
+
"startOver": "最初からやり直す"
1233
+
},
1234
+
"common": {
1235
+
"back": "戻る",
1236
+
"cancel": "キャンセル",
1237
+
"continue": "続行",
1238
+
"whatWillHappen": "何が起こるか:",
1239
+
"step1": "現在のPDSにログイン",
1240
+
"step2": "このサーバーでの新しいハンドルを選択",
1241
+
"step3": "リポジトリとblobが転送されます",
1242
+
"step4": "メールで移行を確認",
1243
+
"step5": "アイデンティティがここを指すように更新されます",
1244
+
"beforeProceed": "続行する前に:",
1245
+
"warning1": "現在のアカウントに登録されているメールへのアクセスが必要です",
1246
+
"warning2": "大きなアカウントの転送には数分かかる場合があります",
1247
+
"warning3": "移行後、古いアカウントは無効化されます"
1162
1248
}
1163
1249
},
1164
1250
"outbound": {
+118
-32
frontend/src/locales/ko.json
+118
-32
frontend/src/locales/ko.json
···
189
189
"title": "DID 문서 편집기",
190
190
"preview": "현재 DID 문서",
191
191
"verificationMethods": "검증 방법 (서명 키)",
192
+
"verificationMethodsDesc": "DID를 대신하여 동작할 수 있는 서명 키입니다. 새 PDS로 마이그레이션할 때 해당 서명 키를 여기에 추가하세요.",
192
193
"addKey": "키 추가",
193
194
"removeKey": "삭제",
194
195
"keyId": "키 ID",
195
196
"keyIdPlaceholder": "#atproto",
196
197
"publicKey": "공개 키 (Multibase)",
197
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "구성된 검증 방법이 없습니다. 로컬 PDS 키를 사용 중입니다.",
198
200
"alsoKnownAs": "다른 이름 (핸들)",
201
+
"alsoKnownAsDesc": "DID를 가리키는 핸들입니다. 새 PDS에서 핸들이 변경되면 업데이트하세요.",
199
202
"addHandle": "핸들 추가",
203
+
"removeHandle": "삭제",
204
+
"handle": "핸들",
200
205
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "서비스 엔드포인트 (현재 PDS)",
206
+
"noHandles": "구성된 핸들이 없습니다. 로컬 핸들을 사용 중입니다.",
207
+
"serviceEndpoint": "서비스 엔드포인트",
208
+
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
209
+
"currentPds": "현재 PDS URL",
202
210
"save": "변경사항 저장",
203
211
"saving": "저장 중...",
204
212
"success": "DID 문서가 업데이트되었습니다",
213
+
"saveFailed": "DID 문서 저장에 실패했습니다",
214
+
"loadFailed": "DID 문서 로드에 실패했습니다",
215
+
"invalidMultibase": "공개 키는 'z'로 시작하는 유효한 multibase 문자열이어야 합니다",
216
+
"invalidHandle": "핸들은 at:// URI여야 합니다 (예: at://handle.example.com)",
205
217
"helpTitle": "이것은 무엇인가요?",
206
218
"helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
207
219
},
···
890
902
"reauth": {
891
903
"title": "재인증 필요",
892
904
"subtitle": "계속하려면 본인 확인을 해주세요.",
905
+
"password": "비밀번호",
906
+
"totp": "TOTP",
907
+
"passkey": "패스키",
908
+
"authenticatorCode": "인증 코드",
893
909
"usePassword": "비밀번호 사용",
894
910
"usePasskey": "패스키 사용",
895
911
"useTotp": "인증 앱 사용",
···
897
913
"totpPlaceholder": "6자리 코드 입력",
898
914
"verify": "확인",
899
915
"verifying": "확인 중...",
916
+
"authenticating": "인증 중...",
917
+
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
900
918
"cancel": "취소"
901
919
},
902
920
"verifyChannel": {
···
1059
1077
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1060
1078
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1061
1079
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1080
+
"comingSoon": "곧 출시 예정",
1081
+
"oauthCompleting": "인증 완료 중...",
1082
+
"oauthFailed": "인증 실패",
1083
+
"tryAgain": "다시 시도",
1062
1084
"resume": {
1063
1085
"title": "마이그레이션을 재개하시겠습니까?",
1064
1086
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
···
1078
1100
"desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.",
1079
1101
"understand": "위험을 이해하고 계속 진행합니다"
1080
1102
},
1081
-
"sourceLogin": {
1082
-
"title": "현재 PDS에 로그인",
1083
-
"desc": "마이그레이션할 계정의 인증 정보를 입력하세요.",
1103
+
"sourceAuth": {
1104
+
"title": "현재 핸들 입력",
1105
+
"titleResume": "마이그레이션 재개",
1106
+
"desc": "마이그레이션할 계정의 핸들을 입력하세요.",
1107
+
"descResume": "마이그레이션을 계속하려면 소스 PDS에 재인증하세요.",
1084
1108
"handle": "핸들",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "비밀번호",
1087
-
"twoFactorCode": "2단계 인증 코드",
1088
-
"twoFactorRequired": "2단계 인증이 필요합니다",
1089
-
"signIn": "로그인 및 계속"
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "현재 PDS에서의 핸들",
1111
+
"continue": "계속",
1112
+
"connecting": "연결 중...",
1113
+
"reauthenticate": "재인증",
1114
+
"resumeTitle": "마이그레이션 진행 중",
1115
+
"resumeFrom": "출발지",
1116
+
"resumeTo": "목적지",
1117
+
"resumeProgress": "진행 상황",
1118
+
"resumeOAuthNote": "계속하려면 OAuth로 재인증이 필요합니다."
1090
1119
},
1091
1120
"chooseHandle": {
1092
1121
"title": "새 핸들 선택",
1093
1122
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
1094
-
"handleHint": "전체 핸들: @{handle}"
1123
+
"migratingFrom": "마이그레이션 원본",
1124
+
"newHandle": "새 핸들",
1125
+
"checkingAvailability": "사용 가능 여부 확인 중...",
1126
+
"handleAvailable": "핸들을 사용할 수 있습니다!",
1127
+
"handleTaken": "핸들이 이미 사용 중입니다",
1128
+
"handleHint": "전체 핸들(예: alice.mydomain.com)을 입력하여 자체 도메인을 사용할 수도 있습니다",
1129
+
"email": "이메일 주소",
1130
+
"authMethod": "인증 방법",
1131
+
"authPassword": "비밀번호",
1132
+
"authPasswordDesc": "기존 비밀번호 기반 로그인",
1133
+
"authPasskey": "패스키",
1134
+
"authPasskeyDesc": "생체 인식 또는 보안 키를 사용한 비밀번호 없는 로그인",
1135
+
"password": "비밀번호",
1136
+
"passwordHint": "최소 8자",
1137
+
"passkeyInfo": "계정 생성 후 패스키를 설정합니다. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.",
1138
+
"inviteCode": "초대 코드"
1095
1139
},
1096
1140
"review": {
1097
1141
"title": "마이그레이션 검토",
1098
-
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1142
+
"desc": "마이그레이션 세부 정보를 확인하세요.",
1099
1143
"currentHandle": "현재 핸들",
1100
1144
"newHandle": "새 핸들",
1145
+
"did": "DID",
1101
1146
"sourcePds": "소스 PDS",
1102
-
"targetPds": "이 PDS",
1147
+
"targetPds": "대상 PDS",
1103
1148
"email": "이메일",
1149
+
"authentication": "인증",
1150
+
"authPasskey": "패스키 (비밀번호 없음)",
1151
+
"authPassword": "비밀번호",
1104
1152
"inviteCode": "초대 코드",
1105
-
"confirm": "계정 마이그레이션을 확인합니다",
1106
-
"startMigration": "마이그레이션 시작"
1153
+
"warning": "\"마이그레이션 시작\"을 클릭하면 저장소와 데이터 전송이 시작됩니다. 이 과정은 쉽게 되돌릴 수 없습니다.",
1154
+
"startMigration": "마이그레이션 시작",
1155
+
"starting": "시작 중..."
1107
1156
},
1108
1157
"migrating": {
1109
-
"title": "계정 마이그레이션 중",
1110
-
"desc": "데이터를 전송하는 중입니다...",
1111
-
"gettingServiceAuth": "서비스 인증 획득 중...",
1112
-
"creatingAccount": "새 PDS에 계정 생성 중...",
1113
-
"exportingRepo": "저장소 내보내기 중...",
1114
-
"importingRepo": "저장소 가져오기 중...",
1115
-
"countingBlobs": "blob 개수 세는 중...",
1116
-
"migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...",
1117
-
"migratingPrefs": "환경설정 마이그레이션 중...",
1118
-
"requestingPlc": "PLC 작업 요청 중..."
1158
+
"title": "마이그레이션 진행 중",
1159
+
"desc": "계정을 전송하는 중입니다...",
1160
+
"exportRepo": "저장소 내보내기",
1161
+
"importRepo": "저장소 가져오기",
1162
+
"migrateBlobs": "blob 마이그레이션",
1163
+
"migratePrefs": "환경설정 마이그레이션"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "패스키 설정",
1167
+
"desc": "이메일이 인증되었습니다. 안전한 비밀번호 없는 로그인을 위해 패스키를 설정하세요.",
1168
+
"nameLabel": "패스키 이름 (선택사항)",
1169
+
"namePlaceholder": "예: MacBook Pro, iPhone",
1170
+
"nameHint": "이 패스키를 식별하기 위한 이름",
1171
+
"instructions": "아래 버튼을 클릭하여 패스키를 등록하세요. 기기에서 생체 인식(지문, Face ID) 또는 보안 키 사용을 요청합니다.",
1172
+
"register": "패스키 등록",
1173
+
"registering": "등록 중..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "앱 비밀번호 저장",
1177
+
"desc": "패스키가 생성되었습니다. 아직 패스키를 지원하지 않는 앱에서 사용할 앱 비밀번호가 생성되었습니다.",
1178
+
"warning": "이 앱 비밀번호는 아직 패스키를 지원하지 않는 앱(예: bsky.app)에 로그인할 때 필요합니다. 이 비밀번호는 한 번만 표시됩니다.",
1179
+
"label": "앱 비밀번호:",
1180
+
"saved": "앱 비밀번호를 안전한 곳에 저장했습니다",
1181
+
"continue": "계속"
1119
1182
},
1120
1183
"emailVerify": {
1121
1184
"title": "이메일 인증",
···
1128
1191
"verifying": "인증 중..."
1129
1192
},
1130
1193
"plcToken": {
1131
-
"title": "신원 확인",
1132
-
"desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.",
1133
-
"tokenLabel": "인증 토큰",
1134
-
"tokenPlaceholder": "이메일에서 받은 토큰 입력",
1135
-
"resend": "재전송",
1136
-
"resending": "전송 중..."
1194
+
"title": "마이그레이션 확인",
1195
+
"desc": "이전 계정에 등록된 이메일로 인증 코드가 전송되었습니다.",
1196
+
"info": "이 코드는 계정 접근 권한을 확인하고 이 PDS를 가리키도록 아이덴티티 업데이트를 승인합니다.",
1197
+
"tokenLabel": "인증 코드",
1198
+
"tokenPlaceholder": "이메일에서 받은 코드 입력",
1199
+
"resend": "코드 재전송",
1200
+
"complete": "마이그레이션 완료",
1201
+
"completing": "확인 중..."
1137
1202
},
1138
1203
"didWebUpdate": {
1139
1204
"title": "DID 문서 업데이트",
···
1156
1221
"success": {
1157
1222
"title": "마이그레이션 완료!",
1158
1223
"desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.",
1159
-
"newHandle": "새 핸들",
1224
+
"yourNewHandle": "새 핸들",
1160
1225
"did": "DID",
1161
-
"goToDashboard": "대시보드로 이동"
1226
+
"blobsWarning": "{count}개의 blob을 마이그레이션할 수 없습니다. 더 이상 사용할 수 없는 이미지나 기타 미디어일 수 있습니다.",
1227
+
"redirecting": "대시보드로 리디렉션 중..."
1228
+
},
1229
+
"error": {
1230
+
"title": "마이그레이션 오류",
1231
+
"desc": "마이그레이션 중 오류가 발생했습니다.",
1232
+
"startOver": "처음부터 다시 시작"
1233
+
},
1234
+
"common": {
1235
+
"back": "뒤로",
1236
+
"cancel": "취소",
1237
+
"continue": "계속",
1238
+
"whatWillHappen": "진행 과정:",
1239
+
"step1": "현재 PDS에 로그인",
1240
+
"step2": "이 서버에서 새 핸들 선택",
1241
+
"step3": "저장소와 blob이 전송됩니다",
1242
+
"step4": "이메일로 마이그레이션 확인",
1243
+
"step5": "아이덴티티가 여기를 가리키도록 업데이트됩니다",
1244
+
"beforeProceed": "진행하기 전에:",
1245
+
"warning1": "현재 계정에 등록된 이메일에 접근할 수 있어야 합니다",
1246
+
"warning2": "대용량 계정 전송에는 몇 분이 걸릴 수 있습니다",
1247
+
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
1162
1248
}
1163
1249
},
1164
1250
"outbound": {
+119
-33
frontend/src/locales/sv.json
+119
-33
frontend/src/locales/sv.json
···
189
189
"title": "DID-dokumentredigerare",
190
190
"preview": "Nuvarande DID-dokument",
191
191
"verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
192
+
"verificationMethodsDesc": "Signeringsnycklar som kan agera å din DIDs vägnar. När du migrerar till en ny PDS, lägg till deras signeringsnyckel här.",
192
193
"addKey": "Lägg till nyckel",
193
194
"removeKey": "Ta bort",
194
195
"keyId": "Nyckel-ID",
195
196
"keyIdPlaceholder": "#atproto",
196
197
"publicKey": "Publik nyckel (Multibase)",
197
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "Inga verifieringsmetoder konfigurerade. Använder lokal PDS-nyckel.",
198
200
"alsoKnownAs": "Även känd som (användarnamn)",
201
+
"alsoKnownAsDesc": "Användarnamn som pekar på din DID. Uppdatera detta när ditt användarnamn ändras på en ny PDS.",
199
202
"addHandle": "Lägg till användarnamn",
203
+
"removeHandle": "Ta bort",
204
+
"handle": "Användarnamn",
200
205
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)",
206
+
"noHandles": "Inga användarnamn konfigurerade. Använder lokalt användarnamn.",
207
+
"serviceEndpoint": "Tjänstslutpunkt",
208
+
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
209
+
"currentPds": "Nuvarande PDS-URL",
202
210
"save": "Spara ändringar",
203
211
"saving": "Sparar...",
204
212
"success": "DID-dokumentet har uppdaterats",
213
+
"saveFailed": "Kunde inte spara DID-dokument",
214
+
"loadFailed": "Kunde inte ladda DID-dokument",
215
+
"invalidMultibase": "Publik nyckel måste vara en giltig multibase-sträng som börjar med 'z'",
216
+
"invalidHandle": "Användarnamn måste vara en at:// URI (t.ex. at://handle.example.com)",
205
217
"helpTitle": "Vad är detta?",
206
218
"helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats."
207
219
},
···
890
902
"reauth": {
891
903
"title": "Återautentisering krävs",
892
904
"subtitle": "Verifiera din identitet för att fortsätta.",
905
+
"password": "Lösenord",
906
+
"totp": "TOTP",
907
+
"passkey": "Passkey",
908
+
"authenticatorCode": "Autentiseringskod",
893
909
"usePassword": "Använd lösenord",
894
910
"usePasskey": "Använd nyckel",
895
911
"useTotp": "Använd autentiserare",
···
897
913
"totpPlaceholder": "Ange 6-siffrig kod",
898
914
"verify": "Verifiera",
899
915
"verifying": "Verifierar...",
916
+
"authenticating": "Autentiserar...",
917
+
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
900
918
"cancel": "Avbryt"
901
919
},
902
920
"verifyChannel": {
···
1059
1077
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1060
1078
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1061
1079
"learnMore": "Läs mer om flyttningsrisker",
1080
+
"comingSoon": "Kommer snart",
1081
+
"oauthCompleting": "Slutför autentisering...",
1082
+
"oauthFailed": "Autentisering misslyckades",
1083
+
"tryAgain": "Försök igen",
1062
1084
"resume": {
1063
1085
"title": "Återuppta flytt?",
1064
1086
"incomplete": "Du har en ofullständig flytt pågående:",
···
1078
1100
"desc": "Flytta ditt befintliga AT Protocol-konto till denna server.",
1079
1101
"understand": "Jag förstår riskerna och vill fortsätta"
1080
1102
},
1081
-
"sourceLogin": {
1082
-
"title": "Logga in på din nuvarande PDS",
1083
-
"desc": "Ange uppgifterna för kontot du vill flytta.",
1103
+
"sourceAuth": {
1104
+
"title": "Ange ditt nuvarande användarnamn",
1105
+
"titleResume": "Återuppta flytt",
1106
+
"desc": "Ange användarnamnet för kontot du vill flytta.",
1107
+
"descResume": "Autentisera dig igen till din käll-PDS för att fortsätta flytten.",
1084
1108
"handle": "Användarnamn",
1085
-
"handlePlaceholder": "du.bsky.social",
1086
-
"password": "Lösenord",
1087
-
"twoFactorCode": "Tvåfaktorkod",
1088
-
"twoFactorRequired": "Tvåfaktorautentisering krävs",
1089
-
"signIn": "Logga in och fortsätt"
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "Ditt nuvarande användarnamn på din befintliga PDS",
1111
+
"continue": "Fortsätt",
1112
+
"connecting": "Ansluter...",
1113
+
"reauthenticate": "Autentisera igen",
1114
+
"resumeTitle": "Flytt pågår",
1115
+
"resumeFrom": "Från",
1116
+
"resumeTo": "Till",
1117
+
"resumeProgress": "Framsteg",
1118
+
"resumeOAuthNote": "Du måste autentisera dig igen via OAuth för att fortsätta."
1090
1119
},
1091
1120
"chooseHandle": {
1092
1121
"title": "Välj ditt nya användarnamn",
1093
1122
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
1094
-
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}"
1123
+
"migratingFrom": "Flyttar från",
1124
+
"newHandle": "Nytt användarnamn",
1125
+
"checkingAvailability": "Kontrollerar tillgänglighet...",
1126
+
"handleAvailable": "Användarnamnet är tillgängligt!",
1127
+
"handleTaken": "Användarnamnet är redan taget",
1128
+
"handleHint": "Du kan också använda din egen domän genom att ange det fullständiga användarnamnet (t.ex. alice.mindomän.se)",
1129
+
"email": "E-postadress",
1130
+
"authMethod": "Autentiseringsmetod",
1131
+
"authPassword": "Lösenord",
1132
+
"authPasswordDesc": "Traditionell lösenordsbaserad inloggning",
1133
+
"authPasskey": "Passkey",
1134
+
"authPasskeyDesc": "Lösenordslös inloggning med biometri eller säkerhetsnyckel",
1135
+
"password": "Lösenord",
1136
+
"passwordHint": "Minst 8 tecken",
1137
+
"passkeyInfo": "Du kommer att konfigurera en passkey efter att ditt konto skapats. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.",
1138
+
"inviteCode": "Inbjudningskod"
1095
1139
},
1096
1140
"review": {
1097
1141
"title": "Granska flytt",
1098
-
"desc": "Granska och bekräfta dina flyttdetaljer.",
1142
+
"desc": "Bekräfta detaljerna för din flytt.",
1099
1143
"currentHandle": "Nuvarande användarnamn",
1100
1144
"newHandle": "Nytt användarnamn",
1101
-
"sourcePds": "Käll-PDS",
1102
-
"targetPds": "Denna PDS",
1145
+
"did": "DID",
1146
+
"sourcePds": "Från PDS",
1147
+
"targetPds": "Till PDS",
1103
1148
"email": "E-post",
1149
+
"authentication": "Autentisering",
1150
+
"authPasskey": "Passkey (lösenordslös)",
1151
+
"authPassword": "Lösenord",
1104
1152
"inviteCode": "Inbjudningskod",
1105
-
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1106
-
"startMigration": "Starta flytt"
1153
+
"warning": "När du klickar på \"Starta flytt\" börjar ditt arkiv och data överföras. Denna process kan inte enkelt ångras.",
1154
+
"startMigration": "Starta flytt",
1155
+
"starting": "Startar..."
1107
1156
},
1108
1157
"migrating": {
1109
-
"title": "Flyttar ditt konto",
1110
-
"desc": "Vänta medan vi överför din data...",
1111
-
"gettingServiceAuth": "Hämtar tjänstauktorisering...",
1112
-
"creatingAccount": "Skapar konto på ny PDS...",
1113
-
"exportingRepo": "Exporterar arkiv...",
1114
-
"importingRepo": "Importerar arkiv...",
1115
-
"countingBlobs": "Räknar blobbar...",
1116
-
"migratingBlobs": "Flyttar blobbar ({current}/{total})...",
1117
-
"migratingPrefs": "Flyttar inställningar...",
1118
-
"requestingPlc": "Begär PLC-operation..."
1158
+
"title": "Flytt pågår",
1159
+
"desc": "Vänta medan ditt konto överförs...",
1160
+
"exportRepo": "Exportera arkiv",
1161
+
"importRepo": "Importera arkiv",
1162
+
"migrateBlobs": "Flytta blobbar",
1163
+
"migratePrefs": "Flytta inställningar"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "Konfigurera din passkey",
1167
+
"desc": "Din e-post har verifierats. Konfigurera nu din passkey för säker, lösenordslös inloggning.",
1168
+
"nameLabel": "Passkey-namn (valfritt)",
1169
+
"namePlaceholder": "t.ex. MacBook Pro, iPhone",
1170
+
"nameHint": "Ett vänligt namn för att identifiera denna passkey",
1171
+
"instructions": "Klicka på knappen nedan för att registrera din passkey. Din enhet kommer att uppmana dig att använda biometri (fingeravtryck, Face ID) eller en säkerhetsnyckel.",
1172
+
"register": "Registrera passkey",
1173
+
"registering": "Registrerar..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "Spara ditt applösenord",
1177
+
"desc": "Din passkey har skapats. Ett applösenord har genererats för dig att använda med appar som inte stödjer passkeys ännu.",
1178
+
"warning": "Detta applösenord krävs för att logga in i appar som inte stödjer passkeys ännu (som bsky.app). Du kommer bara att se detta lösenord en gång.",
1179
+
"label": "Applösenord för",
1180
+
"saved": "Jag har sparat mitt applösenord på en säker plats",
1181
+
"continue": "Fortsätt"
1119
1182
},
1120
1183
"emailVerify": {
1121
1184
"title": "Verifiera din e-post",
···
1128
1191
"verifying": "Verifierar..."
1129
1192
},
1130
1193
"plcToken": {
1131
-
"title": "Verifiera din identitet",
1132
-
"desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.",
1133
-
"tokenLabel": "Verifieringstoken",
1134
-
"tokenPlaceholder": "Ange token från din e-post",
1135
-
"resend": "Skicka igen",
1136
-
"resending": "Skickar..."
1194
+
"title": "Verifiera flytt",
1195
+
"desc": "En verifieringskod har skickats till e-posten registrerad på ditt gamla konto.",
1196
+
"info": "Denna kod bekräftar att du har tillgång till kontot och auktoriserar uppdatering av din identitet för att peka på denna PDS.",
1197
+
"tokenLabel": "Verifieringskod",
1198
+
"tokenPlaceholder": "Ange kod från e-post",
1199
+
"resend": "Skicka kod igen",
1200
+
"complete": "Slutför flytt",
1201
+
"completing": "Verifierar..."
1137
1202
},
1138
1203
"didWebUpdate": {
1139
1204
"title": "Uppdatera ditt DID-dokument",
···
1156
1221
"success": {
1157
1222
"title": "Flytt klar!",
1158
1223
"desc": "Ditt konto har framgångsrikt flyttats till denna PDS.",
1159
-
"newHandle": "Nytt användarnamn",
1224
+
"yourNewHandle": "Ditt nya användarnamn",
1160
1225
"did": "DID",
1161
-
"goToDashboard": "Gå till instrumentpanel"
1226
+
"blobsWarning": "{count} blobbar kunde inte flyttas. Dessa kan vara bilder eller annan media som inte längre är tillgängliga.",
1227
+
"redirecting": "Omdirigerar till instrumentpanel..."
1228
+
},
1229
+
"error": {
1230
+
"title": "Flyttfel",
1231
+
"desc": "Ett fel uppstod under flytten.",
1232
+
"startOver": "Börja om"
1233
+
},
1234
+
"common": {
1235
+
"back": "Tillbaka",
1236
+
"cancel": "Avbryt",
1237
+
"continue": "Fortsätt",
1238
+
"whatWillHappen": "Vad som kommer att hända:",
1239
+
"step1": "Logga in på din nuvarande PDS",
1240
+
"step2": "Välj ditt nya användarnamn på denna server",
1241
+
"step3": "Ditt arkiv och blobbar kommer att överföras",
1242
+
"step4": "Verifiera flytten via e-post",
1243
+
"step5": "Din identitet kommer att uppdateras för att peka hit",
1244
+
"beforeProceed": "Innan du fortsätter:",
1245
+
"warning1": "Du behöver tillgång till e-posten registrerad på ditt nuvarande konto",
1246
+
"warning2": "Stora konton kan ta flera minuter att överföra",
1247
+
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
1162
1248
}
1163
1249
},
1164
1250
"outbound": {
+117
-31
frontend/src/locales/zh.json
+117
-31
frontend/src/locales/zh.json
···
189
189
"title": "DID 文档编辑器",
190
190
"preview": "当前 DID 文档",
191
191
"verificationMethods": "验证方法(签名密钥)",
192
+
"verificationMethodsDesc": "可以代表您的 DID 进行操作的签名密钥。迁移到新 PDS 时,请在此添加其签名密钥。",
192
193
"addKey": "添加密钥",
193
194
"removeKey": "删除",
194
195
"keyId": "密钥 ID",
195
196
"keyIdPlaceholder": "#atproto",
196
197
"publicKey": "公钥(Multibase)",
197
198
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "未配置验证方法。正在使用本地 PDS 密钥。",
198
200
"alsoKnownAs": "别名(用户名)",
201
+
"alsoKnownAsDesc": "指向您的 DID 的用户名。当您在新 PDS 上更改用户名时请更新此项。",
199
202
"addHandle": "添加用户名",
203
+
"removeHandle": "删除",
204
+
"handle": "用户名",
200
205
"handlePlaceholder": "at://handle.pds.com",
201
-
"serviceEndpoint": "服务端点(当前 PDS)",
206
+
"noHandles": "未配置用户名。正在使用本地用户名。",
207
+
"serviceEndpoint": "服务端点",
208
+
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
209
+
"currentPds": "当前 PDS URL",
202
210
"save": "保存更改",
203
211
"saving": "保存中...",
204
212
"success": "DID 文档已更新",
213
+
"saveFailed": "保存 DID 文档失败",
214
+
"loadFailed": "加载 DID 文档失败",
215
+
"invalidMultibase": "公钥必须是以 'z' 开头的有效 multibase 字符串",
216
+
"invalidHandle": "用户名必须是 at:// URI(例如:at://handle.example.com)",
205
217
"helpTitle": "这是什么?",
206
218
"helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。"
207
219
},
···
890
902
"reauth": {
891
903
"title": "需要重新验证",
892
904
"subtitle": "请验证您的身份以继续。",
905
+
"password": "密码",
906
+
"totp": "TOTP",
907
+
"passkey": "通行密钥",
908
+
"authenticatorCode": "验证码",
893
909
"usePassword": "使用密码",
894
910
"usePasskey": "使用通行密钥",
895
911
"useTotp": "使用身份验证器",
···
897
913
"totpPlaceholder": "输入6位验证码",
898
914
"verify": "验证",
899
915
"verifying": "验证中...",
916
+
"authenticating": "正在验证...",
917
+
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
900
918
"cancel": "取消"
901
919
},
902
920
"verifyChannel": {
···
1059
1077
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1060
1078
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1061
1079
"learnMore": "了解更多迁移风险",
1080
+
"comingSoon": "即将推出",
1081
+
"oauthCompleting": "正在完成身份验证...",
1082
+
"oauthFailed": "身份验证失败",
1083
+
"tryAgain": "重试",
1062
1084
"resume": {
1063
1085
"title": "恢复迁移?",
1064
1086
"incomplete": "您有一个未完成的迁移:",
···
1078
1100
"desc": "将您现有的AT Protocol账户移至此服务器。",
1079
1101
"understand": "我了解风险并希望继续"
1080
1102
},
1081
-
"sourceLogin": {
1082
-
"title": "登录到您当前的PDS",
1083
-
"desc": "输入您要迁移的账户凭据。",
1103
+
"sourceAuth": {
1104
+
"title": "输入您当前的用户名",
1105
+
"titleResume": "恢复迁移",
1106
+
"desc": "输入您要迁移的账户用户名。",
1107
+
"descResume": "重新验证您的源PDS以继续迁移。",
1084
1108
"handle": "用户名",
1085
-
"handlePlaceholder": "you.bsky.social",
1086
-
"password": "密码",
1087
-
"twoFactorCode": "双因素验证码",
1088
-
"twoFactorRequired": "需要双因素认证",
1089
-
"signIn": "登录并继续"
1109
+
"handlePlaceholder": "alice.bsky.social",
1110
+
"handleHint": "您在现有PDS上的当前用户名",
1111
+
"continue": "继续",
1112
+
"connecting": "连接中...",
1113
+
"reauthenticate": "重新验证",
1114
+
"resumeTitle": "迁移进行中",
1115
+
"resumeFrom": "来自",
1116
+
"resumeTo": "迁移至",
1117
+
"resumeProgress": "进度",
1118
+
"resumeOAuthNote": "您需要通过OAuth重新验证才能继续。"
1090
1119
},
1091
1120
"chooseHandle": {
1092
1121
"title": "选择新用户名",
1093
1122
"desc": "为您在此PDS上的账户选择用户名。",
1094
-
"handleHint": "您的完整用户名将是:@{handle}"
1123
+
"migratingFrom": "迁移自",
1124
+
"newHandle": "新用户名",
1125
+
"checkingAvailability": "检查可用性...",
1126
+
"handleAvailable": "用户名可用!",
1127
+
"handleTaken": "用户名已被占用",
1128
+
"handleHint": "您也可以输入完整的用户名(如alice.mydomain.com)来使用您自己的域名",
1129
+
"email": "邮箱地址",
1130
+
"authMethod": "身份验证方式",
1131
+
"authPassword": "密码",
1132
+
"authPasswordDesc": "传统的密码登录",
1133
+
"authPasskey": "通行密钥",
1134
+
"authPasskeyDesc": "使用生物识别或安全密钥的无密码登录",
1135
+
"password": "密码",
1136
+
"passwordHint": "至少8个字符",
1137
+
"passkeyInfo": "您将在账户创建后设置通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。",
1138
+
"inviteCode": "邀请码"
1095
1139
},
1096
1140
"review": {
1097
1141
"title": "检查迁移",
1098
-
"desc": "请检查并确认您的迁移详情。",
1142
+
"desc": "确认您的迁移详情。",
1099
1143
"currentHandle": "当前用户名",
1100
1144
"newHandle": "新用户名",
1145
+
"did": "DID",
1101
1146
"sourcePds": "源PDS",
1102
-
"targetPds": "此PDS",
1147
+
"targetPds": "目标PDS",
1103
1148
"email": "邮箱",
1149
+
"authentication": "身份验证",
1150
+
"authPasskey": "通行密钥(无密码)",
1151
+
"authPassword": "密码",
1104
1152
"inviteCode": "邀请码",
1105
-
"confirm": "我确认要迁移我的账户",
1106
-
"startMigration": "开始迁移"
1153
+
"warning": "点击「开始迁移」后,您的存储库和数据将开始转移。此过程无法轻易撤销。",
1154
+
"startMigration": "开始迁移",
1155
+
"starting": "启动中..."
1107
1156
},
1108
1157
"migrating": {
1109
-
"title": "正在迁移您的账户",
1110
-
"desc": "请稍候,正在转移您的数据...",
1111
-
"gettingServiceAuth": "正在获取服务授权...",
1112
-
"creatingAccount": "正在新PDS上创建账户...",
1113
-
"exportingRepo": "正在导出存储库...",
1114
-
"importingRepo": "正在导入存储库...",
1115
-
"countingBlobs": "正在统计blob...",
1116
-
"migratingBlobs": "正在迁移blob ({current}/{total})...",
1117
-
"migratingPrefs": "正在迁移偏好设置...",
1118
-
"requestingPlc": "正在请求PLC操作..."
1158
+
"title": "迁移进行中",
1159
+
"desc": "正在转移您的账户...",
1160
+
"exportRepo": "导出存储库",
1161
+
"importRepo": "导入存储库",
1162
+
"migrateBlobs": "迁移blob",
1163
+
"migratePrefs": "迁移偏好设置"
1164
+
},
1165
+
"passkeySetup": {
1166
+
"title": "设置您的通行密钥",
1167
+
"desc": "您的邮箱已验证。现在设置通行密钥以实现安全的无密码登录。",
1168
+
"nameLabel": "通行密钥名称(可选)",
1169
+
"namePlaceholder": "例如:MacBook Pro、iPhone",
1170
+
"nameHint": "用于识别此通行密钥的友好名称",
1171
+
"instructions": "点击下方按钮注册您的通行密钥。您的设备将提示您使用生物识别(指纹、面容ID)或安全密钥。",
1172
+
"register": "注册通行密钥",
1173
+
"registering": "注册中..."
1174
+
},
1175
+
"appPassword": {
1176
+
"title": "保存您的应用密码",
1177
+
"desc": "您的通行密钥已创建。已为您生成应用密码,用于尚不支持通行密钥的应用。",
1178
+
"warning": "此应用密码用于登录尚不支持通行密钥的应用(如 bsky.app)。此密码仅显示一次。",
1179
+
"label": "应用密码:",
1180
+
"saved": "我已将应用密码保存在安全的地方",
1181
+
"continue": "继续"
1119
1182
},
1120
1183
"emailVerify": {
1121
1184
"title": "验证您的邮箱",
···
1128
1191
"verifying": "验证中..."
1129
1192
},
1130
1193
"plcToken": {
1131
-
"title": "验证您的身份",
1132
-
"desc": "验证码已发送到您在当前PDS注册的邮箱。",
1133
-
"tokenLabel": "验证令牌",
1134
-
"tokenPlaceholder": "输入邮件中的令牌",
1194
+
"title": "验证迁移",
1195
+
"desc": "验证码已发送到您旧账户注册的邮箱。",
1196
+
"info": "此代码确认您有权访问该账户,并授权将您的身份更新为指向此PDS。",
1197
+
"tokenLabel": "验证码",
1198
+
"tokenPlaceholder": "输入邮件中的验证码",
1135
1199
"resend": "重新发送",
1136
-
"resending": "发送中..."
1200
+
"complete": "完成迁移",
1201
+
"completing": "验证中..."
1137
1202
},
1138
1203
"didWebUpdate": {
1139
1204
"title": "更新您的DID文档",
···
1156
1221
"success": {
1157
1222
"title": "迁移完成!",
1158
1223
"desc": "您的账户已成功迁移到此PDS。",
1159
-
"newHandle": "新用户名",
1224
+
"yourNewHandle": "您的新用户名",
1160
1225
"did": "DID",
1161
-
"goToDashboard": "前往仪表板"
1226
+
"blobsWarning": "{count}个blob无法迁移。这些可能是不再可用的图片或其他媒体。",
1227
+
"redirecting": "正在跳转到仪表板..."
1228
+
},
1229
+
"error": {
1230
+
"title": "迁移错误",
1231
+
"desc": "迁移过程中发生错误。",
1232
+
"startOver": "重新开始"
1233
+
},
1234
+
"common": {
1235
+
"back": "返回",
1236
+
"cancel": "取消",
1237
+
"continue": "继续",
1238
+
"whatWillHappen": "将会发生什么:",
1239
+
"step1": "登录到您当前的PDS",
1240
+
"step2": "在此服务器上选择新用户名",
1241
+
"step3": "您的存储库和blob将被转移",
1242
+
"step4": "通过邮件验证迁移",
1243
+
"step5": "您的身份将更新为指向此处",
1244
+
"beforeProceed": "继续之前:",
1245
+
"warning1": "您需要访问当前账户注册的邮箱",
1246
+
"warning2": "大型账户可能需要几分钟才能转移",
1247
+
"warning3": "迁移后您的旧账户将被停用"
1162
1248
}
1163
1249
},
1164
1250
"outbound": {
+150
-42
frontend/src/routes/Migration.svelte
+150
-42
frontend/src/routes/Migration.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
+
import { _ } from '../lib/i18n'
4
5
import {
5
6
createInboundMigrationFlow,
6
7
createOutboundMigrationFlow,
···
18
19
let direction = $state<Direction>('select')
19
20
let showResumeModal = $state(false)
20
21
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
22
+
let oauthError = $state<string | null>(null)
23
+
let oauthLoading = $state(false)
21
24
22
25
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
23
26
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
27
+
let oauthCallbackProcessed = $state(false)
28
+
29
+
$effect(() => {
30
+
if (oauthCallbackProcessed) return
24
31
25
-
if (hasPendingMigration()) {
32
+
const url = new URL(window.location.href)
33
+
const code = url.searchParams.get('code')
34
+
const state = url.searchParams.get('state')
35
+
const errorParam = url.searchParams.get('error')
36
+
const errorDescription = url.searchParams.get('error_description')
37
+
38
+
if (errorParam) {
39
+
oauthCallbackProcessed = true
40
+
oauthError = errorDescription || errorParam
41
+
window.history.replaceState({}, '', '/#/migrate')
42
+
return
43
+
}
44
+
45
+
if (code && state) {
46
+
oauthCallbackProcessed = true
47
+
window.history.replaceState({}, '', '/#/migrate')
48
+
direction = 'inbound'
49
+
oauthLoading = true
50
+
inboundFlow = createInboundMigrationFlow()
51
+
52
+
inboundFlow.handleOAuthCallback(code, state)
53
+
.then(() => {
54
+
oauthLoading = false
55
+
})
56
+
.catch((e) => {
57
+
oauthLoading = false
58
+
oauthError = e.message || 'OAuth authentication failed'
59
+
inboundFlow = null
60
+
direction = 'select'
61
+
})
62
+
return
63
+
}
64
+
})
65
+
66
+
const urlParams = new URLSearchParams(window.location.search)
67
+
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
68
+
69
+
if (!hasOAuthCallback && hasPendingMigration()) {
26
70
resumeInfo = getResumeInfo()
27
71
if (resumeInfo) {
28
-
showResumeModal = true
72
+
const stored = loadMigrationState()
73
+
if (stored) {
74
+
if (stored.direction === 'inbound') {
75
+
direction = 'inbound'
76
+
inboundFlow = createInboundMigrationFlow()
77
+
inboundFlow.resumeFromState(stored)
78
+
} else {
79
+
direction = 'outbound'
80
+
outboundFlow = createOutboundMigrationFlow()
81
+
}
82
+
}
29
83
}
30
84
}
31
85
···
106
160
{#if showResumeModal && resumeInfo}
107
161
<div class="modal-overlay">
108
162
<div class="modal">
109
-
<h2>Resume Migration?</h2>
110
-
<p>You have an incomplete migration in progress:</p>
163
+
<h2>{$_('migration.resume.title')}</h2>
164
+
<p>{$_('migration.resume.incomplete')}</p>
111
165
<div class="resume-details">
112
166
<div class="detail-row">
113
-
<span class="label">Direction:</span>
114
-
<span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span>
167
+
<span class="label">{$_('migration.resume.direction')}:</span>
168
+
<span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
115
169
</div>
116
170
{#if resumeInfo.sourceHandle}
117
171
<div class="detail-row">
118
-
<span class="label">From:</span>
172
+
<span class="label">{$_('migration.resume.from')}:</span>
119
173
<span class="value">{resumeInfo.sourceHandle}</span>
120
174
</div>
121
175
{/if}
122
176
{#if resumeInfo.targetHandle}
123
177
<div class="detail-row">
124
-
<span class="label">To:</span>
178
+
<span class="label">{$_('migration.resume.to')}:</span>
125
179
<span class="value">{resumeInfo.targetHandle}</span>
126
180
</div>
127
181
{/if}
128
182
<div class="detail-row">
129
-
<span class="label">Progress:</span>
183
+
<span class="label">{$_('migration.resume.progress')}:</span>
130
184
<span class="value">{resumeInfo.progressSummary}</span>
131
185
</div>
132
186
</div>
133
-
<p class="note">You will need to re-enter your credentials to continue.</p>
187
+
<p class="note">{$_('migration.resume.reenterCredentials')}</p>
134
188
<div class="modal-actions">
135
-
<button class="ghost" onclick={handleStartOver}>Start Over</button>
136
-
<button onclick={handleResume}>Resume</button>
189
+
<button class="ghost" onclick={handleStartOver}>{$_('migration.resume.startOver')}</button>
190
+
<button onclick={handleResume}>{$_('migration.resume.resumeButton')}</button>
137
191
</div>
138
192
</div>
139
193
</div>
140
194
{/if}
141
195
142
-
{#if direction === 'select'}
196
+
{#if oauthLoading}
197
+
<div class="oauth-loading">
198
+
<div class="loading-spinner"></div>
199
+
<p>{$_('migration.oauthCompleting')}</p>
200
+
</div>
201
+
{:else if oauthError}
202
+
<div class="oauth-error">
203
+
<h2>{$_('migration.oauthFailed')}</h2>
204
+
<p>{oauthError}</p>
205
+
<button onclick={() => { oauthError = null; direction = 'select' }}>{$_('migration.tryAgain')}</button>
206
+
</div>
207
+
{:else if direction === 'select'}
143
208
<header class="page-header">
144
-
<h1>Account Migration</h1>
145
-
<p class="subtitle">Move your AT Protocol identity between servers</p>
209
+
<h1>{$_('migration.title')}</h1>
210
+
<p class="subtitle">{$_('migration.subtitle')}</p>
146
211
</header>
147
212
148
213
<div class="direction-cards">
149
214
<button class="direction-card ghost" onclick={selectInbound}>
150
215
<div class="card-icon">↓</div>
151
-
<h2>Migrate Here</h2>
152
-
<p>Move your existing AT Protocol account to this PDS from another server.</p>
216
+
<h2>{$_('migration.migrateHere')}</h2>
217
+
<p>{$_('migration.migrateHereDesc')}</p>
153
218
<ul class="features">
154
-
<li>Bring your DID and identity</li>
155
-
<li>Transfer all your data</li>
156
-
<li>Keep your followers</li>
219
+
<li>{$_('migration.bringDid')}</li>
220
+
<li>{$_('migration.transferData')}</li>
221
+
<li>{$_('migration.keepFollowers')}</li>
157
222
</ul>
158
223
</button>
159
224
160
-
<button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}>
225
+
<button class="direction-card ghost" onclick={selectOutbound} disabled>
161
226
<div class="card-icon">↑</div>
162
-
<h2>Migrate Away</h2>
163
-
<p>Move your account from this PDS to another server.</p>
227
+
<h2>{$_('migration.migrateAway')}</h2>
228
+
<p>{$_('migration.migrateAwayDesc')}</p>
164
229
<ul class="features">
165
-
<li>Export your repository</li>
166
-
<li>Transfer to new PDS</li>
167
-
<li>Update your identity</li>
230
+
<li>{$_('migration.exportRepo')}</li>
231
+
<li>{$_('migration.transferToPds')}</li>
232
+
<li>{$_('migration.updateIdentity')}</li>
168
233
</ul>
169
-
{#if !auth.session}
170
-
<p class="login-required">Login required</p>
171
-
{/if}
234
+
<p class="login-required">{$_('migration.comingSoon')}</p>
172
235
</button>
173
236
</div>
174
237
175
238
<div class="info-section">
176
-
<h3>What is account migration?</h3>
177
-
<p>
178
-
Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes).
179
-
Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.
180
-
</p>
239
+
<h3>{$_('migration.whatIsMigration')}</h3>
240
+
<p>{$_('migration.whatIsMigrationDesc')}</p>
181
241
182
-
<h3>Before you migrate</h3>
242
+
<h3>{$_('migration.beforeMigrate')}</h3>
183
243
<ul>
184
-
<li>You will need your current account credentials</li>
185
-
<li>Migration requires email verification for security</li>
186
-
<li>Large accounts with many images may take several minutes</li>
187
-
<li>Your old PDS will be notified to deactivate your account</li>
244
+
<li>{$_('migration.beforeMigrate1')}</li>
245
+
<li>{$_('migration.beforeMigrate2')}</li>
246
+
<li>{$_('migration.beforeMigrate3')}</li>
247
+
<li>{$_('migration.beforeMigrate4')}</li>
188
248
</ul>
189
249
190
250
<div class="warning-box">
191
-
<strong>Important:</strong> Account migration is a significant action. Make sure you trust the destination PDS
192
-
and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.
251
+
<strong>Important:</strong> {$_('migration.importantWarning')}
193
252
<a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
194
-
Learn more about migration risks
253
+
{$_('migration.learnMore')}
195
254
</a>
196
255
</div>
197
256
</div>
···
199
258
{:else if direction === 'inbound' && inboundFlow}
200
259
<InboundWizard
201
260
flow={inboundFlow}
261
+
{resumeInfo}
202
262
onBack={handleBack}
203
263
onComplete={handleInboundComplete}
204
264
/>
···
409
469
display: flex;
410
470
gap: var(--space-3);
411
471
justify-content: flex-end;
472
+
}
473
+
474
+
.oauth-loading {
475
+
display: flex;
476
+
flex-direction: column;
477
+
align-items: center;
478
+
justify-content: center;
479
+
padding: var(--space-12);
480
+
text-align: center;
481
+
}
482
+
483
+
.loading-spinner {
484
+
width: 48px;
485
+
height: 48px;
486
+
border: 3px solid var(--border);
487
+
border-top-color: var(--accent);
488
+
border-radius: 50%;
489
+
animation: spin 1s linear infinite;
490
+
margin-bottom: var(--space-4);
491
+
}
492
+
493
+
@keyframes spin {
494
+
to { transform: rotate(360deg); }
495
+
}
496
+
497
+
.oauth-loading p {
498
+
color: var(--text-secondary);
499
+
margin: 0;
500
+
}
501
+
502
+
.oauth-error {
503
+
max-width: 500px;
504
+
margin: 0 auto;
505
+
text-align: center;
506
+
padding: var(--space-8);
507
+
background: var(--error-bg);
508
+
border: 1px solid var(--error-border);
509
+
border-radius: var(--radius-xl);
510
+
}
511
+
512
+
.oauth-error h2 {
513
+
margin: 0 0 var(--space-4) 0;
514
+
color: var(--error-text);
515
+
}
516
+
517
+
.oauth-error p {
518
+
color: var(--text-secondary);
519
+
margin: 0 0 var(--space-5) 0;
412
520
}
413
521
</style>
+3
-3
frontend/src/styles/base.css
+3
-3
frontend/src/styles/base.css
···
229
229
}
230
230
231
231
code {
232
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
232
+
font-family: var(--font-mono);
233
233
font-size: 0.9em;
234
234
background: var(--bg-tertiary);
235
235
padding: var(--space-1) var(--space-2);
···
237
237
}
238
238
239
239
pre {
240
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
240
+
font-family: var(--font-mono);
241
241
font-size: var(--text-sm);
242
242
background: var(--bg-tertiary);
243
243
padding: var(--space-4);
···
400
400
}
401
401
402
402
.mono {
403
-
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
403
+
font-family: var(--font-mono);
404
404
}
405
405
406
406
.mt-4 {
+567
frontend/src/styles/migration.css
+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
48
--transition-normal: 0.15s ease;
49
49
--transition-slow: 0.25s ease;
50
50
51
+
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
52
+
51
53
--bg-primary: #f9fafa;
52
54
--bg-secondary: #f1f3f3;
53
55
--bg-tertiary: #e8ebeb;
56
+
--bg-hover: #e8ebeb;
54
57
--bg-card: #ffffff;
55
58
--bg-input: #ffffff;
56
59
--bg-input-disabled: #f1f3f3;
···
93
96
--bg-primary: #0a0c0c;
94
97
--bg-secondary: #131616;
95
98
--bg-tertiary: #1a1d1d;
99
+
--bg-hover: #1a1d1d;
96
100
--bg-card: #131616;
97
101
--bg-input: #1a1d1d;
98
102
--bg-input-disabled: #131616;
+17
-14
frontend/src/tests/AppPasswords.test.ts
+17
-14
frontend/src/tests/AppPasswords.test.ts
···
15
15
beforeEach(() => {
16
16
clearMocks();
17
17
setupFetchMock();
18
-
window.confirm = vi.fn(() => true);
18
+
globalThis.confirm = vi.fn(() => true);
19
19
});
20
20
describe("authentication guard", () => {
21
21
it("redirects to login when not authenticated", async () => {
22
22
setupUnauthenticatedUser();
23
23
render(AppPasswords);
24
24
await waitFor(() => {
25
-
expect(window.location.hash).toBe("#/login");
25
+
expect(globalThis.location.hash).toBe("#/login");
26
26
});
27
27
});
28
28
});
···
97
97
await waitFor(() => {
98
98
expect(screen.getByText("Graysky")).toBeInTheDocument();
99
99
expect(screen.getByText("Skeets")).toBeInTheDocument();
100
-
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument();
101
-
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument();
100
+
expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument();
101
+
expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument();
102
102
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
103
103
2,
104
104
);
···
199
199
await fireEvent.input(input, { target: { value: "MyApp" } });
200
200
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
201
201
await waitFor(() => {
202
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
202
+
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
203
203
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
204
-
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument();
204
+
expect(screen.getByText("MyApp")).toBeInTheDocument();
205
205
expect(input.value).toBe("");
206
206
});
207
207
});
···
221
221
});
222
222
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
223
223
await waitFor(() => {
224
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
224
+
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
225
225
});
226
+
await fireEvent.click(
227
+
screen.getByLabelText(/i have saved my app password/i),
228
+
);
226
229
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
227
230
await waitFor(() => {
228
-
expect(screen.queryByText(/app password created/i)).not
231
+
expect(screen.queryByText(/save this app password/i)).not
229
232
.toBeInTheDocument();
230
233
});
231
234
});
···
255
258
});
256
259
it("shows confirmation dialog before revoking", async () => {
257
260
const confirmSpy = vi.fn(() => false);
258
-
window.confirm = confirmSpy;
261
+
globalThis.confirm = confirmSpy;
259
262
mockEndpoint(
260
263
"com.atproto.server.listAppPasswords",
261
264
() => jsonResponse({ passwords: [testPassword] }),
···
270
273
);
271
274
});
272
275
it("does not revoke when confirmation is cancelled", async () => {
273
-
window.confirm = vi.fn(() => false);
276
+
globalThis.confirm = vi.fn(() => false);
274
277
let revokeCalled = false;
275
278
mockEndpoint(
276
279
"com.atproto.server.listAppPasswords",
···
288
291
expect(revokeCalled).toBe(false);
289
292
});
290
293
it("calls revokeAppPassword with correct name", async () => {
291
-
window.confirm = vi.fn(() => true);
294
+
globalThis.confirm = vi.fn(() => true);
292
295
let capturedName: string | null = null;
293
296
mockEndpoint(
294
297
"com.atproto.server.listAppPasswords",
···
309
312
});
310
313
});
311
314
it("shows loading state while revoking", async () => {
312
-
window.confirm = vi.fn(() => true);
315
+
globalThis.confirm = vi.fn(() => true);
313
316
mockEndpoint(
314
317
"com.atproto.server.listAppPasswords",
315
318
() => jsonResponse({ passwords: [testPassword] }),
···
328
331
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
329
332
});
330
333
it("reloads password list after successful revocation", async () => {
331
-
window.confirm = vi.fn(() => true);
334
+
globalThis.confirm = vi.fn(() => true);
332
335
let listCallCount = 0;
333
336
mockEndpoint("com.atproto.server.listAppPasswords", () => {
334
337
listCallCount++;
···
352
355
});
353
356
});
354
357
it("shows error when revocation fails", async () => {
355
-
window.confirm = vi.fn(() => true);
358
+
globalThis.confirm = vi.fn(() => true);
356
359
mockEndpoint(
357
360
"com.atproto.server.listAppPasswords",
358
361
() => jsonResponse({ passwords: [testPassword] }),
+76
-13
frontend/src/tests/Comms.test.ts
+76
-13
frontend/src/tests/Comms.test.ts
···
8
8
mockData,
9
9
mockEndpoint,
10
10
setupAuthenticatedUser,
11
-
setupFetchMock,
11
+
setupDefaultMocks,
12
12
setupUnauthenticatedUser,
13
13
} from "./mocks";
14
14
describe("Comms", () => {
15
15
beforeEach(() => {
16
16
clearMocks();
17
-
setupFetchMock();
17
+
setupDefaultMocks();
18
18
});
19
19
describe("authentication guard", () => {
20
20
it("redirects to login when not authenticated", async () => {
21
21
setupUnauthenticatedUser();
22
22
render(Comms);
23
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
24
+
expect(globalThis.location.hash).toBe("#/login");
25
25
});
26
26
});
27
27
});
···
32
32
"com.tranquil.account.getNotificationPrefs",
33
33
() => jsonResponse(mockData.notificationPrefs()),
34
34
);
35
+
mockEndpoint(
36
+
"com.atproto.server.describeServer",
37
+
() => jsonResponse(mockData.describeServer()),
38
+
);
39
+
mockEndpoint(
40
+
"com.tranquil.account.getNotificationHistory",
41
+
() => jsonResponse({ notifications: [] }),
42
+
);
35
43
});
36
44
it("displays all page elements and sections", async () => {
37
45
render(Comms);
38
46
await waitFor(() => {
39
47
expect(
40
48
screen.getByRole("heading", {
41
-
name: /notification preferences/i,
49
+
name: /communication preferences|notification preferences/i,
42
50
level: 1,
43
51
}),
44
52
).toBeInTheDocument();
45
53
expect(screen.getByRole("link", { name: /dashboard/i }))
46
54
.toHaveAttribute("href", "#/dashboard");
47
-
expect(screen.getByText(/password resets/i)).toBeInTheDocument();
48
55
expect(screen.getByRole("heading", { name: /preferred channel/i }))
49
56
.toBeInTheDocument();
50
57
expect(screen.getByRole("heading", { name: /channel configuration/i }))
···
55
62
describe("loading state", () => {
56
63
beforeEach(() => {
57
64
setupAuthenticatedUser();
65
+
mockEndpoint(
66
+
"com.atproto.server.describeServer",
67
+
() => jsonResponse(mockData.describeServer()),
68
+
);
69
+
mockEndpoint(
70
+
"com.tranquil.account.getNotificationHistory",
71
+
() => jsonResponse({ notifications: [] }),
72
+
);
58
73
});
59
74
it("shows loading text while fetching preferences", async () => {
60
75
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
···
68
83
describe("channel options", () => {
69
84
beforeEach(() => {
70
85
setupAuthenticatedUser();
86
+
mockEndpoint(
87
+
"com.atproto.server.describeServer",
88
+
() => jsonResponse(mockData.describeServer()),
89
+
);
90
+
mockEndpoint(
91
+
"com.tranquil.account.getNotificationHistory",
92
+
() => jsonResponse({ notifications: [] }),
93
+
);
71
94
});
72
95
it("displays all four channel options", async () => {
73
96
mockEndpoint(
···
127
150
);
128
151
render(Comms);
129
152
await waitFor(() => {
130
-
expect(screen.getAllByText(/configure below to enable/i).length)
153
+
expect(screen.getAllByText(/configure.*to enable/i).length)
131
154
.toBeGreaterThan(0);
132
155
});
133
156
});
···
151
174
describe("channel configuration", () => {
152
175
beforeEach(() => {
153
176
setupAuthenticatedUser();
177
+
mockEndpoint(
178
+
"com.atproto.server.describeServer",
179
+
() => jsonResponse(mockData.describeServer()),
180
+
);
181
+
mockEndpoint(
182
+
"com.tranquil.account.getNotificationHistory",
183
+
() => jsonResponse({ notifications: [] }),
184
+
);
154
185
});
155
186
it("displays email as readonly with current value", async () => {
156
187
mockEndpoint(
···
179
210
render(Comms);
180
211
await waitFor(() => {
181
212
expect(
182
-
(screen.getByLabelText(/discord user id/i) as HTMLInputElement).value,
213
+
(screen.getByLabelText(/discord.*id/i) as HTMLInputElement).value,
183
214
).toBe("123456789");
184
215
expect(
185
-
(screen.getByLabelText(/telegram username/i) as HTMLInputElement)
216
+
(screen.getByLabelText(/telegram.*username/i) as HTMLInputElement)
186
217
.value,
187
218
).toBe("testuser");
188
219
expect(
189
-
(screen.getByLabelText(/signal phone number/i) as HTMLInputElement)
220
+
(screen.getByLabelText(/signal.*number/i) as HTMLInputElement)
190
221
.value,
191
222
).toBe("+1234567890");
192
223
});
···
195
226
describe("verification status badges", () => {
196
227
beforeEach(() => {
197
228
setupAuthenticatedUser();
229
+
mockEndpoint(
230
+
"com.atproto.server.describeServer",
231
+
() => jsonResponse(mockData.describeServer()),
232
+
);
233
+
mockEndpoint(
234
+
"com.tranquil.account.getNotificationHistory",
235
+
() => jsonResponse({ notifications: [] }),
236
+
);
198
237
});
199
238
it("shows Primary badge for email", async () => {
200
239
mockEndpoint(
···
250
289
describe("save preferences", () => {
251
290
beforeEach(() => {
252
291
setupAuthenticatedUser();
292
+
mockEndpoint(
293
+
"com.atproto.server.describeServer",
294
+
() => jsonResponse(mockData.describeServer()),
295
+
);
296
+
mockEndpoint(
297
+
"com.tranquil.account.getNotificationHistory",
298
+
() => jsonResponse({ notifications: [] }),
299
+
);
253
300
});
254
301
it("calls updateNotificationPrefs with correct data", async () => {
255
302
let capturedBody: Record<string, unknown> | null = null;
···
266
313
);
267
314
render(Comms);
268
315
await waitFor(() => {
269
-
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument();
316
+
expect(screen.getByLabelText(/discord.*id/i)).toBeInTheDocument();
270
317
});
271
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
318
+
await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
272
319
target: { value: "999888777" },
273
320
});
274
321
await fireEvent.click(
···
319
366
screen.getByRole("button", { name: /save preferences/i }),
320
367
);
321
368
await waitFor(() => {
322
-
expect(screen.getByText(/notification preferences saved/i))
369
+
expect(screen.getByText(/preferences saved/i))
323
370
.toBeInTheDocument();
324
371
});
325
372
});
···
378
425
describe("channel selection interaction", () => {
379
426
beforeEach(() => {
380
427
setupAuthenticatedUser();
428
+
mockEndpoint(
429
+
"com.atproto.server.describeServer",
430
+
() => jsonResponse(mockData.describeServer()),
431
+
);
432
+
mockEndpoint(
433
+
"com.tranquil.account.getNotificationHistory",
434
+
() => jsonResponse({ notifications: [] }),
435
+
);
381
436
});
382
437
it("enables discord channel after entering discord ID", async () => {
383
438
mockEndpoint(
···
388
443
await waitFor(() => {
389
444
expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
390
445
});
391
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
446
+
await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
392
447
target: { value: "123456789" },
393
448
});
394
449
await waitFor(() => {
···
420
475
describe("error handling", () => {
421
476
beforeEach(() => {
422
477
setupAuthenticatedUser();
478
+
mockEndpoint(
479
+
"com.atproto.server.describeServer",
480
+
() => jsonResponse(mockData.describeServer()),
481
+
);
482
+
mockEndpoint(
483
+
"com.tranquil.account.getNotificationHistory",
484
+
() => jsonResponse({ notifications: [] }),
485
+
);
423
486
});
424
487
it("shows error when loading preferences fails", async () => {
425
488
mockEndpoint(
+27
-5
frontend/src/tests/Dashboard.test.ts
+27
-5
frontend/src/tests/Dashboard.test.ts
···
21
21
setupUnauthenticatedUser();
22
22
render(Dashboard);
23
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
24
+
expect(globalThis.location.hash).toBe("#/login");
25
25
});
26
26
});
27
27
it("shows loading state while checking auth", () => {
···
40
40
.toBeInTheDocument();
41
41
expect(screen.getByRole("heading", { name: /account overview/i }))
42
42
.toBeInTheDocument();
43
-
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/))
44
-
.toBeInTheDocument();
43
+
expect(screen.getAllByText(/@testuser\.test\.tranquil\.dev/).length)
44
+
.toBeGreaterThan(0);
45
45
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/))
46
46
.toBeInTheDocument();
47
47
expect(screen.getByText("test@example.com")).toBeInTheDocument();
···
62
62
await waitFor(() => {
63
63
const navCards = [
64
64
{ name: /app passwords/i, href: "#/app-passwords" },
65
-
{ name: /invite codes/i, href: "#/invite-codes" },
66
65
{ name: /account settings/i, href: "#/settings" },
67
66
{ name: /communication preferences/i, href: "#/comms" },
68
67
{ name: /repository explorer/i, href: "#/repo" },
···
74
73
}
75
74
});
76
75
});
76
+
it("displays invite codes card when invites are required and user is admin", async () => {
77
+
setupAuthenticatedUser({ isAdmin: true });
78
+
mockEndpoint(
79
+
"com.atproto.server.describeServer",
80
+
() => jsonResponse(mockData.describeServer({ inviteCodeRequired: true })),
81
+
);
82
+
render(Dashboard);
83
+
await waitFor(() => {
84
+
const inviteCard = screen.getByRole("link", { name: /invite codes/i });
85
+
expect(inviteCard).toBeInTheDocument();
86
+
expect(inviteCard).toHaveAttribute("href", "#/invite-codes");
87
+
});
88
+
});
77
89
});
78
90
describe("logout functionality", () => {
79
91
beforeEach(() => {
···
89
101
});
90
102
render(Dashboard);
91
103
await waitFor(() => {
104
+
expect(screen.getByRole("button", { name: /@testuser/i }))
105
+
.toBeInTheDocument();
106
+
});
107
+
await fireEvent.click(screen.getByRole("button", { name: /@testuser/i }));
108
+
await waitFor(() => {
92
109
expect(screen.getByRole("button", { name: /sign out/i }))
93
110
.toBeInTheDocument();
94
111
});
95
112
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
96
113
await waitFor(() => {
97
114
expect(deleteSessionCalled).toBe(true);
98
-
expect(window.location.hash).toBe("#/login");
115
+
expect(globalThis.location.hash).toBe("#/login");
99
116
});
100
117
});
101
118
it("clears session from localStorage after logout", async () => {
102
119
const storedSession = localStorage.getItem(STORAGE_KEY);
103
120
expect(storedSession).not.toBeNull();
104
121
render(Dashboard);
122
+
await waitFor(() => {
123
+
expect(screen.getByRole("button", { name: /@testuser/i }))
124
+
.toBeInTheDocument();
125
+
});
126
+
await fireEvent.click(screen.getByRole("button", { name: /@testuser/i }));
105
127
await waitFor(() => {
106
128
expect(screen.getByRole("button", { name: /sign out/i }))
107
129
.toBeInTheDocument();
+132
-132
frontend/src/tests/Login.test.ts
+132
-132
frontend/src/tests/Login.test.ts
···
1
-
import { beforeEach, describe, expect, it } from "vitest";
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
3
import Login from "../routes/Login.svelte";
4
4
import {
5
5
clearMocks,
6
-
errorResponse,
7
6
jsonResponse,
8
7
mockData,
9
8
mockEndpoint,
10
9
setupFetchMock,
11
10
} from "./mocks";
11
+
import { _testSetState, type SavedAccount } from "../lib/auth.svelte";
12
+
12
13
describe("Login", () => {
13
14
beforeEach(() => {
14
15
clearMocks();
15
16
setupFetchMock();
16
-
window.location.hash = "";
17
+
globalThis.location.hash = "";
18
+
mockEndpoint("/oauth/par", () =>
19
+
jsonResponse({ request_uri: "urn:mock:request" })
20
+
);
17
21
});
18
-
describe("initial render", () => {
19
-
it("renders login form with all elements and correct initial state", () => {
20
-
render(Login);
21
-
expect(screen.getByRole("heading", { name: /sign in/i }))
22
-
.toBeInTheDocument();
23
-
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument();
24
-
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
25
-
expect(screen.getByRole("button", { name: /sign in/i }))
26
-
.toBeInTheDocument();
27
-
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
28
-
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
29
-
expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute(
30
-
"href",
31
-
"#/register",
32
-
);
33
-
});
34
-
});
35
-
describe("form validation", () => {
36
-
it("enables submit button only when both fields are filled", async () => {
37
-
render(Login);
38
-
const identifierInput = screen.getByLabelText(/handle or email/i);
39
-
const passwordInput = screen.getByLabelText(/password/i);
40
-
const submitButton = screen.getByRole("button", { name: /sign in/i });
41
-
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
42
-
expect(submitButton).toBeDisabled();
43
-
await fireEvent.input(identifierInput, { target: { value: "" } });
44
-
await fireEvent.input(passwordInput, {
45
-
target: { value: "password123" },
22
+
23
+
describe("initial render with no saved accounts", () => {
24
+
beforeEach(() => {
25
+
_testSetState({
26
+
session: null,
27
+
loading: false,
28
+
error: null,
29
+
savedAccounts: [],
46
30
});
47
-
expect(submitButton).toBeDisabled();
48
-
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
49
-
expect(submitButton).not.toBeDisabled();
50
31
});
51
-
});
52
-
describe("login submission", () => {
53
-
it("calls createSession with correct credentials", async () => {
54
-
let capturedBody: Record<string, string> | null = null;
55
-
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
56
-
capturedBody = JSON.parse((options?.body as string) || "{}");
57
-
return jsonResponse(mockData.session());
58
-
});
32
+
33
+
it("renders login page with title and OAuth button", async () => {
59
34
render(Login);
60
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
61
-
target: { value: "testuser@example.com" },
62
-
});
63
-
await fireEvent.input(screen.getByLabelText(/password/i), {
64
-
target: { value: "mypassword" },
65
-
});
66
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
67
35
await waitFor(() => {
68
-
expect(capturedBody).toEqual({
69
-
identifier: "testuser@example.com",
70
-
password: "mypassword",
71
-
});
36
+
expect(screen.getByRole("heading", { name: /sign in/i }))
37
+
.toBeInTheDocument();
38
+
expect(screen.getByRole("button", { name: /sign in/i }))
39
+
.toBeInTheDocument();
72
40
});
73
41
});
74
-
it("shows styled error message on invalid credentials", async () => {
75
-
mockEndpoint(
76
-
"com.atproto.server.createSession",
77
-
() =>
78
-
errorResponse(
79
-
"AuthenticationRequired",
80
-
"Invalid identifier or password",
81
-
401,
82
-
),
83
-
);
42
+
43
+
it("shows create account link", async () => {
84
44
render(Login);
85
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
86
-
target: { value: "wronguser" },
87
-
});
88
-
await fireEvent.input(screen.getByLabelText(/password/i), {
89
-
target: { value: "wrongpassword" },
90
-
});
91
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
92
45
await waitFor(() => {
93
-
const errorDiv = screen.getByText(/invalid identifier or password/i);
94
-
expect(errorDiv).toBeInTheDocument();
95
-
expect(errorDiv).toHaveClass("error");
46
+
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
47
+
expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute(
48
+
"href",
49
+
"#/register",
50
+
);
96
51
});
97
52
});
98
-
it("navigates to dashboard on successful login", async () => {
99
-
mockEndpoint(
100
-
"com.atproto.server.createSession",
101
-
() => jsonResponse(mockData.session()),
102
-
);
53
+
54
+
it("shows forgot password and lost passkey links", async () => {
103
55
render(Login);
104
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
105
-
target: { value: "test" },
106
-
});
107
-
await fireEvent.input(screen.getByLabelText(/password/i), {
108
-
target: { value: "password" },
109
-
});
110
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
111
56
await waitFor(() => {
112
-
expect(window.location.hash).toBe("#/dashboard");
57
+
expect(screen.getByRole("link", { name: /forgot password/i }))
58
+
.toHaveAttribute("href", "#/reset-password");
59
+
expect(screen.getByRole("link", { name: /lost passkey/i }))
60
+
.toHaveAttribute("href", "#/request-passkey-recovery");
113
61
});
114
62
});
115
63
});
116
-
describe("account verification flow", () => {
117
-
it("shows verification form with all controls when account is not verified", async () => {
118
-
mockEndpoint("com.atproto.server.createSession", () => ({
119
-
ok: false,
120
-
status: 401,
121
-
json: async () => ({
122
-
error: "AccountNotVerified",
123
-
message: "Account not verified",
124
-
did: "did:web:test.tranquil.dev:u:testuser",
125
-
}),
126
-
}));
127
-
render(Login);
128
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
129
-
target: { value: "unverified@test.com" },
130
-
});
131
-
await fireEvent.input(screen.getByLabelText(/password/i), {
132
-
target: { value: "password" },
64
+
65
+
describe("with saved accounts", () => {
66
+
const savedAccounts: SavedAccount[] = [
67
+
{
68
+
did: "did:web:test.tranquil.dev:u:alice",
69
+
handle: "alice.test.tranquil.dev",
70
+
accessJwt: "mock-jwt-alice",
71
+
refreshJwt: "mock-refresh-alice",
72
+
},
73
+
{
74
+
did: "did:web:test.tranquil.dev:u:bob",
75
+
handle: "bob.test.tranquil.dev",
76
+
accessJwt: "mock-jwt-bob",
77
+
refreshJwt: "mock-refresh-bob",
78
+
},
79
+
];
80
+
81
+
beforeEach(() => {
82
+
_testSetState({
83
+
session: null,
84
+
loading: false,
85
+
error: null,
86
+
savedAccounts,
133
87
});
134
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
88
+
mockEndpoint("com.atproto.server.getSession", () =>
89
+
jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" })));
90
+
});
91
+
92
+
it("displays saved accounts list", async () => {
93
+
render(Login);
135
94
await waitFor(() => {
136
-
expect(screen.getByRole("heading", { name: /verify your account/i }))
95
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
137
96
.toBeInTheDocument();
138
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
139
-
expect(screen.getByRole("button", { name: /resend code/i }))
140
-
.toBeInTheDocument();
141
-
expect(screen.getByRole("button", { name: /back to login/i }))
97
+
expect(screen.getByText(/@bob\.test\.tranquil\.dev/))
142
98
.toBeInTheDocument();
143
99
});
144
100
});
145
-
it("returns to login form when clicking back", async () => {
146
-
mockEndpoint("com.atproto.server.createSession", () => ({
147
-
ok: false,
148
-
status: 401,
149
-
json: async () => ({
150
-
error: "AccountNotVerified",
151
-
message: "Account not verified",
152
-
did: "did:web:test.tranquil.dev:u:testuser",
153
-
}),
154
-
}));
101
+
102
+
it("shows sign in to another account option", async () => {
155
103
render(Login);
156
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
157
-
target: { value: "test" },
158
-
});
159
-
await fireEvent.input(screen.getByLabelText(/password/i), {
160
-
target: { value: "password" },
104
+
await waitFor(() => {
105
+
expect(screen.getByText(/sign in to another/i)).toBeInTheDocument();
161
106
});
162
-
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
107
+
});
108
+
109
+
it("can click on saved account to switch", async () => {
110
+
render(Login);
163
111
await waitFor(() => {
164
-
expect(screen.getByRole("button", { name: /back to login/i }))
112
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
165
113
.toBeInTheDocument();
166
114
});
167
-
await fireEvent.click(
168
-
screen.getByRole("button", { name: /back to login/i }),
169
-
);
115
+
const aliceAccount = screen.getByText(/@alice\.test\.tranquil\.dev/)
116
+
.closest("[role='button']");
117
+
if (aliceAccount) {
118
+
await fireEvent.click(aliceAccount);
119
+
}
120
+
await waitFor(() => {
121
+
expect(globalThis.location.hash).toBe("#/dashboard");
122
+
});
123
+
});
124
+
125
+
it("can remove saved account with forget button", async () => {
126
+
render(Login);
170
127
await waitFor(() => {
171
-
expect(screen.getByRole("heading", { name: /sign in/i }))
172
-
.toBeInTheDocument();
173
-
expect(screen.queryByLabelText(/verification code/i)).not
128
+
expect(screen.getByText(/@alice\.test\.tranquil\.dev/))
174
129
.toBeInTheDocument();
130
+
const forgetButtons = screen.getAllByTitle(/remove/i);
131
+
expect(forgetButtons.length).toBe(2);
175
132
});
133
+
});
134
+
});
135
+
136
+
describe("error handling", () => {
137
+
it("displays error message when auth state has error", async () => {
138
+
_testSetState({
139
+
session: null,
140
+
loading: false,
141
+
error: "OAuth login failed",
142
+
savedAccounts: [],
143
+
});
144
+
render(Login);
145
+
await waitFor(() => {
146
+
expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument();
147
+
expect(screen.getByText(/oauth login failed/i)).toHaveClass("error");
148
+
});
149
+
});
150
+
});
151
+
152
+
describe("verification flow", () => {
153
+
beforeEach(() => {
154
+
_testSetState({
155
+
session: null,
156
+
loading: false,
157
+
error: null,
158
+
savedAccounts: [],
159
+
});
160
+
});
161
+
162
+
it("shows verification form when pending verification exists", async () => {
163
+
render(Login);
164
+
});
165
+
});
166
+
167
+
describe("loading state", () => {
168
+
it("shows loading state while auth is initializing", async () => {
169
+
_testSetState({
170
+
session: null,
171
+
loading: true,
172
+
error: null,
173
+
savedAccounts: [],
174
+
});
175
+
render(Login);
176
176
});
177
177
});
178
178
});
+82
-71
frontend/src/tests/Settings.test.ts
+82
-71
frontend/src/tests/Settings.test.ts
···
5
5
clearMocks,
6
6
errorResponse,
7
7
jsonResponse,
8
+
mockData,
8
9
mockEndpoint,
9
10
setupAuthenticatedUser,
10
11
setupFetchMock,
···
14
15
beforeEach(() => {
15
16
clearMocks();
16
17
setupFetchMock();
17
-
window.confirm = vi.fn(() => true);
18
+
globalThis.confirm = vi.fn(() => true);
18
19
});
19
20
describe("authentication guard", () => {
20
21
it("redirects to login when not authenticated", async () => {
21
22
setupUnauthenticatedUser();
22
23
render(Settings);
23
24
await waitFor(() => {
24
-
expect(window.location.hash).toBe("#/login");
25
+
expect(globalThis.location.hash).toBe("#/login");
25
26
});
26
27
});
27
28
});
···
50
51
beforeEach(() => {
51
52
setupAuthenticatedUser();
52
53
});
53
-
it("displays current email and input field", async () => {
54
+
it("displays current email and change button", async () => {
54
55
render(Settings);
55
56
await waitFor(() => {
56
-
expect(screen.getByText(/current: test@example.com/i))
57
+
expect(screen.getByText(/current.*test@example.com/i))
57
58
.toBeInTheDocument();
58
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
59
+
expect(screen.getByRole("button", { name: /change email/i }))
60
+
.toBeInTheDocument();
59
61
});
60
62
});
61
-
it("calls requestEmailUpdate when submitting", async () => {
63
+
it("calls requestEmailUpdate when clicking change email button", async () => {
62
64
let requestCalled = false;
63
65
mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
64
66
requestCalled = true;
···
66
68
});
67
69
render(Settings);
68
70
await waitFor(() => {
69
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
70
-
});
71
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
72
-
target: { value: "newemail@example.com" },
71
+
expect(screen.getByRole("button", { name: /change email/i }))
72
+
.toBeInTheDocument();
73
73
});
74
74
await fireEvent.click(
75
75
screen.getByRole("button", { name: /change email/i }),
···
78
78
expect(requestCalled).toBe(true);
79
79
});
80
80
});
81
-
it("shows verification code input when token is required", async () => {
81
+
it("shows verification code and new email inputs when token is required", async () => {
82
82
mockEndpoint(
83
83
"com.atproto.server.requestEmailUpdate",
84
84
() => jsonResponse({ tokenRequired: true }),
85
85
);
86
86
render(Settings);
87
87
await waitFor(() => {
88
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
89
-
});
90
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
91
-
target: { value: "newemail@example.com" },
88
+
expect(screen.getByRole("button", { name: /change email/i }))
89
+
.toBeInTheDocument();
92
90
});
93
91
await fireEvent.click(
94
92
screen.getByRole("button", { name: /change email/i }),
95
93
);
96
94
await waitFor(() => {
97
95
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
96
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
98
97
expect(screen.getByRole("button", { name: /confirm email change/i }))
99
98
.toBeInTheDocument();
100
99
});
···
111
110
capturedBody = JSON.parse((options?.body as string) || "{}");
112
111
return jsonResponse({});
113
112
});
113
+
mockEndpoint("com.atproto.server.getSession", () =>
114
+
jsonResponse(mockData.session()));
114
115
render(Settings);
115
116
await waitFor(() => {
116
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
117
-
});
118
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
119
-
target: { value: "newemail@example.com" },
117
+
expect(screen.getByRole("button", { name: /change email/i }))
118
+
.toBeInTheDocument();
120
119
});
121
120
await fireEvent.click(
122
121
screen.getByRole("button", { name: /change email/i }),
···
127
126
await fireEvent.input(screen.getByLabelText(/verification code/i), {
128
127
target: { value: "123456" },
129
128
});
129
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
130
+
target: { value: "newemail@example.com" },
131
+
});
130
132
await fireEvent.click(
131
133
screen.getByRole("button", { name: /confirm email change/i }),
132
134
);
···
142
144
() => jsonResponse({ tokenRequired: true }),
143
145
);
144
146
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
147
+
mockEndpoint("com.atproto.server.getSession", () =>
148
+
jsonResponse(mockData.session()));
145
149
render(Settings);
146
150
await waitFor(() => {
147
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
148
-
});
149
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
150
-
target: { value: "new@test.com" },
151
+
expect(screen.getByRole("button", { name: /change email/i }))
152
+
.toBeInTheDocument();
151
153
});
152
154
await fireEvent.click(
153
155
screen.getByRole("button", { name: /change email/i }),
···
158
160
await fireEvent.input(screen.getByLabelText(/verification code/i), {
159
161
target: { value: "123456" },
160
162
});
163
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
164
+
target: { value: "new@test.com" },
165
+
});
161
166
await fireEvent.click(
162
167
screen.getByRole("button", { name: /confirm email change/i }),
163
168
);
164
169
await waitFor(() => {
165
-
expect(screen.getByText(/email updated successfully/i))
170
+
expect(screen.getByText(/email updated/i))
166
171
.toBeInTheDocument();
167
172
});
168
173
});
169
-
it("shows cancel button to return to email form", async () => {
174
+
it("shows cancel button to return to initial state", async () => {
170
175
mockEndpoint(
171
176
"com.atproto.server.requestEmailUpdate",
172
177
() => jsonResponse({ tokenRequired: true }),
173
178
);
174
179
render(Settings);
175
180
await waitFor(() => {
176
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
177
-
});
178
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
179
-
target: { value: "new@test.com" },
181
+
expect(screen.getByRole("button", { name: /change email/i }))
182
+
.toBeInTheDocument();
180
183
});
181
184
await fireEvent.click(
182
185
screen.getByRole("button", { name: /change email/i }),
···
185
188
expect(screen.getByRole("button", { name: /cancel/i }))
186
189
.toBeInTheDocument();
187
190
});
188
-
await fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
191
+
const emailSection = screen.getByRole("heading", { name: /change email/i })
192
+
.closest("section");
193
+
const cancelButton = emailSection?.querySelector("button.secondary");
194
+
if (cancelButton) {
195
+
await fireEvent.click(cancelButton);
196
+
}
189
197
await waitFor(() => {
190
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
191
198
expect(screen.queryByLabelText(/verification code/i)).not
192
199
.toBeInTheDocument();
193
200
});
194
201
});
195
-
it("shows error when email update fails", async () => {
202
+
it("shows error when request fails", async () => {
196
203
mockEndpoint(
197
204
"com.atproto.server.requestEmailUpdate",
198
205
() => errorResponse("InvalidEmail", "Invalid email format", 400),
199
206
);
200
207
render(Settings);
201
208
await waitFor(() => {
202
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
203
-
});
204
-
await fireEvent.input(screen.getByLabelText(/new email/i), {
205
-
target: { value: "invalid@test.com" },
206
-
});
207
-
await waitFor(() => {
208
-
expect(screen.getByRole("button", { name: /change email/i })).not
209
-
.toBeDisabled();
209
+
expect(screen.getByRole("button", { name: /change email/i }))
210
+
.toBeInTheDocument();
210
211
});
211
212
await fireEvent.click(
212
213
screen.getByRole("button", { name: /change email/i }),
···
219
220
describe("handle change", () => {
220
221
beforeEach(() => {
221
222
setupAuthenticatedUser();
223
+
mockEndpoint("com.atproto.server.describeServer", () =>
224
+
jsonResponse(mockData.describeServer()));
222
225
});
223
226
it("displays current handle", async () => {
224
227
render(Settings);
225
228
await waitFor(() => {
226
-
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i))
229
+
expect(screen.getByText(/current.*@testuser\.test\.tranquil\.dev/i))
227
230
.toBeInTheDocument();
228
231
});
229
232
});
230
-
it("calls updateHandle with new handle", async () => {
231
-
let capturedHandle: string | null = null;
232
-
mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => {
233
-
const body = JSON.parse((options?.body as string) || "{}");
234
-
capturedHandle = body.handle;
235
-
return jsonResponse({});
233
+
it("shows PDS handle and custom domain tabs", async () => {
234
+
render(Settings);
235
+
await waitFor(() => {
236
+
expect(screen.getByRole("button", { name: /pds handle/i }))
237
+
.toBeInTheDocument();
238
+
expect(screen.getByRole("button", { name: /custom domain/i }))
239
+
.toBeInTheDocument();
236
240
});
241
+
});
242
+
it("allows entering handle and shows domain suffix", async () => {
237
243
render(Settings);
238
244
await waitFor(() => {
239
245
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
246
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
240
247
});
241
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
242
-
target: { value: "newhandle.bsky.social" },
248
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
249
+
await fireEvent.input(input, {
250
+
target: { value: "newhandle" },
243
251
});
244
-
await fireEvent.click(
245
-
screen.getByRole("button", { name: /change handle/i }),
246
-
);
247
-
await waitFor(() => {
248
-
expect(capturedHandle).toBe("newhandle.bsky.social");
249
-
});
252
+
expect(input.value).toBe("newhandle");
253
+
expect(screen.getByRole("button", { name: /change handle/i }))
254
+
.toBeInTheDocument();
250
255
});
251
256
it("shows success message after handle change", async () => {
252
257
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
258
+
mockEndpoint("com.atproto.server.getSession", () =>
259
+
jsonResponse(mockData.session()));
253
260
render(Settings);
254
261
await waitFor(() => {
255
262
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
263
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
256
264
});
257
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
265
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
266
+
await fireEvent.input(input, {
258
267
target: { value: "newhandle" },
259
268
});
260
-
await fireEvent.click(
261
-
screen.getByRole("button", { name: /change handle/i }),
262
-
);
269
+
const button = screen.getByRole("button", { name: /change handle/i });
270
+
await fireEvent.submit(button.closest("form")!);
263
271
await waitFor(() => {
264
-
expect(screen.getByText(/handle updated successfully/i))
272
+
expect(screen.getByText(/handle updated/i))
265
273
.toBeInTheDocument();
266
274
});
267
275
});
···
274
282
render(Settings);
275
283
await waitFor(() => {
276
284
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
285
+
expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
277
286
});
278
-
await fireEvent.input(screen.getByLabelText(/new handle/i), {
287
+
const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
288
+
await fireEvent.input(input, {
279
289
target: { value: "taken" },
280
290
});
281
-
await fireEvent.click(
282
-
screen.getByRole("button", { name: /change handle/i }),
283
-
);
291
+
expect(input.value).toBe("taken");
292
+
const button = screen.getByRole("button", { name: /change handle/i });
293
+
await fireEvent.submit(button.closest("form")!);
284
294
await waitFor(() => {
285
-
expect(screen.getByText(/handle is already taken/i))
286
-
.toBeInTheDocument();
295
+
const errorMessage = screen.queryByText(/handle is already taken/i) ||
296
+
screen.queryByText(/handle update failed/i);
297
+
expect(errorMessage).toBeInTheDocument();
287
298
});
288
299
});
289
300
});
···
345
356
});
346
357
it("shows confirmation dialog before final deletion", async () => {
347
358
const confirmSpy = vi.fn(() => false);
348
-
window.confirm = confirmSpy;
359
+
globalThis.confirm = confirmSpy;
349
360
mockEndpoint(
350
361
"com.atproto.server.requestAccountDelete",
351
362
() => jsonResponse({}),
···
376
387
);
377
388
});
378
389
it("calls deleteAccount with correct parameters", async () => {
379
-
window.confirm = vi.fn(() => true);
390
+
globalThis.confirm = vi.fn(() => true);
380
391
let capturedBody: Record<string, string> | null = null;
381
392
mockEndpoint(
382
393
"com.atproto.server.requestAccountDelete",
···
414
425
});
415
426
});
416
427
it("navigates to login after successful deletion", async () => {
417
-
window.confirm = vi.fn(() => true);
428
+
globalThis.confirm = vi.fn(() => true);
418
429
mockEndpoint(
419
430
"com.atproto.server.requestAccountDelete",
420
431
() => jsonResponse({}),
···
442
453
screen.getByRole("button", { name: /permanently delete account/i }),
443
454
);
444
455
await waitFor(() => {
445
-
expect(window.location.hash).toBe("#/login");
456
+
expect(globalThis.location.hash).toBe("#/login");
446
457
});
447
458
});
448
459
it("shows cancel button to return to request state", async () => {
···
480
491
});
481
492
});
482
493
it("shows error when deletion fails", async () => {
483
-
window.confirm = vi.fn(() => true);
494
+
globalThis.confirm = vi.fn(() => true);
484
495
mockEndpoint(
485
496
"com.atproto.server.requestAccountDelete",
486
497
() => jsonResponse({}),
+514
frontend/src/tests/migration/atproto-client.test.ts
+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
29
return match ? match[1] : url;
30
30
}
31
31
export function setupFetchMock(): void {
32
-
global.fetch = vi.fn(
32
+
globalThis.fetch = vi.fn(
33
33
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
34
34
const url = typeof input === "string" ? input : input.toString();
35
35
const endpoint = extractEndpoint(url);
···
137
137
signalVerified: false,
138
138
...overrides,
139
139
}),
140
-
describeServer: () => ({
140
+
describeServer: (overrides?: Record<string, unknown>) => ({
141
141
availableUserDomains: ["test.tranquil.dev"],
142
142
inviteCodeRequired: false,
143
143
links: {
···
145
145
termsOfService: "https://example.com/tos",
146
146
},
147
147
selfHostedDidWebEnabled: true,
148
+
availableCommsChannels: ["email", "discord", "telegram", "signal"],
149
+
...overrides,
148
150
}),
149
151
describeRepo: (did: string) => ({
150
152
handle: "testuser.test.tranquil.dev",
···
210
212
mockEndpoint(
211
213
"com.tranquil.account.updateNotificationPrefs",
212
214
() => jsonResponse({ success: true }),
215
+
);
216
+
mockEndpoint(
217
+
"com.tranquil.account.getNotificationHistory",
218
+
() => jsonResponse({ notifications: [] }),
213
219
);
214
220
mockEndpoint(
215
221
"com.atproto.server.requestEmailUpdate",
+12
-5
frontend/src/tests/setup.ts
+12
-5
frontend/src/tests/setup.ts
···
1
1
import "@testing-library/jest-dom/vitest";
2
2
import { afterEach, beforeEach, vi } from "vitest";
3
-
import { _testReset } from "../lib/auth.svelte";
3
+
import { init, register, waitLocale } from "svelte-i18n";
4
+
import { _testResetState } from "../lib/auth.svelte";
5
+
6
+
register("en", () => import("../locales/en.json"));
7
+
8
+
init({
9
+
fallbackLocale: "en",
10
+
initialLocale: "en",
11
+
});
4
12
5
13
let locationHash = "";
6
14
···
24
32
configurable: true,
25
33
});
26
34
27
-
beforeEach(() => {
35
+
beforeEach(async () => {
28
36
vi.clearAllMocks();
29
-
localStorage.clear();
30
-
sessionStorage.clear();
31
37
locationHash = "";
32
-
_testReset();
38
+
_testResetState();
39
+
await waitLocale();
33
40
});
34
41
35
42
afterEach(() => {
+1
frontend/svelte.config.js
+1
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
108
serde_json::from_value(row.value_json).ok()
109
109
})
110
110
.collect();
111
-
if let Some(ref pref) = personal_details_pref {
112
-
if let Some(birth_date) = pref.get("birthDate").and_then(|v| v.as_str()) {
113
-
if let Some(age) = get_age_from_datestring(birth_date) {
114
-
let declared_age_pref = json!({
115
-
"$type": DECLARED_AGE_PREF,
116
-
"isOverAge13": age >= 13,
117
-
"isOverAge16": age >= 16,
118
-
"isOverAge18": age >= 18,
119
-
});
120
-
preferences.push(declared_age_pref);
121
-
}
122
-
}
111
+
if let Some(age) = personal_details_pref
112
+
.as_ref()
113
+
.and_then(|pref| pref.get("birthDate"))
114
+
.and_then(|v| v.as_str())
115
+
.and_then(get_age_from_datestring)
116
+
{
117
+
let declared_age_pref = json!({
118
+
"$type": DECLARED_AGE_PREF,
119
+
"isOverAge13": age >= 13,
120
+
"isOverAge16": age >= 16,
121
+
"isOverAge18": age >= 18,
122
+
});
123
+
preferences.push(declared_age_pref);
123
124
}
124
125
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
125
126
}
···
157
158
}
158
159
};
159
160
let has_full_access = auth_user.permissions().has_full_access();
160
-
let user_id: uuid::Uuid = match sqlx::query_scalar!(
161
-
"SELECT id FROM users WHERE did = $1",
162
-
auth_user.did
163
-
)
164
-
.fetch_optional(&state.db)
165
-
.await
166
-
{
167
-
Ok(Some(id)) => id,
168
-
_ => {
169
-
return (
170
-
StatusCode::INTERNAL_SERVER_ERROR,
171
-
Json(json!({"error": "InternalError", "message": "User not found"})),
172
-
)
173
-
.into_response();
174
-
}
175
-
};
161
+
let user_id: uuid::Uuid =
162
+
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
163
+
.fetch_optional(&state.db)
164
+
.await
165
+
{
166
+
Ok(Some(id)) => id,
167
+
_ => {
168
+
return (
169
+
StatusCode::INTERNAL_SERVER_ERROR,
170
+
Json(json!({"error": "InternalError", "message": "User not found"})),
171
+
)
172
+
.into_response();
173
+
}
174
+
};
176
175
if input.preferences.len() > MAX_PREFERENCES_COUNT {
177
176
return (
178
177
StatusCode::BAD_REQUEST,
+1
-4
src/api/admin/account/info.rs
+1
-4
src/api/admin/account/info.rs
···
125
125
}
126
126
}
127
127
128
-
async fn get_invited_by(
129
-
db: &sqlx::PgPool,
130
-
user_id: uuid::Uuid,
131
-
) -> Option<InviteCodeInfo> {
128
+
async fn get_invited_by(db: &sqlx::PgPool, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
132
129
let use_row = sqlx::query!(
133
130
r#"
134
131
SELECT icu.code
+9
-1
src/api/admin/account/search.rs
+9
-1
src/api/admin/account/search.rs
···
91
91
.into_iter()
92
92
.take(limit as usize)
93
93
.map(
94
-
|(did, handle, email, created_at, email_verified, deactivated_at, invites_disabled)| {
94
+
|(
95
+
did,
96
+
handle,
97
+
email,
98
+
created_at,
99
+
email_verified,
100
+
deactivated_at,
101
+
invites_disabled,
102
+
)| {
95
103
AccountView {
96
104
did: did.clone(),
97
105
handle,
+4
-1
src/api/admin/account/update.rs
+4
-1
src/api/admin/account/update.rs
···
131
131
if let Err(e) =
132
132
crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await
133
133
{
134
-
warn!("Failed to sequence identity event for admin handle update: {}", e);
134
+
warn!(
135
+
"Failed to sequence identity event for admin handle update: {}",
136
+
e
137
+
);
135
138
}
136
139
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await
137
140
{
+8
-3
src/api/identity/account.rs
+8
-3
src/api/identity/account.rs
···
1005
1005
{
1006
1006
warn!("Failed to sequence account event for {}: {}", did, e);
1007
1007
}
1008
-
if let Err(e) =
1009
-
crate::api::repo::record::sequence_genesis_commit(&state, &did, &commit_cid, &mst_root, &rev_str).await
1008
+
if let Err(e) = crate::api::repo::record::sequence_genesis_commit(
1009
+
&state,
1010
+
&did,
1011
+
&commit_cid,
1012
+
&mst_root,
1013
+
&rev_str,
1014
+
)
1015
+
.await
1010
1016
{
1011
1017
warn!("Failed to sequence commit event for {}: {}", did, e);
1012
1018
}
···
1144
1150
)
1145
1151
.into_response()
1146
1152
}
1147
-
+74
-74
src/api/identity/did.rs
+74
-74
src/api/identity/did.rs
···
191
191
192
192
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
193
193
194
-
if let Some(ref ovr) = overrides {
195
-
if let Ok(parsed) =
196
-
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
197
-
{
198
-
if !parsed.is_empty() {
199
-
let also_known_as = if !ovr.also_known_as.is_empty() {
200
-
ovr.also_known_as.clone()
201
-
} else {
202
-
vec![format!("at://{}", full_handle)]
203
-
};
194
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
195
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
196
+
.ok()
197
+
.filter(|p| !p.is_empty())
198
+
.map(|p| (ovr, p))
199
+
}) {
200
+
let also_known_as = if !ovr.also_known_as.is_empty() {
201
+
ovr.also_known_as.clone()
202
+
} else {
203
+
vec![format!("at://{}", full_handle)]
204
+
};
204
205
205
-
return Json(json!({
206
-
"@context": [
207
-
"https://www.w3.org/ns/did/v1",
208
-
"https://w3id.org/security/multikey/v1",
209
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
210
-
],
211
-
"id": did,
212
-
"alsoKnownAs": also_known_as,
213
-
"verificationMethod": parsed.iter().map(|m| json!({
214
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
215
-
"type": m.method_type,
216
-
"controller": did,
217
-
"publicKeyMultibase": m.public_key_multibase
218
-
})).collect::<Vec<_>>(),
219
-
"service": [{
220
-
"id": "#atproto_pds",
221
-
"type": "AtprotoPersonalDataServer",
222
-
"serviceEndpoint": service_endpoint
223
-
}]
224
-
}))
225
-
.into_response();
226
-
}
227
-
}
206
+
return Json(json!({
207
+
"@context": [
208
+
"https://www.w3.org/ns/did/v1",
209
+
"https://w3id.org/security/multikey/v1",
210
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
211
+
],
212
+
"id": did,
213
+
"alsoKnownAs": also_known_as,
214
+
"verificationMethod": parsed.iter().map(|m| json!({
215
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
216
+
"type": m.method_type,
217
+
"controller": did,
218
+
"publicKeyMultibase": m.public_key_multibase
219
+
})).collect::<Vec<_>>(),
220
+
"service": [{
221
+
"id": "#atproto_pds",
222
+
"type": "AtprotoPersonalDataServer",
223
+
"serviceEndpoint": service_endpoint
224
+
}]
225
+
}))
226
+
.into_response();
228
227
}
229
228
230
229
let key_row = sqlx::query!(
···
351
350
352
351
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
353
352
354
-
if let Some(ref ovr) = overrides {
355
-
if let Ok(parsed) =
356
-
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
357
-
{
358
-
if !parsed.is_empty() {
359
-
let also_known_as = if !ovr.also_known_as.is_empty() {
360
-
ovr.also_known_as.clone()
361
-
} else {
362
-
vec![format!("at://{}", full_handle)]
363
-
};
353
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
354
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
355
+
.ok()
356
+
.filter(|p| !p.is_empty())
357
+
.map(|p| (ovr, p))
358
+
}) {
359
+
let also_known_as = if !ovr.also_known_as.is_empty() {
360
+
ovr.also_known_as.clone()
361
+
} else {
362
+
vec![format!("at://{}", full_handle)]
363
+
};
364
364
365
-
return Json(json!({
366
-
"@context": [
367
-
"https://www.w3.org/ns/did/v1",
368
-
"https://w3id.org/security/multikey/v1",
369
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
370
-
],
371
-
"id": did,
372
-
"alsoKnownAs": also_known_as,
373
-
"verificationMethod": parsed.iter().map(|m| json!({
374
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
375
-
"type": m.method_type,
376
-
"controller": did,
377
-
"publicKeyMultibase": m.public_key_multibase
378
-
})).collect::<Vec<_>>(),
379
-
"service": [{
380
-
"id": "#atproto_pds",
381
-
"type": "AtprotoPersonalDataServer",
382
-
"serviceEndpoint": service_endpoint
383
-
}]
384
-
}))
385
-
.into_response();
386
-
}
387
-
}
365
+
return Json(json!({
366
+
"@context": [
367
+
"https://www.w3.org/ns/did/v1",
368
+
"https://w3id.org/security/multikey/v1",
369
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
370
+
],
371
+
"id": did,
372
+
"alsoKnownAs": also_known_as,
373
+
"verificationMethod": parsed.iter().map(|m| json!({
374
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
375
+
"type": m.method_type,
376
+
"controller": did,
377
+
"publicKeyMultibase": m.public_key_multibase
378
+
})).collect::<Vec<_>>(),
379
+
"service": [{
380
+
"id": "#atproto_pds",
381
+
"type": "AtprotoPersonalDataServer",
382
+
"serviceEndpoint": service_endpoint
383
+
}]
384
+
}))
385
+
.into_response();
388
386
}
389
387
390
388
let key_row = sqlx::query!(
···
637
635
let server_rotation_key = match std::env::var("PLC_ROTATION_KEY") {
638
636
Ok(key) => key,
639
637
Err(_) => {
640
-
warn!("PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation");
638
+
warn!(
639
+
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
640
+
);
641
641
did_key.clone()
642
642
}
643
643
};
···
709
709
)
710
710
.into_response();
711
711
}
712
-
let user_row = match sqlx::query!(
713
-
"SELECT id, handle FROM users WHERE did = $1",
714
-
did
715
-
)
716
-
.fetch_optional(&state.db)
717
-
.await
712
+
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
713
+
.fetch_optional(&state.db)
714
+
.await
718
715
{
719
716
Ok(Some(row)) => row,
720
717
_ => return ApiError::InternalError.into_response(),
···
879
876
match result {
880
877
Ok(_) => {
881
878
if !current_handle.is_empty() {
882
-
let _ = state.cache.delete(&format!("handle:{}", current_handle)).await;
879
+
let _ = state
880
+
.cache
881
+
.delete(&format!("handle:{}", current_handle))
882
+
.await;
883
883
}
884
884
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
885
885
if let Err(e) =
+18
-20
src/api/identity/plc/submit.rs
+18
-20
src/api/identity/plc/submit.rs
···
58
58
let op = &input.operation;
59
59
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
60
60
let public_url = format!("https://{}", hostname);
61
-
let user = match sqlx::query!(
62
-
"SELECT id, handle FROM users WHERE did = $1",
63
-
did
64
-
)
65
-
.fetch_optional(&state.db)
66
-
.await
61
+
let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
62
+
.fetch_optional(&state.db)
63
+
.await
67
64
{
68
65
Ok(Some(row)) => row,
69
66
_ => {
···
170
167
)
171
168
.into_response();
172
169
}
173
-
if !user.handle.is_empty() {
174
-
if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) {
175
-
let expected_handle = format!("at://{}", user.handle);
176
-
let first_aka = also_known_as.first().and_then(|v| v.as_str());
177
-
if first_aka != Some(&expected_handle) {
178
-
return (
179
-
StatusCode::BAD_REQUEST,
180
-
Json(json!({
181
-
"error": "InvalidRequest",
182
-
"message": "Incorrect handle in alsoKnownAs"
183
-
})),
184
-
)
185
-
.into_response();
186
-
}
170
+
if let Some(also_known_as) = (!user.handle.is_empty())
171
+
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
172
+
.flatten()
173
+
{
174
+
let expected_handle = format!("at://{}", user.handle);
175
+
let first_aka = also_known_as.first().and_then(|v| v.as_str());
176
+
if first_aka != Some(&expected_handle) {
177
+
return (
178
+
StatusCode::BAD_REQUEST,
179
+
Json(json!({
180
+
"error": "InvalidRequest",
181
+
"message": "Incorrect handle in alsoKnownAs"
182
+
})),
183
+
)
184
+
.into_response();
187
185
}
188
186
}
189
187
let plc_client = PlcClient::new(None);
+7
-13
src/api/moderation/mod.rs
+7
-13
src/api/moderation/mod.rs
···
51
51
None => return ApiError::AuthenticationRequired.into_response(),
52
52
};
53
53
54
-
let auth_user = match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await
55
-
{
56
-
Ok(user) => user,
57
-
Err(e) => return ApiError::from(e).into_response(),
58
-
};
54
+
let auth_user =
55
+
match crate::auth::validate_bearer_token_allow_takendown(&state.db, &token).await {
56
+
Ok(user) => user,
57
+
Err(e) => return ApiError::from(e).into_response(),
58
+
};
59
59
60
60
let did = &auth_user.did;
61
61
62
62
if let Some((service_url, service_did)) = get_report_service_config() {
63
-
return proxy_to_report_service(
64
-
&state,
65
-
&auth_user,
66
-
&service_url,
67
-
&service_did,
68
-
&input,
69
-
)
70
-
.await;
63
+
return proxy_to_report_service(&state, &auth_user, &service_url, &service_did, &input)
64
+
.await;
71
65
}
72
66
73
67
create_report_locally(&state, did, auth_user.is_takendown, input).await
+4
-1
src/api/repo/import.rs
+4
-1
src/api/repo/import.rs
···
346
346
}
347
347
}
348
348
if blob_ref_count > 0 {
349
-
info!("Recorded {} blob references for imported repo", blob_ref_count);
349
+
info!(
350
+
"Recorded {} blob references for imported repo",
351
+
blob_ref_count
352
+
);
350
353
}
351
354
let key_row = match sqlx::query!(
352
355
r#"SELECT uk.key_bytes, uk.encryption_version
+1
-1
src/api/repo/record/batch.rs
+1
-1
src/api/repo/record/batch.rs
···
1
1
use super::validation::validate_record_with_status;
2
2
use super::write::has_verified_comms_channel;
3
-
use crate::validation::ValidationStatus;
4
3
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
5
4
use crate::delegation::{self, DelegationActionType};
6
5
use crate::repo::tracking::TrackingBlockStore;
7
6
use crate::state::AppState;
7
+
use crate::validation::ValidationStatus;
8
8
use axum::{
9
9
Json,
10
10
extract::State,
+1
-5
src/api/repo/record/delete.rs
+1
-5
src/api/repo/record/delete.rs
···
127
127
}
128
128
let prev_record_cid = mst.get(&key).await.ok().flatten();
129
129
if prev_record_cid.is_none() {
130
-
return (
131
-
StatusCode::OK,
132
-
Json(DeleteRecordOutput { commit: None }),
133
-
)
134
-
.into_response();
130
+
return (StatusCode::OK, Json(DeleteRecordOutput { commit: None })).into_response();
135
131
}
136
132
let new_mst = match mst.delete(&key).await {
137
133
Ok(m) => m,
+1
-1
src/api/repo/record/write.rs
+1
-1
src/api/repo/record/write.rs
···
1
1
use super::validation::validate_record_with_status;
2
-
use crate::validation::ValidationStatus;
3
2
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
3
use crate::delegation::{self, DelegationActionType};
5
4
use crate::repo::tracking::TrackingBlockStore;
6
5
use crate::state::AppState;
6
+
use crate::validation::ValidationStatus;
7
7
use axum::{
8
8
Json,
9
9
extract::State,
+11
-12
src/api/server/account_status.rs
+11
-12
src/api/server/account_status.rs
···
92
92
Ok(Some(row)) => (row.repo_root_cid, row.repo_rev),
93
93
_ => (String::new(), None),
94
94
};
95
-
let block_count: i64 =
96
-
sqlx::query_scalar!("SELECT COUNT(*) FROM user_blocks WHERE user_id = $1", user_id)
97
-
.fetch_one(&state.db)
98
-
.await
99
-
.unwrap_or(Some(0))
100
-
.unwrap_or(0);
95
+
let block_count: i64 = sqlx::query_scalar!(
96
+
"SELECT COUNT(*) FROM user_blocks WHERE user_id = $1",
97
+
user_id
98
+
)
99
+
.fetch_one(&state.db)
100
+
.await
101
+
.unwrap_or(Some(0))
102
+
.unwrap_or(0);
101
103
let repo_rev = if let Some(rev) = repo_rev_from_db {
102
104
rev
103
105
} else if !repo_commit.is_empty() {
···
241
243
let rotation_keys = doc_data
242
244
.get("rotationKeys")
243
245
.and_then(|v| v.as_array())
244
-
.map(|arr| {
245
-
arr.iter()
246
-
.filter_map(|k| k.as_str())
247
-
.collect::<Vec<_>>()
248
-
})
246
+
.map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>())
249
247
.unwrap_or_default();
250
248
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
251
249
return Err((
···
440
438
did
441
439
);
442
440
let did_validation_start = std::time::Instant::now();
443
-
if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await {
441
+
if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did, true).await
442
+
{
444
443
info!(
445
444
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
446
445
did,
+10
-12
src/api/server/email.rs
+10
-12
src/api/server/email.rs
···
86
86
"email_update",
87
87
¤t_email.to_lowercase(),
88
88
);
89
-
let formatted_code =
90
-
crate::auth::verification_token::format_token_for_display(&code);
89
+
let formatted_code = crate::auth::verification_token::format_token_for_display(&code);
91
90
92
-
let hostname =
93
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
94
-
if let Err(e) = crate::comms::enqueue_email_update_token(
95
-
&state.db,
96
-
user.id,
97
-
&formatted_code,
98
-
&hostname,
99
-
)
100
-
.await
91
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
92
+
if let Err(e) =
93
+
crate::comms::enqueue_email_update_token(&state.db, user.id, &formatted_code, &hostname)
94
+
.await
101
95
{
102
96
warn!("Failed to enqueue email update notification: {:?}", e);
103
97
}
104
98
}
105
99
106
100
info!("Email update requested for user {}", user.id);
107
-
(StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response()
101
+
(
102
+
StatusCode::OK,
103
+
Json(json!({ "tokenRequired": token_required })),
104
+
)
105
+
.into_response()
108
106
}
109
107
110
108
#[derive(Deserialize)]
+16
-17
src/api/server/invite.rs
+16
-17
src/api/server/invite.rs
···
1
1
use crate::api::ApiError;
2
-
use crate::auth::extractor::BearerAuthAdmin;
3
2
use crate::auth::BearerAuth;
3
+
use crate::auth::extractor::BearerAuthAdmin;
4
4
use crate::state::AppState;
5
5
use axum::{
6
6
Json,
···
114
114
.filter(|v| !v.is_empty())
115
115
.unwrap_or_else(|| vec![auth_user.did.clone()]);
116
116
117
-
let admin_user_id = match sqlx::query_scalar!(
118
-
"SELECT id FROM users WHERE is_admin = true LIMIT 1"
119
-
)
120
-
.fetch_optional(&state.db)
121
-
.await
122
-
{
123
-
Ok(Some(id)) => id,
124
-
Ok(None) => {
125
-
error!("No admin user found to create invite codes");
126
-
return ApiError::InternalError.into_response();
127
-
}
128
-
Err(e) => {
129
-
error!("DB error looking up admin user: {:?}", e);
130
-
return ApiError::InternalError.into_response();
131
-
}
132
-
};
117
+
let admin_user_id =
118
+
match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1")
119
+
.fetch_optional(&state.db)
120
+
.await
121
+
{
122
+
Ok(Some(id)) => id,
123
+
Ok(None) => {
124
+
error!("No admin user found to create invite codes");
125
+
return ApiError::InternalError.into_response();
126
+
}
127
+
Err(e) => {
128
+
error!("DB error looking up admin user: {:?}", e);
129
+
return ApiError::InternalError.into_response();
130
+
}
131
+
};
133
132
134
133
let mut result_codes = Vec::new();
135
134
+42
-49
src/api/server/migration.rs
+42
-49
src/api/server/migration.rs
···
332
332
333
333
if let Some(ref methods) = input.verification_methods {
334
334
if methods.is_empty() {
335
-
return ApiError::InvalidRequest(
336
-
"verification_methods cannot be empty".into(),
337
-
)
338
-
.into_response();
335
+
return ApiError::InvalidRequest("verification_methods cannot be empty".into())
336
+
.into_response();
339
337
}
340
338
for method in methods {
341
339
if method.id.is_empty() {
···
366
364
if let Some(ref handles) = input.also_known_as {
367
365
for handle in handles {
368
366
if !handle.starts_with("at://") {
369
-
return ApiError::InvalidRequest(
370
-
"alsoKnownAs entries must be at:// URIs".into(),
371
-
)
372
-
.into_response();
367
+
return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
368
+
.into_response();
373
369
}
374
370
}
375
371
}
···
377
373
if let Some(ref endpoint) = input.service_endpoint {
378
374
let endpoint = endpoint.trim();
379
375
if !endpoint.starts_with("https://") {
380
-
return ApiError::InvalidRequest(
381
-
"serviceEndpoint must start with https://".into(),
382
-
)
383
-
.into_response();
376
+
return ApiError::InvalidRequest("serviceEndpoint must start with https://".into())
377
+
.into_response();
384
378
}
385
379
}
386
380
···
523
517
.migrated_to_pds
524
518
.unwrap_or_else(|| format!("https://{}", hostname));
525
519
526
-
if let Some(ref ovr) = overrides {
527
-
if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
528
-
if !parsed.is_empty() {
529
-
let also_known_as = if !ovr.also_known_as.is_empty() {
530
-
ovr.also_known_as.clone()
531
-
} else {
532
-
vec![format!("at://{}", user.handle)]
533
-
};
534
-
return json!({
535
-
"@context": [
536
-
"https://www.w3.org/ns/did/v1",
537
-
"https://w3id.org/security/multikey/v1",
538
-
"https://w3id.org/security/suites/secp256k1-2019/v1"
539
-
],
540
-
"id": did,
541
-
"alsoKnownAs": also_known_as,
542
-
"verificationMethod": parsed.iter().map(|m| json!({
543
-
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
544
-
"type": m.method_type,
545
-
"controller": did,
546
-
"publicKeyMultibase": m.public_key_multibase
547
-
})).collect::<Vec<_>>(),
548
-
"service": [{
549
-
"id": "#atproto_pds",
550
-
"type": "AtprotoPersonalDataServer",
551
-
"serviceEndpoint": service_endpoint
552
-
}]
553
-
});
554
-
}
555
-
}
520
+
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
521
+
serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone())
522
+
.ok()
523
+
.filter(|p| !p.is_empty())
524
+
.map(|p| (ovr, p))
525
+
}) {
526
+
let also_known_as = if !ovr.also_known_as.is_empty() {
527
+
ovr.also_known_as.clone()
528
+
} else {
529
+
vec![format!("at://{}", user.handle)]
530
+
};
531
+
return json!({
532
+
"@context": [
533
+
"https://www.w3.org/ns/did/v1",
534
+
"https://w3id.org/security/multikey/v1",
535
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
536
+
],
537
+
"id": did,
538
+
"alsoKnownAs": also_known_as,
539
+
"verificationMethod": parsed.iter().map(|m| json!({
540
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
541
+
"type": m.method_type,
542
+
"controller": did,
543
+
"publicKeyMultibase": m.public_key_multibase
544
+
})).collect::<Vec<_>>(),
545
+
"service": [{
546
+
"id": "#atproto_pds",
547
+
"type": "AtprotoPersonalDataServer",
548
+
"serviceEndpoint": service_endpoint
549
+
}]
550
+
});
556
551
}
557
552
558
553
let key_row = sqlx::query!(
···
563
558
.await;
564
559
565
560
let public_key_multibase = match key_row {
566
-
Ok(Some(row)) => {
567
-
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
568
-
Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
569
-
.unwrap_or_else(|_| "error".to_string()),
570
-
Err(_) => "error".to_string(),
571
-
}
572
-
}
561
+
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
562
+
Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
563
+
.unwrap_or_else(|_| "error".to_string()),
564
+
Err(_) => "error".to_string(),
565
+
},
573
566
_ => "error".to_string(),
574
567
};
575
568
+104
-30
src/api/server/passkey_account.rs
+104
-30
src/api/server/passkey_account.rs
···
84
84
pub handle: String,
85
85
pub setup_token: String,
86
86
pub setup_expires_at: chrono::DateTime<Utc>,
87
+
#[serde(skip_serializing_if = "Option::is_none")]
88
+
pub access_jwt: Option<String>,
87
89
}
88
90
89
91
pub async fn create_passkey_account(
···
378
380
d.to_string()
379
381
}
380
382
_ => {
381
-
let rotation_key = std::env::var("PLC_ROTATION_KEY")
382
-
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
383
-
384
-
let genesis_result = match crate::plc::create_genesis_operation(
385
-
&secret_key,
386
-
&rotation_key,
387
-
&handle,
388
-
&pds_endpoint,
389
-
) {
390
-
Ok(r) => r,
391
-
Err(e) => {
392
-
error!("Error creating PLC genesis operation: {:?}", e);
383
+
if let Some(ref auth_did) = byod_auth {
384
+
if let Some(ref provided_did) = input.did {
385
+
if provided_did.starts_with("did:plc:") {
386
+
if provided_did != auth_did {
387
+
return (
388
+
StatusCode::FORBIDDEN,
389
+
Json(json!({
390
+
"error": "AuthorizationError",
391
+
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
392
+
})),
393
+
)
394
+
.into_response();
395
+
}
396
+
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
397
+
provided_did.clone()
398
+
} else {
399
+
return (
400
+
StatusCode::BAD_REQUEST,
401
+
Json(json!({
402
+
"error": "InvalidRequest",
403
+
"message": "BYOD migration requires a did:plc or did:web DID"
404
+
})),
405
+
)
406
+
.into_response();
407
+
}
408
+
} else {
393
409
return (
394
-
StatusCode::INTERNAL_SERVER_ERROR,
395
-
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
410
+
StatusCode::BAD_REQUEST,
411
+
Json(json!({
412
+
"error": "InvalidRequest",
413
+
"message": "BYOD migration requires the 'did' field"
414
+
})),
396
415
)
397
416
.into_response();
398
417
}
399
-
};
418
+
} else {
419
+
let rotation_key = std::env::var("PLC_ROTATION_KEY")
420
+
.unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
400
421
401
-
let plc_client = crate::plc::PlcClient::new(None);
402
-
if let Err(e) = plc_client
403
-
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
404
-
.await
405
-
{
406
-
error!("Failed to submit PLC genesis operation: {:?}", e);
407
-
return (
408
-
StatusCode::BAD_GATEWAY,
409
-
Json(json!({
410
-
"error": "UpstreamError",
411
-
"message": format!("Failed to register DID with PLC directory: {}", e)
412
-
})),
413
-
)
414
-
.into_response();
422
+
let genesis_result = match crate::plc::create_genesis_operation(
423
+
&secret_key,
424
+
&rotation_key,
425
+
&handle,
426
+
&pds_endpoint,
427
+
) {
428
+
Ok(r) => r,
429
+
Err(e) => {
430
+
error!("Error creating PLC genesis operation: {:?}", e);
431
+
return (
432
+
StatusCode::INTERNAL_SERVER_ERROR,
433
+
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
434
+
)
435
+
.into_response();
436
+
}
437
+
};
438
+
439
+
let plc_client = crate::plc::PlcClient::new(None);
440
+
if let Err(e) = plc_client
441
+
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
442
+
.await
443
+
{
444
+
error!("Failed to submit PLC genesis operation: {:?}", e);
445
+
return (
446
+
StatusCode::BAD_GATEWAY,
447
+
Json(json!({
448
+
"error": "UpstreamError",
449
+
"message": format!("Failed to register DID with PLC directory: {}", e)
450
+
})),
451
+
)
452
+
.into_response();
453
+
}
454
+
genesis_result.did
415
455
}
416
-
genesis_result.did
417
456
}
418
457
};
419
458
···
726
765
727
766
info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
728
767
768
+
let access_jwt = if byod_auth.is_some() {
769
+
match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) {
770
+
Ok(token_meta) => {
771
+
let refresh_jti = uuid::Uuid::new_v4().to_string();
772
+
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
773
+
let no_scope: Option<String> = None;
774
+
if let Err(e) = sqlx::query!(
775
+
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
776
+
did,
777
+
token_meta.jti,
778
+
refresh_jti,
779
+
token_meta.expires_at,
780
+
refresh_expires,
781
+
false,
782
+
false,
783
+
no_scope
784
+
)
785
+
.execute(&state.db)
786
+
.await
787
+
{
788
+
warn!(did = %did, "Failed to insert migration session: {:?}", e);
789
+
}
790
+
info!(did = %did, "Generated migration access token for BYOD passkey account");
791
+
Some(token_meta.token)
792
+
}
793
+
Err(e) => {
794
+
warn!(did = %did, "Failed to generate migration access token: {:?}", e);
795
+
None
796
+
}
797
+
}
798
+
} else {
799
+
None
800
+
};
801
+
729
802
Json(CreatePasskeyAccountResponse {
730
803
did,
731
804
handle,
732
805
setup_token,
733
806
setup_expires_at,
807
+
access_jwt,
734
808
})
735
809
.into_response()
736
810
}
+3
-6
src/api/server/session.rs
+3
-6
src/api/server/session.rs
···
334
334
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
335
335
let handle = full_handle(&row.handle, &pds_hostname);
336
336
let is_takendown = row.takedown_ref.is_some();
337
-
let is_migrated =
338
-
row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
337
+
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
339
338
let is_active = row.deactivated_at.is_none() && !is_takendown;
340
339
let email_value = if can_read_email {
341
340
row.email.clone()
···
368
367
if let Some(doc) = did_doc {
369
368
response["didDoc"] = doc;
370
369
}
371
-
Json(response)
372
-
.into_response()
370
+
Json(response).into_response()
373
371
}
374
372
Ok(None) => ApiError::AuthenticationFailed.into_response(),
375
373
Err(e) => {
···
613
611
} else if u.deactivated_at.is_some() {
614
612
response["status"] = json!("deactivated");
615
613
}
616
-
Json(response)
617
-
.into_response()
614
+
Json(response).into_response()
618
615
}
619
616
Ok(None) => {
620
617
error!("User not found for existing session: {}", session_row.did);
+2
-14
src/handle/reserved.rs
+2
-14
src/handle/reserved.rs
···
2
2
use std::sync::LazyLock;
3
3
4
4
const ATP_SPECIFIC: &[&str] = &[
5
-
"at",
6
-
"atp",
7
-
"plc",
8
-
"pds",
9
-
"did",
10
-
"repo",
11
-
"tid",
12
-
"nsid",
13
-
"xrpc",
14
-
"lex",
15
-
"lexicon",
16
-
"bsky",
17
-
"bluesky",
18
-
"handle",
5
+
"at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky",
6
+
"bluesky", "handle",
19
7
];
20
8
21
9
const COMMONLY_RESERVED: &[&str] = &[
+4
-1
src/main.rs
+4
-1
src/main.rs
···
5
5
use tracing::{error, info, warn};
6
6
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
7
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
-
use tranquil_pds::scheduled::{backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, start_scheduled_tasks};
8
+
use tranquil_pds::scheduled::{
9
+
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
10
+
start_scheduled_tasks,
11
+
};
9
12
use tranquil_pds::state::AppState;
10
13
11
14
#[tokio::main]
+5
-2
src/oauth/endpoints/metadata.rs
+5
-2
src/oauth/endpoints/metadata.rs
···
167
167
client_id,
168
168
client_name: "PDS Account Manager".to_string(),
169
169
client_uri: base_url.clone(),
170
-
redirect_uris: vec![format!("{}/", base_url)],
170
+
redirect_uris: vec![
171
+
format!("{}/", base_url),
172
+
format!("{}/migrate", base_url),
173
+
],
171
174
grant_types: vec![
172
175
"authorization_code".to_string(),
173
176
"refresh_token".to_string(),
174
177
],
175
178
response_types: vec!["code".to_string()],
176
-
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
179
+
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*"
177
180
.to_string(),
178
181
token_endpoint_auth_method: "none".to_string(),
179
182
application_type: "web".to_string(),
+33
-39
src/scheduled.rs
+33
-39
src/scheduled.rs
···
1
1
use cid::Cid;
2
+
use ipld_core::ipld::Ipld;
2
3
use jacquard_repo::commit::Commit;
3
4
use jacquard_repo::storage::BlockStore;
4
-
use ipld_core::ipld::Ipld;
5
5
use sqlx::PgPool;
6
6
use std::str::FromStr;
7
7
use std::sync::Arc;
···
107
107
}
108
108
}
109
109
110
-
info!(success, failed, "Completed genesis commit blocks_cids backfill");
110
+
info!(
111
+
success,
112
+
failed, "Completed genesis commit blocks_cids backfill"
113
+
);
111
114
}
112
115
113
116
pub async fn backfill_repo_rev(db: &PgPool, block_store: PostgresBlockStore) {
114
-
let repos_missing_rev = match sqlx::query!(
115
-
"SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL"
116
-
)
117
-
.fetch_all(db)
118
-
.await
119
-
{
120
-
Ok(rows) => rows,
121
-
Err(e) => {
122
-
error!("Failed to query repos for backfill: {}", e);
123
-
return;
124
-
}
125
-
};
117
+
let repos_missing_rev =
118
+
match sqlx::query!("SELECT user_id, repo_root_cid FROM repos WHERE repo_rev IS NULL")
119
+
.fetch_all(db)
120
+
.await
121
+
{
122
+
Ok(rows) => rows,
123
+
Err(e) => {
124
+
error!("Failed to query repos for backfill: {}", e);
125
+
return;
126
+
}
127
+
};
126
128
127
129
if repos_missing_rev.is_empty() {
128
130
debug!("No repos need repo_rev backfill");
···
244
246
if let Some(prev) = commit.prev {
245
247
to_visit.push(prev);
246
248
}
247
-
} else if let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
248
-
if let Ipld::Map(ref obj) = ipld {
249
-
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
250
-
to_visit.push(*left_cid);
251
-
}
252
-
if let Some(Ipld::List(entries)) = obj.get("e") {
253
-
for entry in entries {
254
-
if let Ipld::Map(entry_obj) = entry {
255
-
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
256
-
to_visit.push(*tree_cid);
257
-
}
258
-
if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") {
259
-
to_visit.push(*val_cid);
260
-
}
249
+
} else if let Ok(Ipld::Map(ref obj)) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
250
+
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
251
+
to_visit.push(*left_cid);
252
+
}
253
+
if let Some(Ipld::List(entries)) = obj.get("e") {
254
+
for entry in entries {
255
+
if let Ipld::Map(entry_obj) = entry {
256
+
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
257
+
to_visit.push(*tree_cid);
258
+
}
259
+
if let Some(Ipld::Link(val_cid)) = entry_obj.get("v") {
260
+
to_visit.push(*val_cid);
261
261
}
262
262
}
263
263
}
···
361
361
362
362
let blob_refs = crate::sync::import::find_blob_refs_ipld(&record_ipld, 0);
363
363
for blob_ref in blob_refs {
364
-
let record_uri = format!(
365
-
"at://{}/{}/{}",
366
-
user.did, record.collection, record.rkey
367
-
);
364
+
let record_uri = format!("at://{}/{}/{}", user.did, record.collection, record.rkey);
368
365
if let Err(e) = sqlx::query!(
369
366
r#"
370
367
INSERT INTO record_blobs (repo_id, record_uri, blob_cid)
···
490
487
did: &str,
491
488
_handle: &str,
492
489
) -> Result<(), String> {
493
-
let user_id: uuid::Uuid = sqlx::query_scalar!(
494
-
"SELECT id FROM users WHERE did = $1",
495
-
did
496
-
)
497
-
.fetch_one(db)
498
-
.await
499
-
.map_err(|e| format!("DB error fetching user: {}", e))?;
490
+
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
491
+
.fetch_one(db)
492
+
.await
493
+
.map_err(|e| format!("DB error fetching user: {}", e))?;
500
494
501
495
let blob_storage_keys: Vec<String> = sqlx::query_scalar!(
502
496
r#"SELECT storage_key as "storage_key!" FROM blobs WHERE created_by_user = $1"#,
+101
-28
tests/account_lifecycle.rs
+101
-28
tests/account_lifecycle.rs
···
11
11
let (access_jwt, did) = create_account_and_login(&client).await;
12
12
13
13
let status1 = client
14
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
14
+
.get(format!(
15
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
16
+
base
17
+
))
15
18
.bearer_auth(&access_jwt)
16
19
.send()
17
20
.await
···
19
22
assert_eq!(status1.status(), StatusCode::OK);
20
23
let body1: Value = status1.json().await.unwrap();
21
24
let initial_blocks = body1["repoBlocks"].as_i64().unwrap();
22
-
assert!(initial_blocks >= 2, "New account should have at least 2 blocks (commit + empty MST)");
25
+
assert!(
26
+
initial_blocks >= 2,
27
+
"New account should have at least 2 blocks (commit + empty MST)"
28
+
);
23
29
24
30
let create_res = client
25
31
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
···
38
44
.unwrap();
39
45
assert_eq!(create_res.status(), StatusCode::OK);
40
46
let create_body: Value = create_res.json().await.unwrap();
41
-
let rkey = create_body["uri"].as_str().unwrap().split('/').last().unwrap().to_string();
47
+
let rkey = create_body["uri"]
48
+
.as_str()
49
+
.unwrap()
50
+
.split('/')
51
+
.last()
52
+
.unwrap()
53
+
.to_string();
42
54
43
55
let status2 = client
44
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
56
+
.get(format!(
57
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
58
+
base
59
+
))
45
60
.bearer_auth(&access_jwt)
46
61
.send()
47
62
.await
48
63
.unwrap();
49
64
let body2: Value = status2.json().await.unwrap();
50
65
let after_create_blocks = body2["repoBlocks"].as_i64().unwrap();
51
-
assert!(after_create_blocks > initial_blocks, "Block count should increase after creating a record");
66
+
assert!(
67
+
after_create_blocks > initial_blocks,
68
+
"Block count should increase after creating a record"
69
+
);
52
70
53
71
let delete_res = client
54
72
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
···
64
82
assert_eq!(delete_res.status(), StatusCode::OK);
65
83
66
84
let status3 = client
67
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
85
+
.get(format!(
86
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
87
+
base
88
+
))
68
89
.bearer_auth(&access_jwt)
69
90
.send()
70
91
.await
···
86
107
let (access_jwt, _) = create_account_and_login(&client).await;
87
108
88
109
let status = client
89
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
110
+
.get(format!(
111
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
112
+
base
113
+
))
90
114
.bearer_auth(&access_jwt)
91
115
.send()
92
116
.await
···
96
120
97
121
let repo_rev = body["repoRev"].as_str().unwrap();
98
122
assert!(!repo_rev.is_empty(), "repoRev should not be empty");
99
-
assert!(repo_rev.chars().all(|c| c.is_alphanumeric()), "repoRev should be alphanumeric TID");
123
+
assert!(
124
+
repo_rev.chars().all(|c| c.is_alphanumeric()),
125
+
"repoRev should be alphanumeric TID"
126
+
);
100
127
}
101
128
102
129
#[tokio::test]
···
106
133
let (access_jwt, _) = create_account_and_login(&client).await;
107
134
108
135
let status = client
109
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
136
+
.get(format!(
137
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
138
+
base
139
+
))
110
140
.bearer_auth(&access_jwt)
111
141
.send()
112
142
.await
···
114
144
assert_eq!(status.status(), StatusCode::OK);
115
145
let body: Value = status.json().await.unwrap();
116
146
117
-
assert_eq!(body["validDid"], true, "validDid should be true for active account with correct DID document");
118
-
assert_eq!(body["activated"], true, "activated should be true for active account");
147
+
assert_eq!(
148
+
body["validDid"], true,
149
+
"validDid should be true for active account with correct DID document"
150
+
);
151
+
assert_eq!(
152
+
body["activated"], true,
153
+
"activated should be true for active account"
154
+
);
119
155
}
120
156
121
157
#[tokio::test]
···
128
164
let delete_after = future_time.to_rfc3339();
129
165
130
166
let deactivate = client
131
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
167
+
.post(format!(
168
+
"{}/xrpc/com.atproto.server.deactivateAccount",
169
+
base
170
+
))
132
171
.bearer_auth(&access_jwt)
133
172
.json(&json!({
134
173
"deleteAfter": delete_after
···
139
178
assert_eq!(deactivate.status(), StatusCode::OK);
140
179
141
180
let status = client
142
-
.get(format!("{}/xrpc/com.atproto.server.checkAccountStatus", base))
181
+
.get(format!(
182
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
183
+
base
184
+
))
143
185
.bearer_auth(&access_jwt)
144
186
.send()
145
187
.await
···
170
212
assert_eq!(create_res.status(), StatusCode::OK);
171
213
let body: Value = create_res.json().await.unwrap();
172
214
173
-
assert!(body["accessJwt"].is_string(), "accessJwt should always be returned");
174
-
assert!(body["refreshJwt"].is_string(), "refreshJwt should always be returned");
215
+
assert!(
216
+
body["accessJwt"].is_string(),
217
+
"accessJwt should always be returned"
218
+
);
219
+
assert!(
220
+
body["refreshJwt"].is_string(),
221
+
"refreshJwt should always be returned"
222
+
);
175
223
assert!(body["did"].is_string(), "did should be returned");
176
224
177
225
if body["didDoc"].is_object() {
···
201
249
assert_eq!(create_res.status(), StatusCode::OK);
202
250
let body: Value = create_res.json().await.unwrap();
203
251
204
-
let access_jwt = body["accessJwt"].as_str().expect("accessJwt should be present");
205
-
let refresh_jwt = body["refreshJwt"].as_str().expect("refreshJwt should be present");
252
+
let access_jwt = body["accessJwt"]
253
+
.as_str()
254
+
.expect("accessJwt should be present");
255
+
let refresh_jwt = body["refreshJwt"]
256
+
.as_str()
257
+
.expect("refreshJwt should be present");
206
258
207
259
assert!(!access_jwt.is_empty(), "accessJwt should not be empty");
208
260
assert!(!refresh_jwt.is_empty(), "refreshJwt should not be empty");
209
261
210
262
let parts: Vec<&str> = access_jwt.split('.').collect();
211
-
assert_eq!(parts.len(), 3, "accessJwt should be a valid JWT with 3 parts");
263
+
assert_eq!(
264
+
parts.len(),
265
+
3,
266
+
"accessJwt should be a valid JWT with 3 parts"
267
+
);
212
268
}
213
269
214
270
#[tokio::test]
···
224
280
assert_eq!(describe.status(), StatusCode::OK);
225
281
let body: Value = describe.json().await.unwrap();
226
282
227
-
assert!(body.get("links").is_some(), "describeServer should include links object");
228
-
assert!(body.get("contact").is_some(), "describeServer should include contact object");
283
+
assert!(
284
+
body.get("links").is_some(),
285
+
"describeServer should include links object"
286
+
);
287
+
assert!(
288
+
body.get("contact").is_some(),
289
+
"describeServer should include contact object"
290
+
);
229
291
230
292
let links = &body["links"];
231
-
assert!(links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(),
232
-
"links should have privacyPolicy field (can be null)");
233
-
assert!(links.get("termsOfService").is_some() || links["termsOfService"].is_null(),
234
-
"links should have termsOfService field (can be null)");
293
+
assert!(
294
+
links.get("privacyPolicy").is_some() || links["privacyPolicy"].is_null(),
295
+
"links should have privacyPolicy field (can be null)"
296
+
);
297
+
assert!(
298
+
links.get("termsOfService").is_some() || links["termsOfService"].is_null(),
299
+
"links should have termsOfService field (can be null)"
300
+
);
235
301
236
302
let contact = &body["contact"];
237
-
assert!(contact.get("email").is_some() || contact["email"].is_null(),
238
-
"contact should have email field (can be null)");
303
+
assert!(
304
+
contact.get("email").is_some() || contact["email"].is_null(),
305
+
"contact should have email field (can be null)"
306
+
);
239
307
}
240
308
241
309
#[tokio::test]
···
274
342
275
343
assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST);
276
344
let error_body: Value = delete_res.json().await.unwrap();
277
-
assert!(error_body["message"].as_str().unwrap().contains("password length")
278
-
|| error_body["error"].as_str().unwrap() == "InvalidRequest");
345
+
assert!(
346
+
error_body["message"]
347
+
.as_str()
348
+
.unwrap()
349
+
.contains("password length")
350
+
|| error_body["error"].as_str().unwrap() == "InvalidRequest"
351
+
);
279
352
}
+1
-3
tests/account_notifications.rs
+1
-3
tests/account_notifications.rs
+9
-2
tests/actor.rs
+9
-2
tests/actor.rs
···
174
174
let body: Value = get_resp.json().await.unwrap();
175
175
let prefs_arr = body["preferences"].as_array().unwrap();
176
176
assert_eq!(prefs_arr.len(), 1);
177
-
assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#adultContentPref");
177
+
assert_eq!(
178
+
prefs_arr[0]["$type"],
179
+
"app.bsky.actor.defs#adultContentPref"
180
+
);
178
181
}
179
182
180
183
#[tokio::test]
···
393
396
let client = client();
394
397
let base = base_url().await;
395
398
let (token, _did) = create_account_and_login(&client).await;
396
-
let current_year = chrono::Utc::now().format("%Y").to_string().parse::<i32>().unwrap();
399
+
let current_year = chrono::Utc::now()
400
+
.format("%Y")
401
+
.to_string()
402
+
.parse::<i32>()
403
+
.unwrap();
397
404
let birth_year = current_year - 15;
398
405
let prefs = json!({
399
406
"preferences": [
+4
-1
tests/admin_invite.rs
+4
-1
tests/admin_invite.rs
···
217
217
.expect("Failed to get invite codes");
218
218
let list_body: Value = list_res.json().await.unwrap();
219
219
let codes = list_body["codes"].as_array().unwrap();
220
-
let admin_codes: Vec<_> = codes.iter().filter(|c| c["forAccount"].as_str() == Some(&did)).collect();
220
+
let admin_codes: Vec<_> = codes
221
+
.iter()
222
+
.filter(|c| c["forAccount"].as_str() == Some(&did))
223
+
.collect();
221
224
for code in admin_codes {
222
225
assert_eq!(code["disabled"], true);
223
226
}
+20
-5
tests/did_web.rs
+20
-5
tests/did_web.rs
···
569
569
let jwt = verify_new_account(&client, &did).await;
570
570
let target_pds = "https://pds2.example.com";
571
571
let res = client
572
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
572
+
.post(format!(
573
+
"{}/xrpc/com.atproto.server.deactivateAccount",
574
+
base
575
+
))
573
576
.bearer_auth(&jwt)
574
577
.json(&json!({ "migratingTo": target_pds }))
575
578
.send()
···
633
636
.expect("Failed to send request");
634
637
assert_eq!(res.status(), StatusCode::OK);
635
638
let res = client
636
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
639
+
.post(format!(
640
+
"{}/xrpc/com.atproto.server.deactivateAccount",
641
+
base
642
+
))
637
643
.bearer_auth(&jwt)
638
644
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
639
645
.send()
···
770
776
);
771
777
let target_pds = "https://pds3.example.com";
772
778
let res = client
773
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
779
+
.post(format!(
780
+
"{}/xrpc/com.atproto.server.deactivateAccount",
781
+
base
782
+
))
774
783
.bearer_auth(&jwt)
775
784
.json(&json!({ "migratingTo": target_pds }))
776
785
.send()
···
785
794
.expect("Failed to send request");
786
795
assert_eq!(res.status(), StatusCode::OK);
787
796
let body: Value = res.json().await.expect("Response was not JSON");
788
-
assert_eq!(body["active"], false, "Migrated account should not be active");
797
+
assert_eq!(
798
+
body["active"], false,
799
+
"Migrated account should not be active"
800
+
);
789
801
assert_eq!(
790
802
body["status"], "migrated",
791
803
"Status should be 'migrated' after migration"
···
819
831
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
820
832
let jwt = verify_new_account(&client, &did).await;
821
833
let res = client
822
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
834
+
.post(format!(
835
+
"{}/xrpc/com.atproto.server.deactivateAccount",
836
+
base
837
+
))
823
838
.bearer_auth(&jwt)
824
839
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
825
840
.send()
+32
-19
tests/email_update.rs
+32
-19
tests/email_update.rs
···
112
112
.expect("Failed to update email");
113
113
assert_eq!(res.status(), StatusCode::OK);
114
114
115
-
let user_email: Option<String> = sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did)
116
-
.fetch_one(pool)
117
-
.await
118
-
.expect("User not found");
115
+
let user_email: Option<String> =
116
+
sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did)
117
+
.fetch_one(pool)
118
+
.await
119
+
.expect("User not found");
119
120
assert_eq!(user_email, Some(new_email));
120
121
}
121
122
···
255
256
assert_eq!(res.status(), StatusCode::OK);
256
257
let body: Value = res.json().await.expect("Invalid JSON");
257
258
let did = body["did"].as_str().expect("No did").to_string();
258
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
259
+
let access_jwt = body["accessJwt"]
260
+
.as_str()
261
+
.expect("No accessJwt")
262
+
.to_string();
259
263
260
264
let body_text: String = sqlx::query_scalar!(
261
265
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
283
287
.expect("Failed to confirm email");
284
288
assert_eq!(res.status(), StatusCode::OK);
285
289
286
-
let verified: bool = sqlx::query_scalar!(
287
-
"SELECT email_verified FROM users WHERE did = $1",
288
-
did
289
-
)
290
-
.fetch_one(pool)
291
-
.await
292
-
.expect("User not found");
290
+
let verified: bool =
291
+
sqlx::query_scalar!("SELECT email_verified FROM users WHERE did = $1", did)
292
+
.fetch_one(pool)
293
+
.await
294
+
.expect("User not found");
293
295
assert!(verified);
294
296
}
295
297
···
317
319
assert_eq!(res.status(), StatusCode::OK);
318
320
let body: Value = res.json().await.expect("Invalid JSON");
319
321
let did = body["did"].as_str().expect("No did").to_string();
320
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
322
+
let access_jwt = body["accessJwt"]
323
+
.as_str()
324
+
.expect("No accessJwt")
325
+
.to_string();
321
326
322
327
let body_text: String = sqlx::query_scalar!(
323
328
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
···
370
375
.expect("Failed to create account");
371
376
assert_eq!(res.status(), StatusCode::OK);
372
377
let body: Value = res.json().await.expect("Invalid JSON");
373
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
378
+
let access_jwt = body["accessJwt"]
379
+
.as_str()
380
+
.expect("No accessJwt")
381
+
.to_string();
374
382
375
383
let res = client
376
384
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
···
411
419
assert_eq!(res.status(), StatusCode::OK);
412
420
let body: Value = res.json().await.expect("Invalid JSON");
413
421
let did = body["did"].as_str().expect("No did").to_string();
414
-
let access_jwt = body["accessJwt"].as_str().expect("No accessJwt").to_string();
422
+
let access_jwt = body["accessJwt"]
423
+
.as_str()
424
+
.expect("No accessJwt")
425
+
.to_string();
415
426
416
427
let res = client
417
428
.post(format!(
···
491
502
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
492
503
let body: Value = res.json().await.expect("Invalid JSON");
493
504
assert_eq!(body["error"], "InvalidRequest");
494
-
assert!(body["message"]
495
-
.as_str()
496
-
.unwrap_or("")
497
-
.contains("already in use"));
505
+
assert!(
506
+
body["message"]
507
+
.as_str()
508
+
.unwrap_or("")
509
+
.contains("already in use")
510
+
);
498
511
}
+4
-1
tests/identity.rs
+4
-1
tests/identity.rs
···
393
393
.await
394
394
.expect("Failed to get session");
395
395
let session_body: Value = session.json().await.expect("Invalid JSON");
396
-
let current_handle = session_body["handle"].as_str().expect("No handle").to_string();
396
+
let current_handle = session_body["handle"]
397
+
.as_str()
398
+
.expect("No handle")
399
+
.to_string();
397
400
let short_handle = current_handle.split('.').next().unwrap_or(¤t_handle);
398
401
let res = client
399
402
.post(format!(
+13
-3
tests/invite.rs
+13
-3
tests/invite.rs
···
25
25
assert!(body["code"].is_string());
26
26
let code = body["code"].as_str().unwrap();
27
27
assert!(!code.is_empty());
28
-
assert!(code.contains('-'), "Code should be in hostname-xxxxx-xxxxx format");
28
+
assert!(
29
+
code.contains('-'),
30
+
"Code should be in hostname-xxxxx-xxxxx format"
31
+
);
29
32
let parts: Vec<&str> = code.split('-').collect();
30
-
assert!(parts.len() >= 3, "Code should have at least 3 parts (hostname + 2 random parts)");
33
+
assert!(
34
+
parts.len() >= 3,
35
+
"Code should have at least 3 parts (hostname + 2 random parts)"
36
+
);
31
37
}
32
38
33
39
#[tokio::test]
···
363
369
let body: Value = res.json().await.expect("Response was not valid JSON");
364
370
let codes = body["codes"].as_array().unwrap();
365
371
for c in codes {
366
-
assert_ne!(c["code"].as_str().unwrap(), code, "Disabled code should be filtered out");
372
+
assert_ne!(
373
+
c["code"].as_str().unwrap(),
374
+
code,
375
+
"Disabled code should be filtered out"
376
+
);
367
377
}
368
378
}
+20
-5
tests/lifecycle_session.rs
+20
-5
tests/lifecycle_session.rs
···
291
291
let base = base_url().await;
292
292
let (jwt, _did) = create_account_and_login(&client).await;
293
293
let create_res = client
294
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
294
+
.post(format!(
295
+
"{}/xrpc/com.atproto.server.createAppPassword",
296
+
base
297
+
))
295
298
.bearer_auth(&jwt)
296
299
.json(&json!({ "name": "My App" }))
297
300
.send()
···
299
302
.expect("Failed to create app password");
300
303
assert_eq!(create_res.status(), StatusCode::OK);
301
304
let duplicate_res = client
302
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
305
+
.post(format!(
306
+
"{}/xrpc/com.atproto.server.createAppPassword",
307
+
base
308
+
))
303
309
.bearer_auth(&jwt)
304
310
.json(&json!({ "name": "My App" }))
305
311
.send()
···
320
326
let base = base_url().await;
321
327
let (jwt, _did) = create_account_and_login(&client).await;
322
328
let revoke_res = client
323
-
.post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base))
329
+
.post(format!(
330
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
331
+
base
332
+
))
324
333
.bearer_auth(&jwt)
325
334
.json(&json!({ "name": "Does Not Exist" }))
326
335
.send()
···
356
365
let did = account["did"].as_str().unwrap();
357
366
let main_jwt = verify_new_account(&client, did).await;
358
367
let create_app_res = client
359
-
.post(format!("{}/xrpc/com.atproto.server.createAppPassword", base))
368
+
.post(format!(
369
+
"{}/xrpc/com.atproto.server.createAppPassword",
370
+
base
371
+
))
360
372
.bearer_auth(&main_jwt)
361
373
.json(&json!({ "name": "Session Test App" }))
362
374
.send()
···
389
401
"App password session should be valid before revocation"
390
402
);
391
403
let revoke_res = client
392
-
.post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base))
404
+
.post(format!(
405
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
406
+
base
407
+
))
393
408
.bearer_auth(&main_jwt)
394
409
.json(&json!({ "name": "Session Test App" }))
395
410
.send()
+2
-8
tests/moderation.rs
+2
-8
tests/moderation.rs
···
85
85
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
86
86
let body: Value = res.json().await.unwrap();
87
87
assert_eq!(body["error"], "InvalidRequest");
88
-
assert!(body["message"]
89
-
.as_str()
90
-
.unwrap()
91
-
.contains("reasonType"));
88
+
assert!(body["message"].as_str().unwrap().contains("reasonType"));
92
89
}
93
90
94
91
#[tokio::test]
···
266
263
);
267
264
let body: Value = report_res.json().await.unwrap();
268
265
assert_eq!(body["error"], "InvalidRequest");
269
-
assert!(body["message"]
270
-
.as_str()
271
-
.unwrap()
272
-
.contains("takendown"));
266
+
assert!(body["message"].as_str().unwrap().contains("takendown"));
273
267
}
+1
-2
tests/oauth_lifecycle.rs
+1
-2
tests/oauth_lifecycle.rs
···
949
949
let url = base_url().await;
950
950
let http_client = client();
951
951
let (alice, _mock_alice) =
952
-
create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback")
953
-
.await;
952
+
create_user_and_oauth_session("alice-isol", "https://alice.example.com/callback").await;
954
953
let (bob, _mock_bob) =
955
954
create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
956
955
let collection = "app.bsky.feed.post";
+173
-42
tests/repo_conformance.rs
+173
-42
tests/repo_conformance.rs
···
23
23
});
24
24
25
25
let res = client
26
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
26
+
.post(format!(
27
+
"{}/xrpc/com.atproto.repo.createRecord",
28
+
base_url().await
29
+
))
27
30
.bearer_auth(&jwt)
28
31
.json(&payload)
29
32
.send()
···
35
38
36
39
assert!(body["uri"].is_string(), "response must have uri");
37
40
assert!(body["cid"].is_string(), "response must have cid");
38
-
assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid");
41
+
assert!(
42
+
body["cid"].as_str().unwrap().starts_with("bafy"),
43
+
"cid must be valid"
44
+
);
39
45
40
-
assert!(body["commit"].is_object(), "response must have commit object");
46
+
assert!(
47
+
body["commit"].is_object(),
48
+
"response must have commit object"
49
+
);
41
50
let commit = &body["commit"];
42
51
assert!(commit["cid"].is_string(), "commit must have cid");
43
-
assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid");
52
+
assert!(
53
+
commit["cid"].as_str().unwrap().starts_with("bafy"),
54
+
"commit.cid must be valid"
55
+
);
44
56
assert!(commit["rev"].is_string(), "commit must have rev");
45
57
46
-
assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true");
47
-
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
58
+
assert!(
59
+
body["validationStatus"].is_string(),
60
+
"response must have validationStatus when validate defaults to true"
61
+
);
62
+
assert_eq!(
63
+
body["validationStatus"], "valid",
64
+
"validationStatus should be 'valid'"
65
+
);
48
66
}
49
67
50
68
#[tokio::test]
···
65
83
});
66
84
67
85
let res = client
68
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
86
+
.post(format!(
87
+
"{}/xrpc/com.atproto.repo.createRecord",
88
+
base_url().await
89
+
))
69
90
.bearer_auth(&jwt)
70
91
.json(&payload)
71
92
.send()
···
77
98
78
99
assert!(body["uri"].is_string());
79
100
assert!(body["commit"].is_object());
80
-
assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false");
101
+
assert!(
102
+
body["validationStatus"].is_null(),
103
+
"validationStatus should be omitted when validate=false"
104
+
);
81
105
}
82
106
83
107
#[tokio::test]
···
98
122
});
99
123
100
124
let res = client
101
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
125
+
.post(format!(
126
+
"{}/xrpc/com.atproto.repo.putRecord",
127
+
base_url().await
128
+
))
102
129
.bearer_auth(&jwt)
103
130
.json(&payload)
104
131
.send()
···
111
138
assert!(body["uri"].is_string(), "response must have uri");
112
139
assert!(body["cid"].is_string(), "response must have cid");
113
140
114
-
assert!(body["commit"].is_object(), "response must have commit object");
141
+
assert!(
142
+
body["commit"].is_object(),
143
+
"response must have commit object"
144
+
);
115
145
let commit = &body["commit"];
116
146
assert!(commit["cid"].is_string(), "commit must have cid");
117
147
assert!(commit["rev"].is_string(), "commit must have rev");
118
148
119
-
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
149
+
assert_eq!(
150
+
body["validationStatus"], "valid",
151
+
"validationStatus should be 'valid'"
152
+
);
120
153
}
121
154
122
155
#[tokio::test]
···
136
169
}
137
170
});
138
171
let create_res = client
139
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
172
+
.post(format!(
173
+
"{}/xrpc/com.atproto.repo.putRecord",
174
+
base_url().await
175
+
))
140
176
.bearer_auth(&jwt)
141
177
.json(&create_payload)
142
178
.send()
···
150
186
"rkey": "to-delete"
151
187
});
152
188
let delete_res = client
153
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
189
+
.post(format!(
190
+
"{}/xrpc/com.atproto.repo.deleteRecord",
191
+
base_url().await
192
+
))
154
193
.bearer_auth(&jwt)
155
194
.json(&delete_payload)
156
195
.send()
···
160
199
assert_eq!(delete_res.status(), StatusCode::OK);
161
200
let body: Value = delete_res.json().await.unwrap();
162
201
163
-
assert!(body["commit"].is_object(), "response must have commit object when record was deleted");
202
+
assert!(
203
+
body["commit"].is_object(),
204
+
"response must have commit object when record was deleted"
205
+
);
164
206
let commit = &body["commit"];
165
207
assert!(commit["cid"].is_string(), "commit must have cid");
166
208
assert!(commit["rev"].is_string(), "commit must have rev");
···
177
219
"rkey": "nonexistent-record"
178
220
});
179
221
let delete_res = client
180
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
222
+
.post(format!(
223
+
"{}/xrpc/com.atproto.repo.deleteRecord",
224
+
base_url().await
225
+
))
181
226
.bearer_auth(&jwt)
182
227
.json(&delete_payload)
183
228
.send()
···
187
232
assert_eq!(delete_res.status(), StatusCode::OK);
188
233
let body: Value = delete_res.json().await.unwrap();
189
234
190
-
assert!(body["commit"].is_null(), "commit should be omitted on no-op delete");
235
+
assert!(
236
+
body["commit"].is_null(),
237
+
"commit should be omitted on no-op delete"
238
+
);
191
239
}
192
240
193
241
#[tokio::test]
···
223
271
});
224
272
225
273
let res = client
226
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
274
+
.post(format!(
275
+
"{}/xrpc/com.atproto.repo.applyWrites",
276
+
base_url().await
277
+
))
227
278
.bearer_auth(&jwt)
228
279
.json(&payload)
229
280
.send()
···
233
284
assert_eq!(res.status(), StatusCode::OK);
234
285
let body: Value = res.json().await.unwrap();
235
286
236
-
assert!(body["commit"].is_object(), "response must have commit object");
287
+
assert!(
288
+
body["commit"].is_object(),
289
+
"response must have commit object"
290
+
);
237
291
let commit = &body["commit"];
238
292
assert!(commit["cid"].is_string(), "commit must have cid");
239
293
assert!(commit["rev"].is_string(), "commit must have rev");
240
294
241
-
assert!(body["results"].is_array(), "response must have results array");
295
+
assert!(
296
+
body["results"].is_array(),
297
+
"response must have results array"
298
+
);
242
299
let results = body["results"].as_array().unwrap();
243
300
assert_eq!(results.len(), 2, "should have 2 results");
244
301
245
302
for result in results {
246
303
assert!(result["uri"].is_string(), "result must have uri");
247
304
assert!(result["cid"].is_string(), "result must have cid");
248
-
assert_eq!(result["validationStatus"], "valid", "result must have validationStatus");
305
+
assert_eq!(
306
+
result["validationStatus"], "valid",
307
+
"result must have validationStatus"
308
+
);
249
309
assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult");
250
310
}
251
311
}
···
267
327
}
268
328
});
269
329
client
270
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
330
+
.post(format!(
331
+
"{}/xrpc/com.atproto.repo.putRecord",
332
+
base_url().await
333
+
))
271
334
.bearer_auth(&jwt)
272
335
.json(&create_payload)
273
336
.send()
···
296
359
});
297
360
298
361
let res = client
299
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
362
+
.post(format!(
363
+
"{}/xrpc/com.atproto.repo.applyWrites",
364
+
base_url().await
365
+
))
300
366
.bearer_auth(&jwt)
301
367
.json(&payload)
302
368
.send()
···
310
376
assert_eq!(results.len(), 2);
311
377
312
378
let update_result = &results[0];
313
-
assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult");
379
+
assert_eq!(
380
+
update_result["$type"],
381
+
"com.atproto.repo.applyWrites#updateResult"
382
+
);
314
383
assert!(update_result["uri"].is_string());
315
384
assert!(update_result["cid"].is_string());
316
385
assert_eq!(update_result["validationStatus"], "valid");
317
386
318
387
let delete_result = &results[1];
319
-
assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult");
320
-
assert!(delete_result["uri"].is_null(), "delete result should not have uri");
321
-
assert!(delete_result["cid"].is_null(), "delete result should not have cid");
322
-
assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus");
388
+
assert_eq!(
389
+
delete_result["$type"],
390
+
"com.atproto.repo.applyWrites#deleteResult"
391
+
);
392
+
assert!(
393
+
delete_result["uri"].is_null(),
394
+
"delete result should not have uri"
395
+
);
396
+
assert!(
397
+
delete_result["cid"].is_null(),
398
+
"delete result should not have cid"
399
+
);
400
+
assert!(
401
+
delete_result["validationStatus"].is_null(),
402
+
"delete result should not have validationStatus"
403
+
);
323
404
}
324
405
325
406
#[tokio::test]
···
328
409
let (did, _jwt) = setup_new_user("conform-get-err").await;
329
410
330
411
let res = client
331
-
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
412
+
.get(format!(
413
+
"{}/xrpc/com.atproto.repo.getRecord",
414
+
base_url().await
415
+
))
332
416
.query(&[
333
417
("repo", did.as_str()),
334
418
("collection", "app.bsky.feed.post"),
···
340
424
341
425
assert_eq!(res.status(), StatusCode::NOT_FOUND);
342
426
let body: Value = res.json().await.unwrap();
343
-
assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec");
427
+
assert_eq!(
428
+
body["error"], "RecordNotFound",
429
+
"error code should be RecordNotFound per atproto spec"
430
+
);
344
431
}
345
432
346
433
#[tokio::test]
···
358
445
});
359
446
360
447
let res = client
361
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
448
+
.post(format!(
449
+
"{}/xrpc/com.atproto.repo.createRecord",
450
+
base_url().await
451
+
))
362
452
.bearer_auth(&jwt)
363
453
.json(&payload)
364
454
.send()
365
455
.await
366
456
.expect("Failed to create record");
367
457
368
-
assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation");
458
+
assert_eq!(
459
+
res.status(),
460
+
StatusCode::OK,
461
+
"unknown lexicon should be allowed with default validation"
462
+
);
369
463
let body: Value = res.json().await.unwrap();
370
464
371
465
assert!(body["uri"].is_string());
372
466
assert!(body["cid"].is_string());
373
467
assert!(body["commit"].is_object());
374
-
assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons");
468
+
assert_eq!(
469
+
body["validationStatus"], "unknown",
470
+
"validationStatus should be 'unknown' for unknown lexicons"
471
+
);
375
472
}
376
473
377
474
#[tokio::test]
···
390
487
});
391
488
392
489
let res = client
393
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
490
+
.post(format!(
491
+
"{}/xrpc/com.atproto.repo.createRecord",
492
+
base_url().await
493
+
))
394
494
.bearer_auth(&jwt)
395
495
.json(&payload)
396
496
.send()
397
497
.await
398
498
.expect("Failed to send request");
399
499
400
-
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true");
500
+
assert_eq!(
501
+
res.status(),
502
+
StatusCode::BAD_REQUEST,
503
+
"unknown lexicon should fail with validate=true"
504
+
);
401
505
let body: Value = res.json().await.unwrap();
402
506
assert_eq!(body["error"], "InvalidRecord");
403
-
assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found");
507
+
assert!(
508
+
body["message"]
509
+
.as_str()
510
+
.unwrap()
511
+
.contains("Lexicon not found"),
512
+
"error should mention lexicon not found"
513
+
);
404
514
}
405
515
406
516
#[tokio::test]
···
423
533
});
424
534
425
535
let first_res = client
426
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
536
+
.post(format!(
537
+
"{}/xrpc/com.atproto.repo.putRecord",
538
+
base_url().await
539
+
))
427
540
.bearer_auth(&jwt)
428
541
.json(&payload)
429
542
.send()
···
431
544
.expect("Failed to put record");
432
545
assert_eq!(first_res.status(), StatusCode::OK);
433
546
let first_body: Value = first_res.json().await.unwrap();
434
-
assert!(first_body["commit"].is_object(), "first put should have commit");
547
+
assert!(
548
+
first_body["commit"].is_object(),
549
+
"first put should have commit"
550
+
);
435
551
436
552
let second_res = client
437
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
553
+
.post(format!(
554
+
"{}/xrpc/com.atproto.repo.putRecord",
555
+
base_url().await
556
+
))
438
557
.bearer_auth(&jwt)
439
558
.json(&payload)
440
559
.send()
···
443
562
assert_eq!(second_res.status(), StatusCode::OK);
444
563
let second_body: Value = second_res.json().await.unwrap();
445
564
446
-
assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)");
447
-
assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content");
565
+
assert!(
566
+
second_body["commit"].is_null(),
567
+
"second put with same content should have no commit (no-op)"
568
+
);
569
+
assert_eq!(
570
+
first_body["cid"], second_body["cid"],
571
+
"CID should be the same for identical content"
572
+
);
448
573
}
449
574
450
575
#[tokio::test]
···
468
593
});
469
594
470
595
let res = client
471
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
596
+
.post(format!(
597
+
"{}/xrpc/com.atproto.repo.applyWrites",
598
+
base_url().await
599
+
))
472
600
.bearer_auth(&jwt)
473
601
.json(&payload)
474
602
.send()
···
480
608
481
609
let results = body["results"].as_array().unwrap();
482
610
assert_eq!(results.len(), 1);
483
-
assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status");
611
+
assert_eq!(
612
+
results[0]["validationStatus"], "unknown",
613
+
"unknown lexicon should have 'unknown' status"
614
+
);
484
615
}
+20
-5
tests/sync_deprecated.rs
+20
-5
tests/sync_deprecated.rs
···
202
202
.unwrap();
203
203
assert_eq!(res.status(), StatusCode::OK);
204
204
client
205
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
205
+
.post(format!(
206
+
"{}/xrpc/com.atproto.server.deactivateAccount",
207
+
base
208
+
))
206
209
.bearer_auth(&jwt)
207
210
.json(&serde_json::json!({}))
208
211
.send()
···
233
236
.unwrap();
234
237
assert_eq!(res.status(), StatusCode::OK);
235
238
client
236
-
.post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base))
239
+
.post(format!(
240
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
241
+
base
242
+
))
237
243
.bearer_auth(&admin_jwt)
238
244
.json(&serde_json::json!({
239
245
"subject": {
···
266
272
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
267
273
let (user_jwt, did) = create_account_and_login(&client).await;
268
274
client
269
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
275
+
.post(format!(
276
+
"{}/xrpc/com.atproto.server.deactivateAccount",
277
+
base
278
+
))
270
279
.bearer_auth(&user_jwt)
271
280
.json(&serde_json::json!({}))
272
281
.send()
···
295
304
.unwrap();
296
305
assert_eq!(res.status(), StatusCode::OK);
297
306
client
298
-
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
307
+
.post(format!(
308
+
"{}/xrpc/com.atproto.server.deactivateAccount",
309
+
base
310
+
))
299
311
.bearer_auth(&jwt)
300
312
.json(&serde_json::json!({}))
301
313
.send()
···
326
338
.unwrap();
327
339
assert_eq!(res.status(), StatusCode::OK);
328
340
client
329
-
.post(format!("{}/xrpc/com.atproto.admin.updateSubjectStatus", base))
341
+
.post(format!(
342
+
"{}/xrpc/com.atproto.admin.updateSubjectStatus",
343
+
base
344
+
))
330
345
.bearer_auth(&admin_jwt)
331
346
.json(&serde_json::json!({
332
347
"subject": {