+9
frontend/src/lib/auth.svelte.ts
+9
frontend/src/lib/auth.svelte.ts
···
20
import { assertNever } from "./types/exhaustive.ts";
21
import {
22
checkForOAuthCallback,
23
clearOAuthCallbackParams,
24
handleOAuthCallback,
25
refreshOAuthToken,
···
274
setState(createLoading(getSavedAccounts(), previousSession));
275
}
276
277
async function tryRefreshToken(): Promise<string | null> {
278
if (state.current.kind !== "authenticated") return null;
279
const currentSession = state.current.session;
···
323
applyLocaleFromSession(session);
324
return { oauthLoginCompleted: true };
325
} catch (e) {
326
setError({
327
type: "oauth",
328
message: e instanceof Error ? e.message : "OAuth login failed",
···
398
}
399
400
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
401
setLoading();
402
try {
403
await startOAuthLogin();
···
20
import { assertNever } from "./types/exhaustive.ts";
21
import {
22
checkForOAuthCallback,
23
+
clearAllOAuthState,
24
clearOAuthCallbackParams,
25
handleOAuthCallback,
26
refreshOAuthToken,
···
275
setState(createLoading(getSavedAccounts(), previousSession));
276
}
277
278
+
export function clearError(): void {
279
+
if (state.current.kind === "error") {
280
+
setState(createUnauthenticated(getSavedAccounts()));
281
+
}
282
+
}
283
+
284
async function tryRefreshToken(): Promise<string | null> {
285
if (state.current.kind !== "authenticated") return null;
286
const currentSession = state.current.session;
···
330
applyLocaleFromSession(session);
331
return { oauthLoginCompleted: true };
332
} catch (e) {
333
+
clearAllOAuthState();
334
setError({
335
type: "oauth",
336
message: e instanceof Error ? e.message : "OAuth login failed",
···
406
}
407
408
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
409
+
clearAllOAuthState();
410
setLoading();
411
try {
412
await startOAuthLogin();
+244
-39
frontend/src/lib/oauth.ts
+244
-39
frontend/src/lib/oauth.ts
···
1
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2
const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
3
const SCOPES = [
4
"atproto",
5
"repo:*?action=create",
···
7
"repo:*?action=delete",
8
"blob:*/*",
9
].join(" ");
10
const CLIENT_ID = !(import.meta.env.DEV)
11
? `${globalThis.location.origin}/oauth/client-metadata.json`
12
: `http://localhost/?scope=${SCOPES}`;
13
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
14
15
interface OAuthState {
16
state: string;
17
codeVerifier: string;
18
returnTo?: string;
19
}
20
21
function generateRandomString(length: number): string {
···
73
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
74
}
75
76
export async function startOAuthLogin(): Promise<void> {
77
const state = generateState();
78
const codeVerifier = generateCodeVerifier();
79
const codeChallenge = await generateCodeChallenge(codeVerifier);
80
81
saveOAuthState({ state, codeVerifier });
82
···
91
state: state,
92
code_challenge: codeChallenge,
93
code_challenge_method: "S256",
94
}),
95
});
96
···
121
sub: string;
122
}
123
124
export async function handleOAuthCallback(
125
code: string,
126
state: string,
···
135
throw new Error("OAuth state mismatch. Please try logging in again.");
136
}
137
138
-
const tokenResponse = await fetch("/oauth/token", {
139
-
method: "POST",
140
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
141
-
body: new URLSearchParams({
142
-
grant_type: "authorization_code",
143
-
client_id: CLIENT_ID,
144
-
code: code,
145
-
redirect_uri: REDIRECT_URI,
146
-
code_verifier: savedState.codeVerifier,
147
-
}),
148
});
149
150
clearOAuthState();
151
152
-
if (!tokenResponse.ok) {
153
-
const error = await tokenResponse.json().catch(() => ({
154
-
error: "Unknown error",
155
-
}));
156
-
throw new Error(
157
-
error.error_description || error.error ||
158
-
"Failed to exchange code for tokens",
159
-
);
160
-
}
161
-
162
-
return tokenResponse.json();
163
}
164
165
export async function refreshOAuthToken(
166
refreshToken: string,
167
): Promise<OAuthTokens> {
168
-
const tokenResponse = await fetch("/oauth/token", {
169
-
method: "POST",
170
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
171
-
body: new URLSearchParams({
172
-
grant_type: "refresh_token",
173
-
client_id: CLIENT_ID,
174
-
refresh_token: refreshToken,
175
-
}),
176
});
177
178
-
if (!tokenResponse.ok) {
179
-
const error = await tokenResponse.json().catch(() => ({
180
-
error: "Unknown error",
181
-
}));
182
-
throw new Error(
183
-
error.error_description || error.error || "Failed to refresh token",
184
-
);
185
-
}
186
-
187
-
return tokenResponse.json();
188
}
189
190
export function checkForOAuthCallback():
···
1
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2
const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
3
+
const DPOP_KEY_STORE = "tranquil_pds_dpop_keys";
4
+
const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce";
5
+
6
const SCOPES = [
7
"atproto",
8
"repo:*?action=create",
···
10
"repo:*?action=delete",
11
"blob:*/*",
12
].join(" ");
13
+
14
const CLIENT_ID = !(import.meta.env.DEV)
15
? `${globalThis.location.origin}/oauth/client-metadata.json`
16
: `http://localhost/?scope=${SCOPES}`;
17
+
18
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
19
20
interface OAuthState {
21
state: string;
22
codeVerifier: string;
23
returnTo?: string;
24
+
}
25
+
26
+
interface DPoPKeyPair {
27
+
publicKey: CryptoKey;
28
+
privateKey: CryptoKey;
29
+
jwk: JsonWebKey;
30
}
31
32
function generateRandomString(length: number): string {
···
84
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
85
}
86
87
+
function clearDPoPNonce(): void {
88
+
sessionStorage.removeItem(DPOP_NONCE_KEY);
89
+
}
90
+
91
+
export function clearAllOAuthState(): void {
92
+
clearOAuthState();
93
+
clearDPoPNonce();
94
+
}
95
+
96
+
async function openKeyStore(): Promise<IDBDatabase> {
97
+
return new Promise((resolve, reject) => {
98
+
const request = indexedDB.open(DPOP_KEY_STORE, 1);
99
+
request.onerror = () => reject(request.error);
100
+
request.onsuccess = () => resolve(request.result);
101
+
request.onupgradeneeded = () => {
102
+
const db = request.result;
103
+
if (!db.objectStoreNames.contains("keys")) {
104
+
db.createObjectStore("keys");
105
+
}
106
+
};
107
+
});
108
+
}
109
+
110
+
async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> {
111
+
const db = await openKeyStore();
112
+
return new Promise((resolve, reject) => {
113
+
const tx = db.transaction("keys", "readwrite");
114
+
const store = tx.objectStore("keys");
115
+
store.put(keyPair.publicKey, "publicKey");
116
+
store.put(keyPair.privateKey, "privateKey");
117
+
store.put(keyPair.jwk, "jwk");
118
+
tx.oncomplete = () => {
119
+
db.close();
120
+
resolve();
121
+
};
122
+
tx.onerror = () => {
123
+
db.close();
124
+
reject(tx.error);
125
+
};
126
+
});
127
+
}
128
+
129
+
async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> {
130
+
try {
131
+
const db = await openKeyStore();
132
+
return new Promise((resolve, reject) => {
133
+
const tx = db.transaction("keys", "readonly");
134
+
const store = tx.objectStore("keys");
135
+
const publicKeyReq = store.get("publicKey");
136
+
const privateKeyReq = store.get("privateKey");
137
+
const jwkReq = store.get("jwk");
138
+
tx.oncomplete = () => {
139
+
db.close();
140
+
if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) {
141
+
resolve({
142
+
publicKey: publicKeyReq.result,
143
+
privateKey: privateKeyReq.result,
144
+
jwk: jwkReq.result,
145
+
});
146
+
} else {
147
+
resolve(null);
148
+
}
149
+
};
150
+
tx.onerror = () => {
151
+
db.close();
152
+
reject(tx.error);
153
+
};
154
+
});
155
+
} catch {
156
+
return null;
157
+
}
158
+
}
159
+
160
+
async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
161
+
const keyPair = await crypto.subtle.generateKey(
162
+
{ name: "ECDSA", namedCurve: "P-256" },
163
+
true,
164
+
["sign", "verify"],
165
+
);
166
+
const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
167
+
return {
168
+
publicKey: keyPair.publicKey,
169
+
privateKey: keyPair.privateKey,
170
+
jwk,
171
+
};
172
+
}
173
+
174
+
async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> {
175
+
const existing = await loadDPoPKeyPair();
176
+
if (existing) return existing;
177
+
178
+
const keyPair = await generateDPoPKeyPair();
179
+
await storeDPoPKeyPair(keyPair);
180
+
return keyPair;
181
+
}
182
+
183
+
async function createDPoPProof(
184
+
keyPair: DPoPKeyPair,
185
+
method: string,
186
+
url: string,
187
+
nonce?: string,
188
+
accessTokenHash?: string,
189
+
): Promise<string> {
190
+
const header = {
191
+
typ: "dpop+jwt",
192
+
alg: "ES256",
193
+
jwk: {
194
+
kty: keyPair.jwk.kty,
195
+
crv: keyPair.jwk.crv,
196
+
x: keyPair.jwk.x,
197
+
y: keyPair.jwk.y,
198
+
},
199
+
};
200
+
201
+
const payload: Record<string, unknown> = {
202
+
jti: generateRandomString(16),
203
+
htm: method.toUpperCase(),
204
+
htu: url.split("?")[0],
205
+
iat: Math.floor(Date.now() / 1000),
206
+
};
207
+
208
+
if (nonce) {
209
+
payload.nonce = nonce;
210
+
}
211
+
212
+
if (accessTokenHash) {
213
+
payload.ath = accessTokenHash;
214
+
}
215
+
216
+
const headerB64 = base64UrlEncode(
217
+
new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer,
218
+
);
219
+
const payloadB64 = base64UrlEncode(
220
+
new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer,
221
+
);
222
+
const signingInput = `${headerB64}.${payloadB64}`;
223
+
224
+
const signature = await crypto.subtle.sign(
225
+
{ name: "ECDSA", hash: "SHA-256" },
226
+
keyPair.privateKey,
227
+
new TextEncoder().encode(signingInput),
228
+
);
229
+
230
+
const sigBytes = new Uint8Array(signature);
231
+
const signatureB64 = base64UrlEncode(sigBytes.buffer);
232
+
233
+
return `${signingInput}.${signatureB64}`;
234
+
}
235
+
236
+
async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
237
+
const canonical = JSON.stringify({
238
+
crv: jwk.crv,
239
+
kty: jwk.kty,
240
+
x: jwk.x,
241
+
y: jwk.y,
242
+
});
243
+
const hash = await sha256(canonical);
244
+
return base64UrlEncode(hash);
245
+
}
246
+
247
+
function getDPoPNonce(): string | null {
248
+
return sessionStorage.getItem(DPOP_NONCE_KEY);
249
+
}
250
+
251
+
function setDPoPNonce(nonce: string): void {
252
+
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
253
+
}
254
+
255
+
function extractDPoPNonceFromResponse(response: Response): void {
256
+
const nonce = response.headers.get("DPoP-Nonce");
257
+
if (nonce) {
258
+
setDPoPNonce(nonce);
259
+
}
260
+
}
261
+
262
export async function startOAuthLogin(): Promise<void> {
263
+
clearAllOAuthState();
264
+
265
const state = generateState();
266
const codeVerifier = generateCodeVerifier();
267
const codeChallenge = await generateCodeChallenge(codeVerifier);
268
+
269
+
const keyPair = await getOrCreateDPoPKeyPair();
270
+
const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
271
272
saveOAuthState({ state, codeVerifier });
273
···
282
state: state,
283
code_challenge: codeChallenge,
284
code_challenge_method: "S256",
285
+
dpop_jkt: dpopJkt,
286
}),
287
});
288
···
313
sub: string;
314
}
315
316
+
async function tokenRequest(
317
+
params: URLSearchParams,
318
+
retryWithNonce = true,
319
+
): Promise<OAuthTokens> {
320
+
const keyPair = await getOrCreateDPoPKeyPair();
321
+
const tokenEndpoint = `${globalThis.location.origin}/oauth/token`;
322
+
323
+
const dpopProof = await createDPoPProof(
324
+
keyPair,
325
+
"POST",
326
+
tokenEndpoint,
327
+
getDPoPNonce() ?? undefined,
328
+
);
329
+
330
+
const response = await fetch("/oauth/token", {
331
+
method: "POST",
332
+
headers: {
333
+
"Content-Type": "application/x-www-form-urlencoded",
334
+
"DPoP": dpopProof,
335
+
},
336
+
body: params,
337
+
});
338
+
339
+
extractDPoPNonceFromResponse(response);
340
+
341
+
if (!response.ok) {
342
+
const error = await response.json().catch(() => ({ error: "Unknown error" }));
343
+
344
+
if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) {
345
+
return tokenRequest(params, false);
346
+
}
347
+
348
+
throw new Error(
349
+
error.error_description || error.error || "Token request failed",
350
+
);
351
+
}
352
+
353
+
return response.json();
354
+
}
355
+
356
export async function handleOAuthCallback(
357
code: string,
358
state: string,
···
367
throw new Error("OAuth state mismatch. Please try logging in again.");
368
}
369
370
+
const params = new URLSearchParams({
371
+
grant_type: "authorization_code",
372
+
client_id: CLIENT_ID,
373
+
code: code,
374
+
redirect_uri: REDIRECT_URI,
375
+
code_verifier: savedState.codeVerifier,
376
});
377
378
clearOAuthState();
379
380
+
return tokenRequest(params);
381
}
382
383
export async function refreshOAuthToken(
384
refreshToken: string,
385
): Promise<OAuthTokens> {
386
+
const params = new URLSearchParams({
387
+
grant_type: "refresh_token",
388
+
client_id: CLIENT_ID,
389
+
refresh_token: refreshToken,
390
});
391
392
+
return tokenRequest(params);
393
}
394
395
export function checkForOAuthCallback():
+22
-4
frontend/src/routes/Dashboard.svelte
+22
-4
frontend/src/routes/Dashboard.svelte
···
285
justify-content: space-between;
286
align-items: center;
287
margin-bottom: var(--space-7);
288
}
289
290
header h1 {
291
margin: 0;
292
}
293
294
.account-dropdown {
295
position: relative;
296
}
297
298
.account-trigger {
···
305
border-radius: var(--radius-md);
306
cursor: pointer;
307
color: var(--text-primary);
308
}
309
310
.account-trigger:hover:not(:disabled) {
···
314
.account-trigger:disabled {
315
opacity: 0.6;
316
cursor: not-allowed;
317
-
}
318
-
319
-
.account-trigger .account-handle {
320
-
font-weight: var(--font-medium);
321
}
322
323
.dropdown-arrow {
···
383
padding: var(--space-6);
384
border-radius: var(--radius-xl);
385
margin-bottom: var(--space-7);
386
}
387
388
section h2 {
···
400
dt {
401
font-weight: var(--font-medium);
402
color: var(--text-secondary);
403
}
404
405
dd {
406
margin: 0;
407
}
408
409
.mono {
···
285
justify-content: space-between;
286
align-items: center;
287
margin-bottom: var(--space-7);
288
+
gap: var(--space-4);
289
+
}
290
+
291
+
@media (max-width: 500px) {
292
+
header {
293
+
flex-direction: column-reverse;
294
+
align-items: flex-start;
295
+
}
296
}
297
298
header h1 {
299
margin: 0;
300
+
min-width: 0;
301
}
302
303
.account-dropdown {
304
position: relative;
305
+
max-width: 100%;
306
}
307
308
.account-trigger {
···
315
border-radius: var(--radius-md);
316
cursor: pointer;
317
color: var(--text-primary);
318
+
max-width: 100%;
319
+
}
320
+
321
+
.account-trigger .account-handle {
322
+
font-weight: var(--font-medium);
323
+
overflow: hidden;
324
+
text-overflow: ellipsis;
325
+
white-space: nowrap;
326
}
327
328
.account-trigger:hover:not(:disabled) {
···
332
.account-trigger:disabled {
333
opacity: 0.6;
334
cursor: not-allowed;
335
}
336
337
.dropdown-arrow {
···
397
padding: var(--space-6);
398
border-radius: var(--radius-xl);
399
margin-bottom: var(--space-7);
400
+
overflow: hidden;
401
+
min-width: 0;
402
}
403
404
section h2 {
···
416
dt {
417
font-weight: var(--font-medium);
418
color: var(--text-secondary);
419
+
max-width: 6rem;
420
}
421
422
dd {
423
margin: 0;
424
+
min-width: 0;
425
}
426
427
.mono {
+9
-12
frontend/src/routes/Login.svelte
+9
-12
frontend/src/routes/Login.svelte
···
6
getAuthState,
7
switchAccount,
8
forgetAccount,
9
matchAuthState,
10
type SavedAccount,
11
type AuthError,
···
14
import { _ } from '../lib/i18n'
15
import { isOk, isErr } from '../lib/types/result'
16
import { unsafeAsDid, type Did } from '../lib/types/branded'
17
18
type PageState =
19
| { kind: 'login' }
···
32
return auth.savedAccounts
33
}
34
35
-
function getErrorMessage(): string | null {
36
-
if (auth.kind === 'error') {
37
-
return auth.error.message
38
-
}
39
-
return null
40
-
}
41
-
42
function isLoading(): boolean {
43
return auth.kind === 'loading'
44
}
45
46
$effect(() => {
47
const accounts = getSavedAccounts()
···
108
resendMessage = null
109
}
110
111
-
const errorMessage = $derived(getErrorMessage())
112
const savedAccounts = $derived(getSavedAccounts())
113
const loading = $derived(isLoading())
114
</script>
115
116
<div class="login-page">
117
-
{#if errorMessage}
118
-
<div class="message error">{errorMessage}</div>
119
-
{/if}
120
-
121
{#if pageState.kind === 'verification'}
122
<header class="page-header">
123
<h1>{$_('verification.title')}</h1>
···
6
getAuthState,
7
switchAccount,
8
forgetAccount,
9
+
clearError,
10
matchAuthState,
11
type SavedAccount,
12
type AuthError,
···
15
import { _ } from '../lib/i18n'
16
import { isOk, isErr } from '../lib/types/result'
17
import { unsafeAsDid, type Did } from '../lib/types/branded'
18
+
import { toast } from '../lib/toast.svelte'
19
20
type PageState =
21
| { kind: 'login' }
···
34
return auth.savedAccounts
35
}
36
37
function isLoading(): boolean {
38
return auth.kind === 'loading'
39
}
40
+
41
+
$effect(() => {
42
+
if (auth.kind === 'error') {
43
+
toast.error(auth.error.message)
44
+
clearError()
45
+
}
46
+
})
47
48
$effect(() => {
49
const accounts = getSavedAccounts()
···
110
resendMessage = null
111
}
112
113
const savedAccounts = $derived(getSavedAccounts())
114
const loading = $derived(isLoading())
115
</script>
116
117
<div class="login-page">
118
{#if pageState.kind === 'verification'}
119
<header class="page-header">
120
<h1>{$_('verification.title')}</h1>
+8
frontend/src/styles/base.css
+8
frontend/src/styles/base.css
···
29
-webkit-font-smoothing: antialiased;
30
-moz-osx-font-smoothing: grayscale;
31
transition: background-color 0.3s ease;
32
}
33
34
h1, h2, h3, h4, h5, h6 {
···
336
border: 1px solid var(--border-color);
337
border-radius: var(--radius-xl);
338
padding: var(--space-6);
339
}
340
341
.section {
342
background: var(--bg-secondary);
343
border-radius: var(--radius-xl);
344
padding: var(--space-6);
345
}
346
347
.section + .section {
···
469
border-radius: var(--radius-xl);
470
padding: var(--space-6);
471
height: fit-content;
472
}
473
474
.info-panel h3 {
···
29
-webkit-font-smoothing: antialiased;
30
-moz-osx-font-smoothing: grayscale;
31
transition: background-color 0.3s ease;
32
+
overflow-wrap: anywhere;
33
+
word-break: break-word;
34
}
35
36
h1, h2, h3, h4, h5, h6 {
···
338
border: 1px solid var(--border-color);
339
border-radius: var(--radius-xl);
340
padding: var(--space-6);
341
+
overflow: hidden;
342
+
min-width: 0;
343
}
344
345
.section {
346
background: var(--bg-secondary);
347
border-radius: var(--radius-xl);
348
padding: var(--space-6);
349
+
overflow: hidden;
350
+
min-width: 0;
351
}
352
353
.section + .section {
···
475
border-radius: var(--radius-xl);
476
padding: var(--space-6);
477
height: fit-content;
478
+
overflow: hidden;
479
+
min-width: 0;
480
}
481
482
.info-panel h3 {
+13
-15
frontend/src/tests/AppPasswords.test.ts
+13
-15
frontend/src/tests/AppPasswords.test.ts
···
4
import {
5
clearMocks,
6
errorResponse,
7
jsonResponse,
8
mockData,
9
mockEndpoint,
···
51
beforeEach(() => {
52
setupAuthenticatedUser();
53
});
54
-
it("shows loading text while fetching passwords", () => {
55
mockEndpoint(
56
"com.atproto.server.listAppPasswords",
57
() =>
···
59
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
60
),
61
);
62
-
render(AppPasswords);
63
-
expect(screen.getByText(/loading/i)).toBeInTheDocument();
64
});
65
});
66
describe("empty state", () => {
···
236
.toBeInTheDocument();
237
});
238
});
239
-
it("shows error when creation fails", async () => {
240
mockEndpoint(
241
"com.atproto.server.createAppPassword",
242
() => errorResponse("InvalidRequest", "Name already exists", 400),
···
250
});
251
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
252
await waitFor(() => {
253
-
expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
254
-
expect(screen.getByText(/name already exists/i)).toHaveClass("error");
255
});
256
});
257
});
···
358
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
359
});
360
});
361
-
it("shows error when revocation fails", async () => {
362
globalThis.confirm = vi.fn(() => true);
363
mockEndpoint(
364
"com.atproto.server.listAppPasswords",
···
374
});
375
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
376
await waitFor(() => {
377
-
expect(screen.getByText(/server error/i)).toBeInTheDocument();
378
-
expect(screen.getByText(/server error/i)).toHaveClass("error");
379
});
380
});
381
});
···
383
beforeEach(() => {
384
setupAuthenticatedUser();
385
});
386
-
it("shows error when loading passwords fails", async () => {
387
mockEndpoint(
388
"com.atproto.server.listAppPasswords",
389
() => errorResponse("InternalError", "Database connection failed", 500),
390
);
391
render(AppPasswords);
392
await waitFor(() => {
393
-
expect(screen.getByText(/database connection failed/i))
394
-
.toBeInTheDocument();
395
-
expect(screen.getByText(/database connection failed/i)).toHaveClass(
396
-
"error",
397
-
);
398
});
399
});
400
});
···
4
import {
5
clearMocks,
6
errorResponse,
7
+
getErrorToasts,
8
jsonResponse,
9
mockData,
10
mockEndpoint,
···
52
beforeEach(() => {
53
setupAuthenticatedUser();
54
});
55
+
it("shows loading skeleton while fetching passwords", () => {
56
mockEndpoint(
57
"com.atproto.server.listAppPasswords",
58
() =>
···
60
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
61
),
62
);
63
+
const { container } = render(AppPasswords);
64
+
expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0);
65
});
66
});
67
describe("empty state", () => {
···
237
.toBeInTheDocument();
238
});
239
});
240
+
it("shows error toast when creation fails", async () => {
241
mockEndpoint(
242
"com.atproto.server.createAppPassword",
243
() => errorResponse("InvalidRequest", "Name already exists", 400),
···
251
});
252
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
253
await waitFor(() => {
254
+
const errors = getErrorToasts();
255
+
expect(errors.some((e) => /name already exists/i.test(e))).toBe(true);
256
});
257
});
258
});
···
359
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
360
});
361
});
362
+
it("shows error toast when revocation fails", async () => {
363
globalThis.confirm = vi.fn(() => true);
364
mockEndpoint(
365
"com.atproto.server.listAppPasswords",
···
375
});
376
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
377
await waitFor(() => {
378
+
const errors = getErrorToasts();
379
+
expect(errors.some((e) => /server error/i.test(e))).toBe(true);
380
});
381
});
382
});
···
384
beforeEach(() => {
385
setupAuthenticatedUser();
386
});
387
+
it("shows error toast when loading passwords fails", async () => {
388
mockEndpoint(
389
"com.atproto.server.listAppPasswords",
390
() => errorResponse("InternalError", "Database connection failed", 500),
391
);
392
render(AppPasswords);
393
await waitFor(() => {
394
+
const errors = getErrorToasts();
395
+
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
396
});
397
});
398
});
+14
-17
frontend/src/tests/Comms.test.ts
+14
-17
frontend/src/tests/Comms.test.ts
···
4
import {
5
clearMocks,
6
errorResponse,
7
jsonResponse,
8
mockData,
9
mockEndpoint,
···
71
() => jsonResponse({ notifications: [] }),
72
);
73
});
74
-
it("shows loading text while fetching preferences", () => {
75
mockEndpoint(
76
"_account.getNotificationPrefs",
77
() =>
···
82
)
83
),
84
);
85
-
render(Comms);
86
-
expect(screen.getByText(/loading/i)).toBeInTheDocument();
87
});
88
});
89
describe("channel options", () => {
···
354
.toBeInTheDocument();
355
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
356
});
357
-
it("shows success message after saving", async () => {
358
mockEndpoint(
359
"_account.getNotificationPrefs",
360
() => jsonResponse(mockData.notificationPrefs()),
···
372
screen.getByRole("button", { name: /save preferences/i }),
373
);
374
await waitFor(() => {
375
-
expect(screen.getByText(/preferences saved/i))
376
-
.toBeInTheDocument();
377
});
378
});
379
-
it("shows error when save fails", async () => {
380
mockEndpoint(
381
"_account.getNotificationPrefs",
382
() => jsonResponse(mockData.notificationPrefs()),
···
395
screen.getByRole("button", { name: /save preferences/i }),
396
);
397
await waitFor(() => {
398
-
expect(screen.getByText(/invalid channel configuration/i))
399
-
.toBeInTheDocument();
400
-
expect(
401
-
screen.getByText(/invalid channel configuration/i).closest(
402
-
".message",
403
-
),
404
-
).toHaveClass("error");
405
});
406
});
407
it("reloads preferences after successful save", async () => {
···
490
() => jsonResponse({ notifications: [] }),
491
);
492
});
493
-
it("shows error when loading preferences fails", async () => {
494
mockEndpoint(
495
"_account.getNotificationPrefs",
496
() => errorResponse("InternalError", "Database connection failed", 500),
497
);
498
render(Comms);
499
await waitFor(() => {
500
-
expect(screen.getByText(/database connection failed/i))
501
-
.toBeInTheDocument();
502
});
503
});
504
});
···
4
import {
5
clearMocks,
6
errorResponse,
7
+
getErrorToasts,
8
+
getToasts,
9
jsonResponse,
10
mockData,
11
mockEndpoint,
···
73
() => jsonResponse({ notifications: [] }),
74
);
75
});
76
+
it("shows loading skeleton while fetching preferences", () => {
77
mockEndpoint(
78
"_account.getNotificationPrefs",
79
() =>
···
84
)
85
),
86
);
87
+
const { container } = render(Comms);
88
+
expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0);
89
});
90
});
91
describe("channel options", () => {
···
356
.toBeInTheDocument();
357
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
358
});
359
+
it("shows success toast after saving", async () => {
360
mockEndpoint(
361
"_account.getNotificationPrefs",
362
() => jsonResponse(mockData.notificationPrefs()),
···
374
screen.getByRole("button", { name: /save preferences/i }),
375
);
376
await waitFor(() => {
377
+
const toasts = getToasts();
378
+
expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true);
379
});
380
});
381
+
it("shows error toast when save fails", async () => {
382
mockEndpoint(
383
"_account.getNotificationPrefs",
384
() => jsonResponse(mockData.notificationPrefs()),
···
397
screen.getByRole("button", { name: /save preferences/i }),
398
);
399
await waitFor(() => {
400
+
const errors = getErrorToasts();
401
+
expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true);
402
});
403
});
404
it("reloads preferences after successful save", async () => {
···
487
() => jsonResponse({ notifications: [] }),
488
);
489
});
490
+
it("shows error toast when loading preferences fails", async () => {
491
mockEndpoint(
492
"_account.getNotificationPrefs",
493
() => errorResponse("InternalError", "Database connection failed", 500),
494
);
495
render(Comms);
496
await waitFor(() => {
497
+
const errors = getErrorToasts();
498
+
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
499
});
500
});
501
});
+3
-2
frontend/src/tests/Dashboard.test.ts
+3
-2
frontend/src/tests/Dashboard.test.ts
···
25
});
26
});
27
it("shows loading state while checking auth", () => {
28
+
const { container } = render(Dashboard);
29
+
expect(container.querySelector(".skeleton-section")).toBeInTheDocument();
30
+
expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0);
31
});
32
});
33
describe("authenticated view", () => {
+7
-3
frontend/src/tests/Login.test.ts
+7
-3
frontend/src/tests/Login.test.ts
···
15
unsafeAsHandle,
16
unsafeAsRefreshToken,
17
} from "../lib/types/branded.ts";
18
19
describe("Login", () => {
20
beforeEach(() => {
···
147
});
148
149
describe("error handling", () => {
150
-
it("displays error message when auth state has error", async () => {
151
_testSetState({
152
session: null,
153
loading: false,
···
156
});
157
render(Login);
158
await waitFor(() => {
159
-
expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument();
160
-
expect(screen.getByText(/oauth login failed/i)).toHaveClass("error");
161
});
162
});
163
});
···
15
unsafeAsHandle,
16
unsafeAsRefreshToken,
17
} from "../lib/types/branded.ts";
18
+
import { getToasts } from "../lib/toast.svelte.ts";
19
20
describe("Login", () => {
21
beforeEach(() => {
···
148
});
149
150
describe("error handling", () => {
151
+
it("displays error message as toast when auth state has error", async () => {
152
_testSetState({
153
session: null,
154
loading: false,
···
157
});
158
render(Login);
159
await waitFor(() => {
160
+
const toasts = getToasts();
161
+
const errorToast = toasts.find(
162
+
(t) => t.type === "error" && t.message.includes("OAuth login failed"),
163
+
);
164
+
expect(errorToast).toBeDefined();
165
});
166
});
167
});
+17
-15
frontend/src/tests/Settings.test.ts
+17
-15
frontend/src/tests/Settings.test.ts
···
4
import {
5
clearMocks,
6
errorResponse,
7
jsonResponse,
8
mockData,
9
mockEndpoint,
···
140
expect(capturedBody?.token).toBe("123456");
141
});
142
});
143
-
it("shows success message after email update", async () => {
144
mockEndpoint(
145
"com.atproto.server.requestEmailUpdate",
146
() => jsonResponse({ tokenRequired: true }),
···
171
screen.getByRole("button", { name: /confirm email change/i }),
172
);
173
await waitFor(() => {
174
-
expect(screen.getByText(/email updated/i))
175
-
.toBeInTheDocument();
176
});
177
});
178
it("shows cancel button to return to initial state", async () => {
···
205
.toBeInTheDocument();
206
});
207
});
208
-
it("shows error when request fails", async () => {
209
mockEndpoint(
210
"com.atproto.server.requestEmailUpdate",
211
() => errorResponse("InvalidEmail", "Invalid email format", 400),
···
219
screen.getByRole("button", { name: /change email/i }),
220
);
221
await waitFor(() => {
222
-
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
223
});
224
});
225
});
···
261
expect(screen.getByRole("button", { name: /change handle/i }))
262
.toBeInTheDocument();
263
});
264
-
it("shows success message after handle change", async () => {
265
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
266
mockEndpoint(
267
"com.atproto.server.getSession",
···
279
const button = screen.getByRole("button", { name: /change handle/i });
280
await fireEvent.submit(button.closest("form")!);
281
await waitFor(() => {
282
-
expect(screen.getByText(/handle updated/i))
283
-
.toBeInTheDocument();
284
});
285
});
286
-
it("shows error when handle change fails", async () => {
287
mockEndpoint(
288
"com.atproto.identity.updateHandle",
289
() =>
···
302
const button = screen.getByRole("button", { name: /change handle/i });
303
await fireEvent.submit(button.closest("form")!);
304
await waitFor(() => {
305
-
const errorMessage = screen.queryByText(/handle is already taken/i) ||
306
-
screen.queryByText(/handle update failed/i);
307
-
expect(errorMessage).toBeInTheDocument();
308
});
309
});
310
});
···
500
).toBeInTheDocument();
501
});
502
});
503
-
it("shows error when deletion fails", async () => {
504
globalThis.confirm = vi.fn(() => true);
505
mockEndpoint(
506
"com.atproto.server.requestAccountDelete",
···
532
screen.getByRole("button", { name: /permanently delete account/i }),
533
);
534
await waitFor(() => {
535
-
expect(screen.getByText(/invalid confirmation code/i))
536
-
.toBeInTheDocument();
537
});
538
});
539
});
···
4
import {
5
clearMocks,
6
errorResponse,
7
+
getErrorToasts,
8
+
getToasts,
9
jsonResponse,
10
mockData,
11
mockEndpoint,
···
142
expect(capturedBody?.token).toBe("123456");
143
});
144
});
145
+
it("shows success toast after email update", async () => {
146
mockEndpoint(
147
"com.atproto.server.requestEmailUpdate",
148
() => jsonResponse({ tokenRequired: true }),
···
173
screen.getByRole("button", { name: /confirm email change/i }),
174
);
175
await waitFor(() => {
176
+
const toasts = getToasts();
177
+
expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true);
178
});
179
});
180
it("shows cancel button to return to initial state", async () => {
···
207
.toBeInTheDocument();
208
});
209
});
210
+
it("shows error toast when request fails", async () => {
211
mockEndpoint(
212
"com.atproto.server.requestEmailUpdate",
213
() => errorResponse("InvalidEmail", "Invalid email format", 400),
···
221
screen.getByRole("button", { name: /change email/i }),
222
);
223
await waitFor(() => {
224
+
const errors = getErrorToasts();
225
+
expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true);
226
});
227
});
228
});
···
264
expect(screen.getByRole("button", { name: /change handle/i }))
265
.toBeInTheDocument();
266
});
267
+
it("shows success toast after handle change", async () => {
268
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
269
mockEndpoint(
270
"com.atproto.server.getSession",
···
282
const button = screen.getByRole("button", { name: /change handle/i });
283
await fireEvent.submit(button.closest("form")!);
284
await waitFor(() => {
285
+
const toasts = getToasts();
286
+
expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true);
287
});
288
});
289
+
it("shows error toast when handle change fails", async () => {
290
mockEndpoint(
291
"com.atproto.identity.updateHandle",
292
() =>
···
305
const button = screen.getByRole("button", { name: /change handle/i });
306
await fireEvent.submit(button.closest("form")!);
307
await waitFor(() => {
308
+
const errors = getErrorToasts();
309
+
expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true);
310
});
311
});
312
});
···
502
).toBeInTheDocument();
503
});
504
});
505
+
it("shows error toast when deletion fails", async () => {
506
globalThis.confirm = vi.fn(() => true);
507
mockEndpoint(
508
"com.atproto.server.requestAccountDelete",
···
534
screen.getByRole("button", { name: /permanently delete account/i }),
535
);
536
await waitFor(() => {
537
+
const errors = getErrorToasts();
538
+
expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true);
539
});
540
});
541
});
+12
-1
frontend/src/tests/mocks.ts
+12
-1
frontend/src/tests/mocks.ts
···
1
import { vi } from "vitest";
2
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
3
-
import { _testSetState } from "../lib/auth.svelte.ts";
4
import {
5
unsafeAsAccessToken,
6
unsafeAsDid,
···
70
}
71
export function clearMocks(): void {
72
mockHandlers.clear();
73
}
74
function extractEndpoint(url: string): string {
75
const match = url.match(/\/xrpc\/([^?]+)/);
76
return match ? match[1] : url;
···
1
import { vi } from "vitest";
2
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
3
+
import { _testSetState, _testResetState } from "../lib/auth.svelte.ts";
4
+
import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts";
5
import {
6
unsafeAsAccessToken,
7
unsafeAsDid,
···
71
}
72
export function clearMocks(): void {
73
mockHandlers.clear();
74
+
_testResetState();
75
+
clearAllToasts();
76
}
77
+
78
+
export function getErrorToasts(): string[] {
79
+
return getToasts()
80
+
.filter((t) => t.type === "error")
81
+
.map((t) => t.message);
82
+
}
83
+
84
+
export { toast, getToasts };
85
function extractEndpoint(url: string): string {
86
const match = url.match(/\/xrpc\/([^?]+)/);
87
return match ? match[1] : url;
+232
frontend/src/tests/oauth.test.ts
+232
frontend/src/tests/oauth.test.ts
···
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import {
3
+
generateCodeChallenge,
4
+
generateCodeVerifier,
5
+
generateState,
6
+
saveOAuthState,
7
+
checkForOAuthCallback,
8
+
clearOAuthCallbackParams,
9
+
} from "../lib/oauth";
10
+
11
+
describe("OAuth utilities", () => {
12
+
beforeEach(() => {
13
+
sessionStorage.clear();
14
+
vi.restoreAllMocks();
15
+
});
16
+
17
+
describe("generateState", () => {
18
+
it("generates a 64-character hex string", () => {
19
+
const state = generateState();
20
+
expect(state).toMatch(/^[0-9a-f]{64}$/);
21
+
});
22
+
23
+
it("generates unique values", () => {
24
+
const states = new Set(Array.from({ length: 100 }, () => generateState()));
25
+
expect(states.size).toBe(100);
26
+
});
27
+
});
28
+
29
+
describe("generateCodeVerifier", () => {
30
+
it("generates a 64-character hex string", () => {
31
+
const verifier = generateCodeVerifier();
32
+
expect(verifier).toMatch(/^[0-9a-f]{64}$/);
33
+
});
34
+
35
+
it("generates unique values", () => {
36
+
const verifiers = new Set(
37
+
Array.from({ length: 100 }, () => generateCodeVerifier()),
38
+
);
39
+
expect(verifiers.size).toBe(100);
40
+
});
41
+
});
42
+
43
+
describe("generateCodeChallenge", () => {
44
+
it("generates a base64url-encoded SHA-256 hash", async () => {
45
+
const verifier = "test-verifier-12345";
46
+
const challenge = await generateCodeChallenge(verifier);
47
+
48
+
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
49
+
expect(challenge).not.toContain("+");
50
+
expect(challenge).not.toContain("/");
51
+
expect(challenge).not.toContain("=");
52
+
});
53
+
54
+
it("produces consistent output for same input", async () => {
55
+
const verifier = "consistent-test-verifier";
56
+
const challenge1 = await generateCodeChallenge(verifier);
57
+
const challenge2 = await generateCodeChallenge(verifier);
58
+
59
+
expect(challenge1).toBe(challenge2);
60
+
});
61
+
62
+
it("produces different output for different inputs", async () => {
63
+
const challenge1 = await generateCodeChallenge("verifier-1");
64
+
const challenge2 = await generateCodeChallenge("verifier-2");
65
+
66
+
expect(challenge1).not.toBe(challenge2);
67
+
});
68
+
69
+
it("produces correct S256 challenge", async () => {
70
+
const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
71
+
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
72
+
});
73
+
});
74
+
75
+
describe("saveOAuthState", () => {
76
+
it("stores state and verifier in sessionStorage", () => {
77
+
saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" });
78
+
79
+
expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe(
80
+
"test-state",
81
+
);
82
+
expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe(
83
+
"test-verifier",
84
+
);
85
+
});
86
+
});
87
+
88
+
describe("checkForOAuthCallback", () => {
89
+
it("returns null when no code/state in URL", () => {
90
+
Object.defineProperty(globalThis.location, "search", {
91
+
value: "",
92
+
writable: true,
93
+
configurable: true,
94
+
});
95
+
Object.defineProperty(globalThis.location, "pathname", {
96
+
value: "/app/",
97
+
writable: true,
98
+
configurable: true,
99
+
});
100
+
101
+
expect(checkForOAuthCallback()).toBeNull();
102
+
});
103
+
104
+
it("returns code and state when present in URL", () => {
105
+
Object.defineProperty(globalThis.location, "search", {
106
+
value: "?code=auth-code-123&state=state-456",
107
+
writable: true,
108
+
configurable: true,
109
+
});
110
+
Object.defineProperty(globalThis.location, "pathname", {
111
+
value: "/app/",
112
+
writable: true,
113
+
configurable: true,
114
+
});
115
+
116
+
const result = checkForOAuthCallback();
117
+
expect(result).toEqual({ code: "auth-code-123", state: "state-456" });
118
+
});
119
+
120
+
it("returns null on migrate path even with code/state", () => {
121
+
Object.defineProperty(globalThis.location, "search", {
122
+
value: "?code=auth-code-123&state=state-456",
123
+
writable: true,
124
+
configurable: true,
125
+
});
126
+
Object.defineProperty(globalThis.location, "pathname", {
127
+
value: "/app/migrate",
128
+
writable: true,
129
+
configurable: true,
130
+
});
131
+
132
+
expect(checkForOAuthCallback()).toBeNull();
133
+
});
134
+
135
+
it("returns null when only code is present", () => {
136
+
Object.defineProperty(globalThis.location, "search", {
137
+
value: "?code=auth-code-123",
138
+
writable: true,
139
+
configurable: true,
140
+
});
141
+
Object.defineProperty(globalThis.location, "pathname", {
142
+
value: "/app/",
143
+
writable: true,
144
+
configurable: true,
145
+
});
146
+
147
+
expect(checkForOAuthCallback()).toBeNull();
148
+
});
149
+
150
+
it("returns null when only state is present", () => {
151
+
Object.defineProperty(globalThis.location, "search", {
152
+
value: "?state=state-456",
153
+
writable: true,
154
+
configurable: true,
155
+
});
156
+
Object.defineProperty(globalThis.location, "pathname", {
157
+
value: "/app/",
158
+
writable: true,
159
+
configurable: true,
160
+
});
161
+
162
+
expect(checkForOAuthCallback()).toBeNull();
163
+
});
164
+
});
165
+
166
+
describe("clearOAuthCallbackParams", () => {
167
+
it("removes query params from URL", () => {
168
+
const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState");
169
+
170
+
Object.defineProperty(globalThis.location, "href", {
171
+
value: "http://localhost:3000/app/?code=123&state=456",
172
+
writable: true,
173
+
configurable: true,
174
+
});
175
+
176
+
clearOAuthCallbackParams();
177
+
178
+
expect(replaceStateSpy).toHaveBeenCalled();
179
+
const callArgs = replaceStateSpy.mock.calls[0];
180
+
expect(callArgs[0]).toEqual({});
181
+
expect(callArgs[1]).toBe("");
182
+
const urlString = callArgs[2] as string;
183
+
expect(urlString).toBe("http://localhost:3000/app/");
184
+
expect(urlString).not.toContain("?");
185
+
expect(urlString).not.toContain("code=");
186
+
expect(urlString).not.toContain("state=");
187
+
});
188
+
});
189
+
});
190
+
191
+
describe("DPoP proof generation", () => {
192
+
it("base64url encoding produces valid output", async () => {
193
+
const testData = new Uint8Array([72, 101, 108, 108, 111]);
194
+
const buffer = testData.buffer;
195
+
196
+
const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join("");
197
+
const base64url = btoa(binary)
198
+
.replace(/\+/g, "-")
199
+
.replace(/\//g, "_")
200
+
.replace(/=+$/, "");
201
+
202
+
expect(base64url).toBe("SGVsbG8");
203
+
expect(base64url).not.toContain("+");
204
+
expect(base64url).not.toContain("/");
205
+
expect(base64url).not.toContain("=");
206
+
});
207
+
208
+
it("JWK thumbprint uses correct key ordering for EC keys", () => {
209
+
const jwk = {
210
+
kty: "EC",
211
+
crv: "P-256",
212
+
x: "test-x",
213
+
y: "test-y",
214
+
};
215
+
216
+
const canonical = JSON.stringify({
217
+
crv: jwk.crv,
218
+
kty: jwk.kty,
219
+
x: jwk.x,
220
+
y: jwk.y,
221
+
});
222
+
223
+
expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}');
224
+
225
+
const keys = Object.keys(JSON.parse(canonical));
226
+
expect(keys).toEqual(["crv", "kty", "x", "y"]);
227
+
228
+
for (let i = 1; i < keys.length; i++) {
229
+
expect(keys[i - 1] < keys[i]).toBe(true);
230
+
}
231
+
});
232
+
});