Skip to content

Commit a8ea72b

Browse files
committed
Preserve auth during network reconnects (#413)
1 parent 2899ddc commit a8ea72b

4 files changed

Lines changed: 126 additions & 14 deletions

File tree

web/messages/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"auth_errors_auth_disabled": "Authentication is disabled by server configuration.",
3131
"auth_errors_invalid_credentials": "Invalid credentials. Please try again.",
3232
"auth_errors_network": "Network error. Please check your connection.",
33+
"auth_reconnecting_title": "Reconnecting to Garcon",
34+
"auth_reconnecting_description": "Your session is still saved. Garcon will retry when your network returns.",
3335
"auth_errors_server": "Server error. Please try again later.",
3436
"chat_empty_no_chat_selected": "No chat selected",
3537
"chat_empty_press_new_chat": "Press New Chat to start.",

web/src/lib/stores/__tests__/auth.test.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ vi.mock('$lib/api/client.js', () => ({
2727
clearAuthToken: vi.fn(),
2828
ApiError: class extends Error {
2929
status: number;
30+
retryable = false;
3031
constructor(status: number, message: string) {
3132
super(message);
3233
this.status = status;
@@ -41,10 +42,11 @@ import {
4142
getUser,
4243
logout as apiLogout,
4344
} from '$lib/api/auth.js';
44-
import { setAuthToken, clearAuthToken } from '$lib/api/client.js';
45+
import { setAuthToken, clearAuthToken, ApiError } from '$lib/api/client.js';
4546

4647
describe('AuthStore', () => {
4748
beforeEach(() => {
49+
vi.useRealTimers();
4850
for (const k of Object.keys(store)) delete store[k];
4951
vi.clearAllMocks();
5052
});
@@ -91,18 +93,62 @@ describe('AuthStore', () => {
9193
expect(auth.user).toEqual({ id: '1', username: 'admin' });
9294
});
9395

94-
it('clears invalid token when getUser fails', async () => {
95-
store[LOCAL_STORAGE_KEYS.authToken] = 'expired-token';
96+
it.each([401, 403])(
97+
'clears an invalid token after an authoritative %i rejection',
98+
async (status) => {
99+
store[LOCAL_STORAGE_KEYS.authToken] = 'expired-token';
100+
vi.mocked(getAuthStatus).mockResolvedValue({
101+
needsSetup: false,
102+
isAuthenticated: false,
103+
authDisabled: false,
104+
});
105+
vi.mocked(getUser).mockRejectedValue(new ApiError(status, 'Rejected'));
106+
const auth = new AuthStore();
107+
await auth.checkAuthStatus();
108+
expect(auth.token).toBeNull();
109+
expect(clearAuthToken).toHaveBeenCalled();
110+
},
111+
);
112+
113+
it('keeps the token and retries when auth status is temporarily unreachable', async () => {
114+
vi.useFakeTimers();
115+
store[LOCAL_STORAGE_KEYS.authToken] = 'saved-token';
116+
vi.mocked(getAuthStatus).mockRejectedValue(new TypeError('Failed to fetch'));
117+
const auth = new AuthStore();
118+
119+
const check = auth.checkAuthStatus();
120+
await vi.runAllTimersAsync();
121+
await check;
122+
123+
expect(getAuthStatus).toHaveBeenCalledTimes(5);
124+
expect(auth.token).toBe('saved-token');
125+
expect(auth.isUnavailable).toBe(true);
126+
expect(auth.error).toBe('Network error. Please check your connection.');
127+
expect(clearAuthToken).not.toHaveBeenCalled();
128+
});
129+
130+
it('recovers from transient user validation without clearing the token', async () => {
131+
vi.useFakeTimers();
132+
store[LOCAL_STORAGE_KEYS.authToken] = 'saved-token';
96133
vi.mocked(getAuthStatus).mockResolvedValue({
97134
needsSetup: false,
98-
isAuthenticated: false,
135+
isAuthenticated: true,
99136
authDisabled: false,
100137
});
101-
vi.mocked(getUser).mockRejectedValue(new Error('401'));
138+
vi.mocked(getUser)
139+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
140+
.mockResolvedValueOnce({ user: { id: '1', username: 'admin' } });
102141
const auth = new AuthStore();
103-
await auth.checkAuthStatus();
104-
expect(auth.token).toBeNull();
105-
expect(clearAuthToken).toHaveBeenCalled();
142+
143+
const check = auth.checkAuthStatus();
144+
await vi.runAllTimersAsync();
145+
await check;
146+
147+
expect(getUser).toHaveBeenCalledTimes(2);
148+
expect(auth.token).toBe('saved-token');
149+
expect(auth.user).toEqual({ id: '1', username: 'admin' });
150+
expect(auth.isUnavailable).toBe(false);
151+
expect(clearAuthToken).not.toHaveBeenCalled();
106152
});
107153

108154
it('enters app mode without token when auth is disabled by server config', async () => {
@@ -143,6 +189,16 @@ describe('AuthStore', () => {
143189
expect(result.success).toBe(false);
144190
expect(auth.error).toBeTruthy();
145191
});
192+
193+
it('maps transport failures to a useful network error', async () => {
194+
vi.mocked(apiLogin).mockRejectedValue(new TypeError('Failed to fetch'));
195+
const auth = new AuthStore();
196+
const result = await auth.login('admin', 'pass');
197+
expect(result).toEqual({
198+
success: false,
199+
error: 'Network error. Please check your connection.',
200+
});
201+
});
146202
});
147203

148204
describe('register', () => {

web/src/lib/stores/auth.svelte.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@ import {
1212
import { getAuthToken, setAuthToken, clearAuthToken, ApiError } from '$lib/api/client.js';
1313
import * as m from '$lib/paraglide/messages.js';
1414

15+
const AUTH_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000] as const;
16+
17+
function isAuthoritativeAuthRejection(err: unknown): boolean {
18+
return err instanceof ApiError && (err.status === 401 || err.status === 403);
19+
}
20+
21+
function isRetryableAuthError(err: unknown): boolean {
22+
if (!(err instanceof ApiError)) return true;
23+
const apiError = err as ApiError;
24+
return apiError.retryable || apiError.status === 429 || apiError.status >= 500;
25+
}
26+
27+
async function retryAuthRequest<T>(request: () => Promise<T>): Promise<T> {
28+
let lastError: unknown;
29+
for (let attempt = 0; attempt <= AUTH_RETRY_DELAYS_MS.length; attempt += 1) {
30+
try {
31+
return await request();
32+
} catch (err) {
33+
lastError = err;
34+
if (!isRetryableAuthError(err) || attempt === AUTH_RETRY_DELAYS_MS.length) throw err;
35+
await new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAYS_MS[attempt]));
36+
}
37+
}
38+
throw lastError;
39+
}
40+
1541
/** Maps API errors to user-facing messages based on HTTP status. */
1642
function describeAuthError(err: unknown): string {
1743
if (err instanceof ApiError) {
@@ -21,7 +47,7 @@ function describeAuthError(err: unknown): string {
2147
if (err.status >= 500) return m.auth_errors_server();
2248
return err.message;
2349
}
24-
if (err instanceof Error) return err.message;
50+
if (err instanceof Error) return m.auth_errors_network();
2551
return m.auth_errors_network();
2652
}
2753

@@ -36,6 +62,7 @@ export class AuthStore {
3662
isLoading = $state(true);
3763
needsSetup = $state(false);
3864
authDisabled = $state(false);
65+
isUnavailable = $state(false);
3966
error = $state<string | null>(null);
4067
isAuthenticated = $derived(this.authDisabled || (!!this.token && !!this.user));
4168

@@ -47,9 +74,10 @@ export class AuthStore {
4774
async checkAuthStatus(): Promise<void> {
4875
try {
4976
this.isLoading = true;
77+
this.isUnavailable = false;
5078
this.error = null;
5179

52-
const status = await getAuthStatus();
80+
const status = await retryAuthRequest(getAuthStatus);
5381
this.authDisabled = Boolean(status.authDisabled);
5482

5583
if (this.authDisabled) {
@@ -72,10 +100,11 @@ export class AuthStore {
72100

73101
if (this.token) {
74102
try {
75-
const data = await getUser();
103+
const data = await retryAuthRequest(getUser);
76104
this.user = data.user;
77-
} catch {
78-
// Token is invalid or expired
105+
} catch (err) {
106+
if (!isAuthoritativeAuthRejection(err)) throw err;
107+
// The server authoritatively rejected the token.
79108
clearAuthToken();
80109
this.token = null;
81110
this.user = null;
@@ -85,6 +114,7 @@ export class AuthStore {
85114
}
86115
} catch (err) {
87116
console.error('[AuthStore] Auth status check failed:', err);
117+
this.isUnavailable = true;
88118
this.error = describeAuthError(err);
89119
} finally {
90120
this.isLoading = false;
@@ -104,6 +134,7 @@ export class AuthStore {
104134
const data = await apiLogin(username, password);
105135
this.token = data.token;
106136
this.user = data.user;
137+
this.isUnavailable = false;
107138
setAuthToken(data.token);
108139
return { success: true };
109140
} catch (err: unknown) {

web/src/routes/+layout.svelte

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { goto } from '$app/navigation';
55
import { page } from '$app/state';
66
import MessageSquare from '@lucide/svelte/icons/message-square';
7+
import { Button } from '$lib/components/ui/button/index.js';
78
89
import { createAuthStore } from '$lib/stores/auth.svelte.js';
910
import { createLocalSettingsStore } from '$lib/stores/local-settings.svelte.js';
@@ -300,6 +301,11 @@
300301
301302
onMount(() => {
302303
auth.checkAuthStatus();
304+
const handleOnline = () => {
305+
if (auth.isUnavailable) void auth.checkAuthStatus();
306+
};
307+
window.addEventListener('online', handleOnline);
308+
return () => window.removeEventListener('online', handleOnline);
303309
});
304310
305311
function handlePageHide() {
@@ -373,6 +379,7 @@
373379
$effect(() => {
374380
if (auth.isLoading) return;
375381
if (auth.authDisabled) return;
382+
if (auth.isUnavailable) return;
376383
377384
if (page.url.pathname === '/setup' && !auth.needsSetup && !auth.isAuthenticated) {
378385
goto('/login');
@@ -392,6 +399,7 @@
392399
// Shared view routes are excluded -- they are public by design.
393400
$effect(() => {
394401
if (auth.isLoading) return;
402+
if (auth.isUnavailable) return;
395403
if (!isPublicRoute) return;
396404
if (page.url.pathname.startsWith('/shared/')) return;
397405
if (auth.authDisabled || (auth.isAuthenticated && !auth.needsSetup)) {
@@ -418,7 +426,7 @@
418426
});
419427
</script>
420428

421-
{#if auth.isLoading && !isPublicRoute}
429+
{#if auth.isLoading && (!isPublicRoute || auth.token)}
422430
<div class="min-h-dvh bg-background flex items-center justify-center p-4">
423431
<div class="text-center">
424432
<div class="flex justify-center mb-4">
@@ -441,6 +449,21 @@
441449
<p class="text-muted-foreground mt-2">{m.status_loading()}</p>
442450
</div>
443451
</div>
452+
{:else if auth.isUnavailable && (!isPublicRoute || auth.token)}
453+
<div class="min-h-dvh bg-background flex items-center justify-center p-4">
454+
<div class="max-w-md text-center">
455+
<div class="flex justify-center mb-4">
456+
<div class="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
457+
<MessageSquare class="w-8 h-8 text-primary-foreground" />
458+
</div>
459+
</div>
460+
<h1 class="text-2xl font-bold text-foreground mb-2">{m.auth_reconnecting_title()}</h1>
461+
<p class="text-muted-foreground mb-4">{m.auth_reconnecting_description()}</p>
462+
<Button onclick={() => auth.checkAuthStatus()} disabled={auth.isLoading}>
463+
{m.common_retry()}
464+
</Button>
465+
</div>
466+
</div>
444467
{:else if isPublicRoute}
445468
{@render children()}
446469
{:else if auth.isAuthenticated}

0 commit comments

Comments
 (0)