feat: add connection loss handling and update auth store
This commit is contained in:
+10
-2
@@ -57,7 +57,7 @@ export default function Home() {
|
|||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
@@ -801,7 +801,14 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
||||||
|
{connectionLost && (
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-destructive/10 border-b border-destructive/30 text-destructive text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
|
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span>{tCommon('reconnecting')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Desktop Navigation Rail */}
|
{/* Desktop Navigation Rail */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && (
|
||||||
<div className="w-14 border-r border-border bg-secondary flex flex-col flex-shrink-0">
|
<div className="w-14 border-r border-border bg-secondary flex flex-col flex-shrink-0">
|
||||||
@@ -1305,6 +1312,7 @@ export default function Home() {
|
|||||||
<NavigationRail orientation="horizontal" />
|
<NavigationRail orientation="horizontal" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Modal */}
|
{/* Keyboard Shortcuts Modal */}
|
||||||
<KeyboardShortcutsModal
|
<KeyboardShortcutsModal
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { JMAPClient } from '../jmap/client';
|
||||||
|
|
||||||
|
// Minimal valid JMAP session response
|
||||||
|
function makeSession(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||||
|
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||||
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||||
|
apiUrl: 'https://mail.example.com/jmap/api',
|
||||||
|
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||||
|
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||||
|
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchResponse(status: number, body?: unknown): Response {
|
||||||
|
return new Response(body ? JSON.stringify(body) : null, {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JMAPClient resilience', () => {
|
||||||
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a connected basic-auth client by mocking the connect() flow
|
||||||
|
*/
|
||||||
|
async function createConnectedClient(mode: 'basic' | 'bearer' = 'basic'): Promise<JMAPClient> {
|
||||||
|
const session = makeSession();
|
||||||
|
|
||||||
|
if (mode === 'basic') {
|
||||||
|
// connect() calls authenticatedFetch → fetch for session
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
|
||||||
|
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
||||||
|
await client.connect();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearer
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
|
||||||
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||||
|
await client.connect();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authenticatedFetch — network error retry', () => {
|
||||||
|
it('retries once on transient network error', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
|
||||||
|
fetchSpy
|
||||||
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
// ping() calls request() which calls authenticatedFetch
|
||||||
|
await expect(client.ping()).resolves.toBeUndefined();
|
||||||
|
// First call fails, delay, second call succeeds
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on persistent network error after retry', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
|
fetchSpy
|
||||||
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||||
|
|
||||||
|
await expect(client.ping()).rejects.toThrow('Failed to fetch');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticatedFetch — basic auth 401 session refresh', () => {
|
||||||
|
it('refreshes session and retries on 401 for API requests', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' });
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
|
||||||
|
fetchSpy
|
||||||
|
// First API call → 401
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401))
|
||||||
|
// refreshSession() fetches /.well-known/jmap
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, refreshedSession))
|
||||||
|
// Retry of original request → 200
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
await expect(client.ping()).resolves.toBeUndefined();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// Verify the session refresh hit the right URL
|
||||||
|
const refreshCall = fetchSpy.mock.calls[1];
|
||||||
|
expect(refreshCall[0]).toBe('https://mail.example.com/.well-known/jmap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original 401 response when session refresh fails', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
|
fetchSpy
|
||||||
|
// First API call → 401
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401))
|
||||||
|
// refreshSession() also fails
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401));
|
||||||
|
|
||||||
|
// request() throws because response.ok is false
|
||||||
|
await expect(client.ping()).rejects.toThrow('Request failed: 401');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT attempt session refresh for /.well-known/jmap requests', async () => {
|
||||||
|
// Connect will fail with 401 on the session URL itself
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(401));
|
||||||
|
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'wrong-pass');
|
||||||
|
|
||||||
|
// connect() should throw without trying to refresh session (would cause infinite recursion)
|
||||||
|
await expect(client.connect()).rejects.toThrow('Invalid username or password');
|
||||||
|
// Only one fetch call — no refresh attempt
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticatedFetch — bearer token refresh', () => {
|
||||||
|
it('refreshes token and retries on 401 for bearer mode', async () => {
|
||||||
|
const tokenRefresh = vi.fn().mockResolvedValue('new-token-456');
|
||||||
|
const session = makeSession();
|
||||||
|
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
|
||||||
|
const client = JMAPClient.withBearer('https://mail.example.com', 'old-token', 'user@test.com', tokenRefresh);
|
||||||
|
await client.connect();
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
|
||||||
|
fetchSpy
|
||||||
|
// First API call → 401
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401))
|
||||||
|
// Retry with new token → 200
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
await expect(client.ping()).resolves.toBeUndefined();
|
||||||
|
expect(tokenRefresh).toHaveBeenCalledOnce();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Verify retry used the new token
|
||||||
|
const retryCall = fetchSpy.mock.calls[1];
|
||||||
|
const retryHeaders = retryCall[1]?.headers as Record<string, string>;
|
||||||
|
expect(retryHeaders['Authorization']).toBe('Bearer new-token-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshSession', () => {
|
||||||
|
it('updates session fields from server response', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
|
const newSession = makeSession({
|
||||||
|
apiUrl: 'https://mail.example.com/jmap/api-v2',
|
||||||
|
downloadUrl: 'https://mail.example.com/jmap/download-v2/{accountId}/{blobId}/{name}',
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a 401 → refreshSession flow
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
fetchSpy
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(401))
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, newSession))
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
await client.ping();
|
||||||
|
|
||||||
|
// After refresh, subsequent requests should go to the new apiUrl
|
||||||
|
fetchSpy.mockReset();
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
await client.ping();
|
||||||
|
|
||||||
|
const apiCall = fetchSpy.mock.calls[0];
|
||||||
|
expect(apiCall[0]).toBe('https://mail.example.com/jmap/api-v2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onConnectionChange callback', () => {
|
||||||
|
it('fires with true on successful ping during keep-alive', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const callback = vi.fn();
|
||||||
|
client.onConnectionChange(callback);
|
||||||
|
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
fetchSpy.mockResolvedValue(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
// Advance past keep-alive interval (30s)
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires with false on ping failure, then true on successful reconnect', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const callback = vi.fn();
|
||||||
|
client.onConnectionChange(callback);
|
||||||
|
|
||||||
|
const session = makeSession();
|
||||||
|
|
||||||
|
// ping() will call request() → authenticatedFetch → first fetch fails
|
||||||
|
// Then retry in authenticatedFetch also fails
|
||||||
|
// So ping throws, keep-alive catches it, fires false
|
||||||
|
// Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds
|
||||||
|
fetchSpy
|
||||||
|
// ping fails — network error, retry also fails
|
||||||
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||||
|
// reconnect → connect() → session URL succeeds
|
||||||
|
.mockResolvedValueOnce(mockFetchResponse(200, session));
|
||||||
|
|
||||||
|
// Trigger the keep-alive interval
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
// Flush the nested 1s retry delay inside authenticatedFetch
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
// Allow microtasks to settle
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
|
// Verify ordering: false fired before true
|
||||||
|
const calls = callback.mock.calls.map((c) => c[0]);
|
||||||
|
const falseIdx = calls.indexOf(false);
|
||||||
|
const trueIdx = calls.lastIndexOf(true);
|
||||||
|
expect(falseIdx).toBeLessThan(trueIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires with false when ping and reconnect both fail', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const callback = vi.fn();
|
||||||
|
client.onConnectionChange(callback);
|
||||||
|
|
||||||
|
// All fetches fail
|
||||||
|
fetchSpy.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
|
||||||
|
// Trigger the keep-alive interval
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
// Flush nested retry delays
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
// Should not have fired true at any point
|
||||||
|
const trueCall = callback.mock.calls.find((c) => c[0] === true);
|
||||||
|
expect(trueCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disconnect', () => {
|
||||||
|
it('stops keep-alive and cleans up', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const callback = vi.fn();
|
||||||
|
client.onConnectionChange(callback);
|
||||||
|
|
||||||
|
client.disconnect();
|
||||||
|
|
||||||
|
// Advancing timers should not trigger any ping
|
||||||
|
fetchSpy.mockResolvedValue(mockFetchResponse(200, { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }));
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in...",
|
"signing_in": "Signing in...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"reconnecting": "Connection lost. Attempting to reconnect\u2026",
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
|
"invalid_credentials": "Invalid email or password. Please check your credentials and try again.",
|
||||||
"connection_failed": "Unable to reach the server. Check your internet connection and try again.",
|
"connection_failed": "Unable to reach the server. Check your internet connection and try again.",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface AuthState {
|
|||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
tokenExpiresAt: number | null;
|
tokenExpiresAt: number | null;
|
||||||
|
connectionLost: boolean;
|
||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||||
@@ -131,6 +132,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
|
|
||||||
login: async (serverUrl, username, password, totp, rememberMe) => {
|
login: async (serverUrl, username, password, totp, rememberMe) => {
|
||||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||||
@@ -138,6 +140,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
@@ -154,6 +159,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
authMode: 'basic',
|
authMode: 'basic',
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,6 +216,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn());
|
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn());
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const username = client.getUsername();
|
const username = client.getUsername();
|
||||||
@@ -227,6 +236,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
authMode: 'oauth',
|
authMode: 'oauth',
|
||||||
accessToken: access_token,
|
accessToken: access_token,
|
||||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||||
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,6 +317,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: null,
|
||||||
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,6 +376,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (token && state.serverUrl) {
|
if (token && state.serverUrl) {
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
|
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
||||||
@@ -403,6 +417,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
const { serverUrl, username, password } = data;
|
const { serverUrl, username, password } = data;
|
||||||
const client = new JMAPClient(serverUrl, username, password);
|
const client = new JMAPClient(serverUrl, username, password);
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||||
|
|||||||
Reference in New Issue
Block a user