+9
frontend/src/lib/auth.svelte.ts
+9
frontend/src/lib/auth.svelte.ts
···
20
20
import { assertNever } from "./types/exhaustive.ts";
21
21
import {
22
22
checkForOAuthCallback,
23
+
clearAllOAuthState,
23
24
clearOAuthCallbackParams,
24
25
handleOAuthCallback,
25
26
refreshOAuthToken,
···
274
275
setState(createLoading(getSavedAccounts(), previousSession));
275
276
}
276
277
278
+
export function clearError(): void {
279
+
if (state.current.kind === "error") {
280
+
setState(createUnauthenticated(getSavedAccounts()));
281
+
}
282
+
}
283
+
277
284
async function tryRefreshToken(): Promise<string | null> {
278
285
if (state.current.kind !== "authenticated") return null;
279
286
const currentSession = state.current.session;
···
323
330
applyLocaleFromSession(session);
324
331
return { oauthLoginCompleted: true };
325
332
} catch (e) {
333
+
clearAllOAuthState();
326
334
setError({
327
335
type: "oauth",
328
336
message: e instanceof Error ? e.message : "OAuth login failed",
···
398
406
}
399
407
400
408
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
409
+
clearAllOAuthState();
401
410
setLoading();
402
411
try {
403
412
await startOAuthLogin();
+244
-39
frontend/src/lib/oauth.ts
+244
-39
frontend/src/lib/oauth.ts
···
1
1
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2
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
+
3
6
const SCOPES = [
4
7
"atproto",
5
8
"repo:*?action=create",
···
7
10
"repo:*?action=delete",
8
11
"blob:*/*",
9
12
].join(" ");
13
+
10
14
const CLIENT_ID = !(import.meta.env.DEV)
11
15
? `${globalThis.location.origin}/oauth/client-metadata.json`
12
16
: `http://localhost/?scope=${SCOPES}`;
17
+
13
18
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
14
19
15
20
interface OAuthState {
16
21
state: string;
17
22
codeVerifier: string;
18
23
returnTo?: string;
24
+
}
25
+
26
+
interface DPoPKeyPair {
27
+
publicKey: CryptoKey;
28
+
privateKey: CryptoKey;
29
+
jwk: JsonWebKey;
19
30
}
20
31
21
32
function generateRandomString(length: number): string {
···
73
84
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
74
85
}
75
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
+
76
262
export async function startOAuthLogin(): Promise<void> {
263
+
clearAllOAuthState();
264
+
77
265
const state = generateState();
78
266
const codeVerifier = generateCodeVerifier();
79
267
const codeChallenge = await generateCodeChallenge(codeVerifier);
268
+
269
+
const keyPair = await getOrCreateDPoPKeyPair();
270
+
const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
80
271
81
272
saveOAuthState({ state, codeVerifier });
82
273
···
91
282
state: state,
92
283
code_challenge: codeChallenge,
93
284
code_challenge_method: "S256",
285
+
dpop_jkt: dpopJkt,
94
286
}),
95
287
});
96
288
···
121
313
sub: string;
122
314
}
123
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
+
124
356
export async function handleOAuthCallback(
125
357
code: string,
126
358
state: string,
···
135
367
throw new Error("OAuth state mismatch. Please try logging in again.");
136
368
}
137
369
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
-
}),
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,
148
376
});
149
377
150
378
clearOAuthState();
151
379
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();
380
+
return tokenRequest(params);
163
381
}
164
382
165
383
export async function refreshOAuthToken(
166
384
refreshToken: string,
167
385
): 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
-
}),
386
+
const params = new URLSearchParams({
387
+
grant_type: "refresh_token",
388
+
client_id: CLIENT_ID,
389
+
refresh_token: refreshToken,
176
390
});
177
391
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();
392
+
return tokenRequest(params);
188
393
}
189
394
190
395
export function checkForOAuthCallback():
+22
-4
frontend/src/routes/Dashboard.svelte
+22
-4
frontend/src/routes/Dashboard.svelte
···
285
285
justify-content: space-between;
286
286
align-items: center;
287
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
+
}
288
296
}
289
297
290
298
header h1 {
291
299
margin: 0;
300
+
min-width: 0;
292
301
}
293
302
294
303
.account-dropdown {
295
304
position: relative;
305
+
max-width: 100%;
296
306
}
297
307
298
308
.account-trigger {
···
305
315
border-radius: var(--radius-md);
306
316
cursor: pointer;
307
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;
308
326
}
309
327
310
328
.account-trigger:hover:not(:disabled) {
···
314
332
.account-trigger:disabled {
315
333
opacity: 0.6;
316
334
cursor: not-allowed;
317
-
}
318
-
319
-
.account-trigger .account-handle {
320
-
font-weight: var(--font-medium);
321
335
}
322
336
323
337
.dropdown-arrow {
···
383
397
padding: var(--space-6);
384
398
border-radius: var(--radius-xl);
385
399
margin-bottom: var(--space-7);
400
+
overflow: hidden;
401
+
min-width: 0;
386
402
}
387
403
388
404
section h2 {
···
400
416
dt {
401
417
font-weight: var(--font-medium);
402
418
color: var(--text-secondary);
419
+
max-width: 6rem;
403
420
}
404
421
405
422
dd {
406
423
margin: 0;
424
+
min-width: 0;
407
425
}
408
426
409
427
.mono {
+9
-12
frontend/src/routes/Login.svelte
+9
-12
frontend/src/routes/Login.svelte
···
6
6
getAuthState,
7
7
switchAccount,
8
8
forgetAccount,
9
+
clearError,
9
10
matchAuthState,
10
11
type SavedAccount,
11
12
type AuthError,
···
14
15
import { _ } from '../lib/i18n'
15
16
import { isOk, isErr } from '../lib/types/result'
16
17
import { unsafeAsDid, type Did } from '../lib/types/branded'
18
+
import { toast } from '../lib/toast.svelte'
17
19
18
20
type PageState =
19
21
| { kind: 'login' }
···
32
34
return auth.savedAccounts
33
35
}
34
36
35
-
function getErrorMessage(): string | null {
36
-
if (auth.kind === 'error') {
37
-
return auth.error.message
38
-
}
39
-
return null
40
-
}
41
-
42
37
function isLoading(): boolean {
43
38
return auth.kind === 'loading'
44
39
}
40
+
41
+
$effect(() => {
42
+
if (auth.kind === 'error') {
43
+
toast.error(auth.error.message)
44
+
clearError()
45
+
}
46
+
})
45
47
46
48
$effect(() => {
47
49
const accounts = getSavedAccounts()
···
108
110
resendMessage = null
109
111
}
110
112
111
-
const errorMessage = $derived(getErrorMessage())
112
113
const savedAccounts = $derived(getSavedAccounts())
113
114
const loading = $derived(isLoading())
114
115
</script>
115
116
116
117
<div class="login-page">
117
-
{#if errorMessage}
118
-
<div class="message error">{errorMessage}</div>
119
-
{/if}
120
-
121
118
{#if pageState.kind === 'verification'}
122
119
<header class="page-header">
123
120
<h1>{$_('verification.title')}</h1>
+8
frontend/src/styles/base.css
+8
frontend/src/styles/base.css
···
29
29
-webkit-font-smoothing: antialiased;
30
30
-moz-osx-font-smoothing: grayscale;
31
31
transition: background-color 0.3s ease;
32
+
overflow-wrap: anywhere;
33
+
word-break: break-word;
32
34
}
33
35
34
36
h1, h2, h3, h4, h5, h6 {
···
336
338
border: 1px solid var(--border-color);
337
339
border-radius: var(--radius-xl);
338
340
padding: var(--space-6);
341
+
overflow: hidden;
342
+
min-width: 0;
339
343
}
340
344
341
345
.section {
342
346
background: var(--bg-secondary);
343
347
border-radius: var(--radius-xl);
344
348
padding: var(--space-6);
349
+
overflow: hidden;
350
+
min-width: 0;
345
351
}
346
352
347
353
.section + .section {
···
469
475
border-radius: var(--radius-xl);
470
476
padding: var(--space-6);
471
477
height: fit-content;
478
+
overflow: hidden;
479
+
min-width: 0;
472
480
}
473
481
474
482
.info-panel h3 {
+13
-15
frontend/src/tests/AppPasswords.test.ts
+13
-15
frontend/src/tests/AppPasswords.test.ts
···
4
4
import {
5
5
clearMocks,
6
6
errorResponse,
7
+
getErrorToasts,
7
8
jsonResponse,
8
9
mockData,
9
10
mockEndpoint,
···
51
52
beforeEach(() => {
52
53
setupAuthenticatedUser();
53
54
});
54
-
it("shows loading text while fetching passwords", () => {
55
+
it("shows loading skeleton while fetching passwords", () => {
55
56
mockEndpoint(
56
57
"com.atproto.server.listAppPasswords",
57
58
() =>
···
59
60
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
60
61
),
61
62
);
62
-
render(AppPasswords);
63
-
expect(screen.getByText(/loading/i)).toBeInTheDocument();
63
+
const { container } = render(AppPasswords);
64
+
expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0);
64
65
});
65
66
});
66
67
describe("empty state", () => {
···
236
237
.toBeInTheDocument();
237
238
});
238
239
});
239
-
it("shows error when creation fails", async () => {
240
+
it("shows error toast when creation fails", async () => {
240
241
mockEndpoint(
241
242
"com.atproto.server.createAppPassword",
242
243
() => errorResponse("InvalidRequest", "Name already exists", 400),
···
250
251
});
251
252
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
252
253
await waitFor(() => {
253
-
expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
254
-
expect(screen.getByText(/name already exists/i)).toHaveClass("error");
254
+
const errors = getErrorToasts();
255
+
expect(errors.some((e) => /name already exists/i.test(e))).toBe(true);
255
256
});
256
257
});
257
258
});
···
358
359
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
359
360
});
360
361
});
361
-
it("shows error when revocation fails", async () => {
362
+
it("shows error toast when revocation fails", async () => {
362
363
globalThis.confirm = vi.fn(() => true);
363
364
mockEndpoint(
364
365
"com.atproto.server.listAppPasswords",
···
374
375
});
375
376
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
376
377
await waitFor(() => {
377
-
expect(screen.getByText(/server error/i)).toBeInTheDocument();
378
-
expect(screen.getByText(/server error/i)).toHaveClass("error");
378
+
const errors = getErrorToasts();
379
+
expect(errors.some((e) => /server error/i.test(e))).toBe(true);
379
380
});
380
381
});
381
382
});
···
383
384
beforeEach(() => {
384
385
setupAuthenticatedUser();
385
386
});
386
-
it("shows error when loading passwords fails", async () => {
387
+
it("shows error toast when loading passwords fails", async () => {
387
388
mockEndpoint(
388
389
"com.atproto.server.listAppPasswords",
389
390
() => errorResponse("InternalError", "Database connection failed", 500),
390
391
);
391
392
render(AppPasswords);
392
393
await waitFor(() => {
393
-
expect(screen.getByText(/database connection failed/i))
394
-
.toBeInTheDocument();
395
-
expect(screen.getByText(/database connection failed/i)).toHaveClass(
396
-
"error",
397
-
);
394
+
const errors = getErrorToasts();
395
+
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
398
396
});
399
397
});
400
398
});
+14
-17
frontend/src/tests/Comms.test.ts
+14
-17
frontend/src/tests/Comms.test.ts
···
4
4
import {
5
5
clearMocks,
6
6
errorResponse,
7
+
getErrorToasts,
8
+
getToasts,
7
9
jsonResponse,
8
10
mockData,
9
11
mockEndpoint,
···
71
73
() => jsonResponse({ notifications: [] }),
72
74
);
73
75
});
74
-
it("shows loading text while fetching preferences", () => {
76
+
it("shows loading skeleton while fetching preferences", () => {
75
77
mockEndpoint(
76
78
"_account.getNotificationPrefs",
77
79
() =>
···
82
84
)
83
85
),
84
86
);
85
-
render(Comms);
86
-
expect(screen.getByText(/loading/i)).toBeInTheDocument();
87
+
const { container } = render(Comms);
88
+
expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0);
87
89
});
88
90
});
89
91
describe("channel options", () => {
···
354
356
.toBeInTheDocument();
355
357
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
356
358
});
357
-
it("shows success message after saving", async () => {
359
+
it("shows success toast after saving", async () => {
358
360
mockEndpoint(
359
361
"_account.getNotificationPrefs",
360
362
() => jsonResponse(mockData.notificationPrefs()),
···
372
374
screen.getByRole("button", { name: /save preferences/i }),
373
375
);
374
376
await waitFor(() => {
375
-
expect(screen.getByText(/preferences saved/i))
376
-
.toBeInTheDocument();
377
+
const toasts = getToasts();
378
+
expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true);
377
379
});
378
380
});
379
-
it("shows error when save fails", async () => {
381
+
it("shows error toast when save fails", async () => {
380
382
mockEndpoint(
381
383
"_account.getNotificationPrefs",
382
384
() => jsonResponse(mockData.notificationPrefs()),
···
395
397
screen.getByRole("button", { name: /save preferences/i }),
396
398
);
397
399
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");
400
+
const errors = getErrorToasts();
401
+
expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true);
405
402
});
406
403
});
407
404
it("reloads preferences after successful save", async () => {
···
490
487
() => jsonResponse({ notifications: [] }),
491
488
);
492
489
});
493
-
it("shows error when loading preferences fails", async () => {
490
+
it("shows error toast when loading preferences fails", async () => {
494
491
mockEndpoint(
495
492
"_account.getNotificationPrefs",
496
493
() => errorResponse("InternalError", "Database connection failed", 500),
497
494
);
498
495
render(Comms);
499
496
await waitFor(() => {
500
-
expect(screen.getByText(/database connection failed/i))
501
-
.toBeInTheDocument();
497
+
const errors = getErrorToasts();
498
+
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true);
502
499
});
503
500
});
504
501
});
+3
-2
frontend/src/tests/Dashboard.test.ts
+3
-2
frontend/src/tests/Dashboard.test.ts
···
25
25
});
26
26
});
27
27
it("shows loading state while checking auth", () => {
28
-
render(Dashboard);
29
-
expect(screen.getByText(/loading/i)).toBeInTheDocument();
28
+
const { container } = render(Dashboard);
29
+
expect(container.querySelector(".skeleton-section")).toBeInTheDocument();
30
+
expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0);
30
31
});
31
32
});
32
33
describe("authenticated view", () => {
+7
-3
frontend/src/tests/Login.test.ts
+7
-3
frontend/src/tests/Login.test.ts
···
15
15
unsafeAsHandle,
16
16
unsafeAsRefreshToken,
17
17
} from "../lib/types/branded.ts";
18
+
import { getToasts } from "../lib/toast.svelte.ts";
18
19
19
20
describe("Login", () => {
20
21
beforeEach(() => {
···
147
148
});
148
149
149
150
describe("error handling", () => {
150
-
it("displays error message when auth state has error", async () => {
151
+
it("displays error message as toast when auth state has error", async () => {
151
152
_testSetState({
152
153
session: null,
153
154
loading: false,
···
156
157
});
157
158
render(Login);
158
159
await waitFor(() => {
159
-
expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument();
160
-
expect(screen.getByText(/oauth login failed/i)).toHaveClass("error");
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();
161
165
});
162
166
});
163
167
});
+17
-15
frontend/src/tests/Settings.test.ts
+17
-15
frontend/src/tests/Settings.test.ts
···
4
4
import {
5
5
clearMocks,
6
6
errorResponse,
7
+
getErrorToasts,
8
+
getToasts,
7
9
jsonResponse,
8
10
mockData,
9
11
mockEndpoint,
···
140
142
expect(capturedBody?.token).toBe("123456");
141
143
});
142
144
});
143
-
it("shows success message after email update", async () => {
145
+
it("shows success toast after email update", async () => {
144
146
mockEndpoint(
145
147
"com.atproto.server.requestEmailUpdate",
146
148
() => jsonResponse({ tokenRequired: true }),
···
171
173
screen.getByRole("button", { name: /confirm email change/i }),
172
174
);
173
175
await waitFor(() => {
174
-
expect(screen.getByText(/email updated/i))
175
-
.toBeInTheDocument();
176
+
const toasts = getToasts();
177
+
expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true);
176
178
});
177
179
});
178
180
it("shows cancel button to return to initial state", async () => {
···
205
207
.toBeInTheDocument();
206
208
});
207
209
});
208
-
it("shows error when request fails", async () => {
210
+
it("shows error toast when request fails", async () => {
209
211
mockEndpoint(
210
212
"com.atproto.server.requestEmailUpdate",
211
213
() => errorResponse("InvalidEmail", "Invalid email format", 400),
···
219
221
screen.getByRole("button", { name: /change email/i }),
220
222
);
221
223
await waitFor(() => {
222
-
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
224
+
const errors = getErrorToasts();
225
+
expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true);
223
226
});
224
227
});
225
228
});
···
261
264
expect(screen.getByRole("button", { name: /change handle/i }))
262
265
.toBeInTheDocument();
263
266
});
264
-
it("shows success message after handle change", async () => {
267
+
it("shows success toast after handle change", async () => {
265
268
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
266
269
mockEndpoint(
267
270
"com.atproto.server.getSession",
···
279
282
const button = screen.getByRole("button", { name: /change handle/i });
280
283
await fireEvent.submit(button.closest("form")!);
281
284
await waitFor(() => {
282
-
expect(screen.getByText(/handle updated/i))
283
-
.toBeInTheDocument();
285
+
const toasts = getToasts();
286
+
expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true);
284
287
});
285
288
});
286
-
it("shows error when handle change fails", async () => {
289
+
it("shows error toast when handle change fails", async () => {
287
290
mockEndpoint(
288
291
"com.atproto.identity.updateHandle",
289
292
() =>
···
302
305
const button = screen.getByRole("button", { name: /change handle/i });
303
306
await fireEvent.submit(button.closest("form")!);
304
307
await waitFor(() => {
305
-
const errorMessage = screen.queryByText(/handle is already taken/i) ||
306
-
screen.queryByText(/handle update failed/i);
307
-
expect(errorMessage).toBeInTheDocument();
308
+
const errors = getErrorToasts();
309
+
expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true);
308
310
});
309
311
});
310
312
});
···
500
502
).toBeInTheDocument();
501
503
});
502
504
});
503
-
it("shows error when deletion fails", async () => {
505
+
it("shows error toast when deletion fails", async () => {
504
506
globalThis.confirm = vi.fn(() => true);
505
507
mockEndpoint(
506
508
"com.atproto.server.requestAccountDelete",
···
532
534
screen.getByRole("button", { name: /permanently delete account/i }),
533
535
);
534
536
await waitFor(() => {
535
-
expect(screen.getByText(/invalid confirmation code/i))
536
-
.toBeInTheDocument();
537
+
const errors = getErrorToasts();
538
+
expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true);
537
539
});
538
540
});
539
541
});
+12
-1
frontend/src/tests/mocks.ts
+12
-1
frontend/src/tests/mocks.ts
···
1
1
import { vi } from "vitest";
2
2
import type { AppPassword, InviteCode, Session } from "../lib/api.ts";
3
-
import { _testSetState } from "../lib/auth.svelte.ts";
3
+
import { _testSetState, _testResetState } from "../lib/auth.svelte.ts";
4
+
import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts";
4
5
import {
5
6
unsafeAsAccessToken,
6
7
unsafeAsDid,
···
70
71
}
71
72
export function clearMocks(): void {
72
73
mockHandlers.clear();
74
+
_testResetState();
75
+
clearAllToasts();
73
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 };
74
85
function extractEndpoint(url: string): string {
75
86
const match = url.match(/\/xrpc\/([^?]+)/);
76
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
+
});