Stay signed in through a network blip #114
No files matched your search
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: shown while the session can't be refreshed because the server
|
||||
* doesn't answer. The reader stays signed in and on the page; the refresh is
|
||||
* retried on its own, and here on request.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { retryRefresh } from '@/services/api';
|
||||
import { useConnectionStore } from '@/stores/connectionStore';
|
||||
|
||||
export function ConnectionBanner() {
|
||||
const { t } = useTranslation();
|
||||
const unreachable = useConnectionStore((s) => s.unreachable);
|
||||
|
||||
if (!unreachable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-amber-500/40 bg-amber-500/5 px-4 py-3 text-sm"
|
||||
>
|
||||
<WifiOff className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="min-w-0 flex-1">
|
||||
{t('connection.unreachable', "Can't reach the server. You're still signed in, and it keeps trying.")}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={retryRefresh}>
|
||||
{t('connection.retry', 'Try now')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2337,5 +2337,9 @@
|
||||
"check": "Check the connection",
|
||||
"searchFailed": "The directory opened, but searching it failed: {{error}}",
|
||||
"connected": "The server can connect to this directory and search it."
|
||||
},
|
||||
"connection": {
|
||||
"unreachable": "Can't reach the server. You're still signed in, and it keeps trying.",
|
||||
"retry": "Try now"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { SectionNav } from '@/components/layout/SectionNav';
|
||||
import { MainContent } from '@/components/layout/MainContent';
|
||||
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
|
||||
import { ConnectionBanner } from '@/components/layout/ConnectionBanner';
|
||||
import { ExplainPanel } from '@/features/ai/explain/ExplainPanel';
|
||||
import { useExplainStore } from '@/features/ai/explain/explainStore';
|
||||
import { explainAvailable } from '@/features/ai/explain/explain';
|
||||
@@ -344,6 +345,7 @@ export default function AdminPanel() {
|
||||
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
|
||||
)}
|
||||
>
|
||||
<ConnectionBanner />
|
||||
<SettingsApplyBanner />
|
||||
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||
<MainContent viewName={viewName} id={id} section={section} />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { startAuthFlow } from '@/services/auth/oauth';
|
||||
import { clearReturnPath, readReturnPath } from '@/services/auth/session';
|
||||
import { SourceLink } from '@/components/common/SourceLink';
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,9 @@ import { SourceLink } from '@/components/common/SourceLink';
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const originalPath = (location.state as { from?: string } | null)?.from ?? null;
|
||||
// The page before signing in: from the route guard, or kept by a session
|
||||
// the server ended (see `endSession`).
|
||||
const originalPath = (location.state as { from?: string } | null)?.from ?? readReturnPath();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const started = useRef(false);
|
||||
@@ -40,6 +43,8 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await startAuthFlow(null, originalPath);
|
||||
// The sign-in round trip carries it from here.
|
||||
clearReturnPath();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred'));
|
||||
setLoading(false);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const { endSession } = vi.hoisted(() => ({ endSession: vi.fn() }));
|
||||
vi.mock('./auth/session', () => ({ endSession }));
|
||||
|
||||
// Fresh modules per test: the refresh keeps its retry state at module level.
|
||||
async function load() {
|
||||
vi.resetModules();
|
||||
const api = await import('./api');
|
||||
const { useAuthStore } = await import('@/stores/authStore');
|
||||
const { useConnectionStore } = await import('@/stores/connectionStore');
|
||||
return { ...api, useAuthStore, useConnectionStore };
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
beforeEach(() => {
|
||||
endSession.mockReset();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it("stays signed in when the server doesn't answer", async () => {
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow('Failed to fetch');
|
||||
expect(endSession).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().refreshToken).toBe('ref');
|
||||
expect(useConnectionStore.getState().unreachable).toBe(true);
|
||||
});
|
||||
|
||||
it.each([500, 502, 503, 429])('stays signed in on a %i', async (status) => {
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock.mockResolvedValueOnce(new Response('busy', { status }));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow();
|
||||
expect(endSession).not.toHaveBeenCalled();
|
||||
expect(useConnectionStore.getState().unreachable).toBe(true);
|
||||
});
|
||||
|
||||
it('stays signed in when a proxy answers with a page instead of a token', async () => {
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>maintenance</html>', { status: 200 }));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow();
|
||||
expect(endSession).not.toHaveBeenCalled();
|
||||
expect(useConnectionStore.getState().unreachable).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 401, 403])('ends the session when the server refuses (%i)', async (status) => {
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock.mockResolvedValueOnce(json({ error: 'invalid_grant' }, status));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow();
|
||||
expect(endSession).toHaveBeenCalledOnce();
|
||||
expect(useConnectionStore.getState().unreachable).toBe(false);
|
||||
});
|
||||
|
||||
it('tries again on its own and clears the note once the server answers', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce(json({ access_token: 'acc2', refresh_token: 'ref2', expires_in: 3600 }));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow();
|
||||
expect(useConnectionStore.getState().unreachable).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(useConnectionStore.getState().unreachable).toBe(false);
|
||||
expect(useAuthStore.getState().accessToken).toBe('acc2');
|
||||
expect(useAuthStore.getState().refreshToken).toBe('ref2');
|
||||
});
|
||||
|
||||
it('stops retrying once signed out', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { refreshAccessToken, useAuthStore, useConnectionStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
await expect(refreshAccessToken()).rejects.toThrow();
|
||||
useAuthStore.getState().logout();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(endSession).not.toHaveBeenCalled();
|
||||
expect(useConnectionStore.getState().unreachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('still sends a request with a token that has not run out', async () => {
|
||||
const { apiFetch, useAuthStore } = await load();
|
||||
// Expiring within the minute, so a refresh is tried first.
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 30, 'https://mail.test/token');
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(json({ ok: true }));
|
||||
|
||||
const response = await apiFetch('/jmap/session');
|
||||
expect(response.status).toBe(200);
|
||||
const [, init] = fetchMock.mock.calls[1];
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer acc');
|
||||
});
|
||||
|
||||
it("reports the server can't be reached, not a 401, when a refresh gets no answer", async () => {
|
||||
const { apiFetch, ApiError, useAuthStore } = await load();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
|
||||
const error = await apiFetch('/jmap/session').catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(TypeError);
|
||||
expect(error).not.toBeInstanceOf(ApiError);
|
||||
expect(endSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+106
-21
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
import { useConnectionStore } from '../stores/connectionStore';
|
||||
import { endSession } from './auth/session';
|
||||
import { getOAuthClientId } from '@/lib/oauthClientId';
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
@@ -41,24 +42,78 @@ export class ApiError extends Error {
|
||||
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* inbuxa: the token endpoint turned the refresh token down (spent, revoked,
|
||||
* or the account locked), so the session is over. Anything else, no answer
|
||||
* at all, a 5xx or rate limiting, passes, and signing out over it would lose
|
||||
* the page and whatever was typed into it.
|
||||
*/
|
||||
function isRefusal(status: number): boolean {
|
||||
return status === 400 || status === 401 || status === 403;
|
||||
}
|
||||
|
||||
// inbuxa: a refresh that failed without a refusal is tried again on its own:
|
||||
// after 5, 10 and 20 seconds, then every minute, and at once when the browser
|
||||
// says it is back online.
|
||||
const RETRY_DELAYS = [5_000, 10_000, 20_000, 60_000];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryCount = 0;
|
||||
let listeningForOnline = false;
|
||||
|
||||
/** Try the refresh again now, as the retry timer, the browser coming online or the reader would. */
|
||||
export function retryRefresh(): void {
|
||||
if (!useAuthStore.getState().refreshToken) {
|
||||
stopRetrying();
|
||||
return;
|
||||
}
|
||||
refreshAccessToken().catch(() => {});
|
||||
}
|
||||
|
||||
function serverUnreachable(): void {
|
||||
useConnectionStore.getState().setUnreachable(true);
|
||||
if (!listeningForOnline) {
|
||||
listeningForOnline = true;
|
||||
window.addEventListener('online', () => {
|
||||
if (useConnectionStore.getState().unreachable) retryRefresh();
|
||||
});
|
||||
}
|
||||
if (retryTimer) return;
|
||||
const delay = RETRY_DELAYS[Math.min(retryCount, RETRY_DELAYS.length - 1)];
|
||||
retryCount += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
retryRefresh();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function stopRetrying(): void {
|
||||
retryCount = 0;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
useConnectionStore.getState().setUnreachable(false);
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<void> {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState();
|
||||
const { refreshToken, tokenEndpoint } = useAuthStore.getState();
|
||||
|
||||
if (!refreshToken || !tokenEndpoint) {
|
||||
logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
stopRetrying();
|
||||
endSession();
|
||||
throw new Error('No refresh token or token endpoint available');
|
||||
}
|
||||
|
||||
const clientId = getOAuthClientId();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
@@ -67,20 +122,44 @@ export async function refreshAccessToken(): Promise<void> {
|
||||
client_id: clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint);
|
||||
} catch (error) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
// No answer: a dropped connection, the server restarting, a laptop waking up.
|
||||
serverUnreachable();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isRefusal(response.status)) {
|
||||
stopRetrying();
|
||||
endSession();
|
||||
} else {
|
||||
serverUnreachable();
|
||||
}
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
let data: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (error) {
|
||||
// A proxy's error page rather than the server's answer.
|
||||
serverUnreachable();
|
||||
throw error;
|
||||
}
|
||||
if (typeof data.access_token !== 'string' || typeof data.expires_in !== 'number') {
|
||||
serverUnreachable();
|
||||
throw new Error('Token refresh answered without a token');
|
||||
}
|
||||
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setTokens(
|
||||
data.access_token,
|
||||
typeof data.refresh_token === 'string' ? data.refresh_token : refreshToken,
|
||||
data.expires_in,
|
||||
tokenEndpoint,
|
||||
);
|
||||
stopRetrying();
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -94,7 +173,12 @@ export async function apiFetch(path: string, options?: RequestInit): Promise<Res
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
if (store.isTokenExpiringSoon() && store.refreshToken) {
|
||||
await refreshAccessToken();
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch (error) {
|
||||
// inbuxa: not refreshed yet, but a token that hasn't run out still works.
|
||||
if (!useAuthStore.getState().isAuthenticated()) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
@@ -121,13 +205,14 @@ export async function apiFetch(path: string, options?: RequestInit): Promise<Res
|
||||
if (useAuthStore.getState().refreshToken) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
response = await makeRequest();
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// inbuxa: the server can't be reached; that, not a 401, is what happened.
|
||||
if (useConnectionStore.getState().unreachable) throw error;
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
response = await makeRequest();
|
||||
} else {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
endSession();
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { clearReturnPath, endSession, readReturnPath } from './session';
|
||||
|
||||
describe('endSession', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAuthStore.getState().setTokens('acc', 'ref', 3600, 'https://mail.test/token');
|
||||
});
|
||||
|
||||
it('signs out, goes to sign-in and keeps the page to come back to', () => {
|
||||
window.history.pushState({}, '', '/management/x:Domain?page=2');
|
||||
const go = vi.fn();
|
||||
endSession(go);
|
||||
expect(go).toHaveBeenCalledWith('/login');
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
expect(useAuthStore.getState().refreshToken).toBeNull();
|
||||
expect(readReturnPath()).toBe('/management/x:Domain?page=2');
|
||||
});
|
||||
|
||||
it("doesn't come back to the sign-in pages", () => {
|
||||
sessionStorage.setItem('inbuxa_admin_return_path', '/stale');
|
||||
window.history.pushState({}, '', '/oauth/callback?code=c&state=s');
|
||||
endSession(vi.fn());
|
||||
expect(readReturnPath()).toBeNull();
|
||||
});
|
||||
|
||||
it('is cleared once sign-in has taken it', () => {
|
||||
window.history.pushState({}, '', '/settings');
|
||||
endSession(vi.fn());
|
||||
clearReturnPath();
|
||||
expect(readReturnPath()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// When the server ends a session (it refused to refresh it), signing in again
|
||||
// brings the reader back to the page they were on. The way out is a full page
|
||||
// load, which drops the router's state, so the page is kept in sessionStorage
|
||||
// for that one trip.
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
|
||||
const RETURN_KEY = 'inbuxa_admin_return_path';
|
||||
|
||||
function isAuthPath(path: string): boolean {
|
||||
return (
|
||||
path === '/login' ||
|
||||
path.startsWith('/login?') ||
|
||||
path === '/oauth/callback' ||
|
||||
path.startsWith('/oauth/callback?')
|
||||
);
|
||||
}
|
||||
|
||||
/** Sign out because the server ended the session, coming back here after signing in again. */
|
||||
export function endSession(
|
||||
go: (url: string) => void = (url) => {
|
||||
window.location.href = url;
|
||||
},
|
||||
): void {
|
||||
const basePath = getBasePath();
|
||||
const here = window.location.pathname + window.location.search;
|
||||
const path = basePath && here.startsWith(basePath) ? here.slice(basePath.length) || '/' : here;
|
||||
try {
|
||||
if (isAuthPath(path)) sessionStorage.removeItem(RETURN_KEY);
|
||||
else sessionStorage.setItem(RETURN_KEY, path);
|
||||
} catch {
|
||||
// Storage off: signing in lands on the dashboard.
|
||||
}
|
||||
useAuthStore.getState().logout();
|
||||
go(`${basePath}/login`);
|
||||
}
|
||||
|
||||
/** The page an ended session was on, if signing in hasn't taken it back there yet. */
|
||||
export function readReturnPath(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(RETURN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearReturnPath(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(RETURN_KEY);
|
||||
} catch {
|
||||
// Nothing kept, nothing to clear.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ConnectionState {
|
||||
/** The last token refresh got no answer from the server, or a passing error. */
|
||||
unreachable: boolean;
|
||||
setUnreachable: (unreachable: boolean) => void;
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>()((set) => ({
|
||||
unreachable: false,
|
||||
setUnreachable: (unreachable) => set({ unreachable }),
|
||||
}));
|
||||
Reference in new issue
Block a user