feat: implement rate limiting handling in JMAPClient #104
This commit is contained in:
@@ -3,6 +3,7 @@ import { IntlProvider } from "@/components/providers/intl-provider";
|
|||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||||
|
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||||
import { TourProvider } from "@/components/tour/tour-provider";
|
import { TourProvider } from "@/components/tour/tour-provider";
|
||||||
import { locales } from "@/i18n/routing";
|
import { locales } from "@/i18n/routing";
|
||||||
|
|
||||||
@@ -28,11 +29,13 @@ export default async function LocaleLayout({
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CalendarAlertProvider>
|
<CalendarAlertProvider>
|
||||||
<EmbeddedBridgeProvider>
|
<RateLimitToastProvider>
|
||||||
<TourProvider>
|
<EmbeddedBridgeProvider>
|
||||||
{children}
|
<TourProvider>
|
||||||
</TourProvider>
|
{children}
|
||||||
</EmbeddedBridgeProvider>
|
</TourProvider>
|
||||||
|
</EmbeddedBridgeProvider>
|
||||||
|
</RateLimitToastProvider>
|
||||||
</CalendarAlertProvider>
|
</CalendarAlertProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
|
|||||||
+26
-2
@@ -41,7 +41,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature } from "@/lib/signature-utils";
|
||||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square } from "lucide-react";
|
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
@@ -68,11 +68,28 @@ export default function Home() {
|
|||||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
|
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore();
|
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||||
const { identities } = useIdentityStore();
|
const { identities } = useIdentityStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRateLimited || !rateLimitUntil) {
|
||||||
|
setRateLimitSecondsLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const seconds = Math.max(1, Math.ceil((rateLimitUntil - Date.now()) / 1000));
|
||||||
|
setRateLimitSecondsLeft(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCountdown();
|
||||||
|
const timer = setInterval(updateCountdown, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isRateLimited, rateLimitUntil]);
|
||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
const { isMobile, isTablet } = useDeviceDetection();
|
||||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
|
||||||
@@ -1069,6 +1086,13 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<DragDropProvider>
|
<DragDropProvider>
|
||||||
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
<div className="flex flex-col h-dvh bg-background overflow-hidden">
|
||||||
|
{isRateLimited && rateLimitSecondsLeft !== null && (
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-300 text-sm py-1.5 px-4 flex-shrink-0">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>{tCommon('rate_limited_title')}</span>
|
||||||
|
<span className="text-amber-700/80 dark:text-amber-300/80">{tCommon('rate_limited_detail', { seconds: rateLimitSecondsLeft })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{connectionLost && (
|
{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">
|
<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" />
|
<RotateCcw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { toast } from '@/stores/toast-store';
|
||||||
|
|
||||||
|
type RateLimitBlockedDetail = {
|
||||||
|
retryAfterMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RateLimitToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onRateLimitBlocked = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<RateLimitBlockedDetail>).detail;
|
||||||
|
const seconds = Math.max(1, Math.ceil((detail?.retryAfterMs ?? 0) / 1000));
|
||||||
|
|
||||||
|
toast.warning(
|
||||||
|
tCommon('rate_limited_action_title'),
|
||||||
|
tCommon('rate_limited_action_detail', { seconds }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
|
||||||
|
return () => window.removeEventListener('bulwark:rate-limit-blocked', onRateLimitBlocked);
|
||||||
|
}, [tCommon]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -22,6 +22,16 @@ function mockFetchResponse(status: number, body?: unknown): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockFetchResponseWithHeaders(status: number, headers: Record<string, string>, body?: unknown): Response {
|
||||||
|
return new Response(body ? JSON.stringify(body) : null, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('JMAPClient resilience', () => {
|
describe('JMAPClient resilience', () => {
|
||||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
@@ -162,6 +172,36 @@ describe('JMAPClient resilience', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('authenticatedFetch — 429 rate limiting', () => {
|
||||||
|
it('stops sending authenticated requests until the retry window expires', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
|
||||||
|
type: 'about:blank',
|
||||||
|
status: 429,
|
||||||
|
title: 'Too Many Authentication Attempts',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(client.ping()).rejects.toThrow('Rate limited by server');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fetchSpy.mockClear();
|
||||||
|
await expect(client.ping()).rejects.toThrow('Rate limited by server');
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
fetchSpy.mockClear();
|
||||||
|
|
||||||
|
const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] };
|
||||||
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse));
|
||||||
|
|
||||||
|
await expect(client.ping()).resolves.toBeUndefined();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('refreshSession', () => {
|
describe('refreshSession', () => {
|
||||||
it('updates session fields from server response', async () => {
|
it('updates session fields from server response', async () => {
|
||||||
const client = await createConnectedClient();
|
const client = await createConnectedClient();
|
||||||
@@ -260,6 +300,32 @@ describe('JMAPClient resilience', () => {
|
|||||||
const trueCall = callback.mock.calls.find((c) => c[0] === true);
|
const trueCall = callback.mock.calls.find((c) => c[0] === true);
|
||||||
expect(trueCall).toBeUndefined();
|
expect(trueCall).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not mark the connection lost or reconnect repeatedly while rate limited', async () => {
|
||||||
|
const client = await createConnectedClient();
|
||||||
|
const callback = vi.fn();
|
||||||
|
client.onConnectionChange(callback);
|
||||||
|
|
||||||
|
fetchSpy.mockResolvedValueOnce(
|
||||||
|
mockFetchResponseWithHeaders(429, { 'Retry-After': '120' }, {
|
||||||
|
type: 'about:blank',
|
||||||
|
status: 429,
|
||||||
|
title: 'Too Many Authentication Attempts',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).not.toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
fetchSpy.mockClear();
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('disconnect', () => {
|
describe('disconnect', () => {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
setupPushNotifications(): boolean { return true; }
|
setupPushNotifications(): boolean { return true; }
|
||||||
closePushNotifications(): void { /* no-op in demo */ }
|
closePushNotifications(): void { /* no-op in demo */ }
|
||||||
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
|
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
|
||||||
|
onRateLimit(): void { /* no-op in demo */ }
|
||||||
|
isRateLimited(): boolean { return false; }
|
||||||
|
getRateLimitRemainingMs(): number { return 0; }
|
||||||
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
|
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
|
||||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||||
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface IJMAPClient {
|
|||||||
setupPushNotifications(): boolean;
|
setupPushNotifications(): boolean;
|
||||||
closePushNotifications(): void;
|
closePushNotifications(): void;
|
||||||
onConnectionChange(callback: (connected: boolean) => void): void;
|
onConnectionChange(callback: (connected: boolean) => void): void;
|
||||||
|
onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void;
|
||||||
|
isRateLimited(): boolean;
|
||||||
|
getRateLimitRemainingMs(): number;
|
||||||
onStateChange(callback: (change: StateChange) => void): void;
|
onStateChange(callback: (change: StateChange) => void): void;
|
||||||
getLastStates(): AccountStates;
|
getLastStates(): AccountStates;
|
||||||
setLastStates(states: AccountStates): void;
|
setLastStates(states: AccountStates): void;
|
||||||
|
|||||||
+139
-4
@@ -4,6 +4,15 @@ import type { IJMAPClient } from "./client-interface";
|
|||||||
import { toWildcardQuery } from "./search-utils";
|
import { toWildcardQuery } from "./search-utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
|
export class RateLimitError extends Error {
|
||||||
|
retryAfterMs: number;
|
||||||
|
constructor(retryAfterMs: number) {
|
||||||
|
super('Rate limited by server');
|
||||||
|
this.name = 'RateLimitError';
|
||||||
|
this.retryAfterMs = retryAfterMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||||
interface JMAPSession {
|
interface JMAPSession {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
@@ -102,6 +111,8 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class JMAPClient implements IJMAPClient {
|
export class JMAPClient implements IJMAPClient {
|
||||||
|
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
private serverUrl: string;
|
private serverUrl: string;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
@@ -121,6 +132,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private lastStates: AccountStates = {};
|
private lastStates: AccountStates = {};
|
||||||
private reconnecting = false;
|
private reconnecting = false;
|
||||||
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
||||||
|
private rateLimitedUntil: number = 0;
|
||||||
|
private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null;
|
||||||
|
private rateLimitTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private lastRateLimitNoticeAt: number = 0;
|
||||||
|
|
||||||
constructor(serverUrl: string, username: string, password: string) {
|
constructor(serverUrl: string, username: string, password: string) {
|
||||||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||||||
@@ -155,6 +170,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
|
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||||
|
// Short-circuit: if rate-limited, reject immediately without sending a request
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
const remaining = this.rateLimitedUntil - Date.now();
|
||||||
|
this.notifyRateLimitBlocked(remaining);
|
||||||
|
throw new RateLimitError(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
@@ -167,6 +189,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
response = await fetch(url, { ...init, headers });
|
response = await fetch(url, { ...init, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle 429 rate limiting — stop immediately, do not retry
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfterMs = JMAPClient.parseRetryAfter(response);
|
||||||
|
this.setRateLimited(retryAfterMs);
|
||||||
|
throw new RateLimitError(retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (this.authMode === 'bearer' && this.onTokenRefresh) {
|
if (this.authMode === 'bearer' && this.onTokenRefresh) {
|
||||||
const newToken = await this.onTokenRefresh();
|
const newToken = await this.onTokenRefresh();
|
||||||
@@ -273,10 +302,15 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.stopKeepAlive();
|
this.stopKeepAlive();
|
||||||
|
|
||||||
this.pingInterval = setInterval(async () => {
|
this.pingInterval = setInterval(async () => {
|
||||||
|
// Skip ping while rate-limited to avoid compounding auth failures
|
||||||
|
if (this.isRateLimited()) return;
|
||||||
try {
|
try {
|
||||||
await this.ping();
|
await this.ping();
|
||||||
this.connectionChangeCallback?.(true);
|
this.connectionChangeCallback?.(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimitError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Keep-alive ping failed:', error);
|
console.error('Keep-alive ping failed:', error);
|
||||||
this.connectionChangeCallback?.(false);
|
this.connectionChangeCallback?.(false);
|
||||||
try {
|
try {
|
||||||
@@ -319,6 +353,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.stopKeepAlive();
|
this.stopKeepAlive();
|
||||||
this.closePushNotifications();
|
this.closePushNotifications();
|
||||||
|
if (this.rateLimitTimeout) {
|
||||||
|
clearTimeout(this.rateLimitTimeout);
|
||||||
|
this.rateLimitTimeout = null;
|
||||||
|
}
|
||||||
this.apiUrl = "";
|
this.apiUrl = "";
|
||||||
this.accountId = "";
|
this.accountId = "";
|
||||||
this.session = null;
|
this.session = null;
|
||||||
@@ -3542,6 +3580,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectSSE(templateUrl: string): void {
|
private connectSSE(templateUrl: string): void {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
this.scheduleSSEReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = templateUrl
|
const url = templateUrl
|
||||||
.replace('{types}', '*')
|
.replace('{types}', '*')
|
||||||
.replace('{closeafter}', 'no')
|
.replace('{closeafter}', 'no')
|
||||||
@@ -3549,8 +3592,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
this.sseAbortController = new AbortController();
|
this.sseAbortController = new AbortController();
|
||||||
|
|
||||||
fetch(url, {
|
this.authenticatedFetch(url, {
|
||||||
headers: { 'Authorization': this.authHeader, 'Accept': 'text/event-stream' },
|
headers: { 'Accept': 'text/event-stream' },
|
||||||
signal: this.sseAbortController.signal,
|
signal: this.sseAbortController.signal,
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
@@ -3558,7 +3601,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.readSSEStream(response.body);
|
this.readSSEStream(response.body);
|
||||||
}).catch(() => {
|
}).catch((error) => {
|
||||||
|
if (error instanceof RateLimitError) {
|
||||||
|
this.sseAbortController = null;
|
||||||
|
this.scheduleSSEReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fallbackToPolling();
|
this.fallbackToPolling();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3619,9 +3667,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.fallbackToPolling();
|
this.fallbackToPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const delay = this.isRateLimited()
|
||||||
|
? Math.max(this.rateLimitedUntil - Date.now(), JMAPClient.SSE_RECONNECT_DELAY)
|
||||||
|
: JMAPClient.SSE_RECONNECT_DELAY;
|
||||||
this.sseReconnectTimeout = setTimeout(() => {
|
this.sseReconnectTimeout = setTimeout(() => {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
this.scheduleSSEReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.connectSSE(eventSourceUrl);
|
this.connectSSE(eventSourceUrl);
|
||||||
}, JMAPClient.SSE_RECONNECT_DELAY);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fallbackToPolling(): void {
|
private fallbackToPolling(): void {
|
||||||
@@ -3632,6 +3687,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startPollingFallback(): void {
|
private startPollingFallback(): void {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fetchCurrentStates();
|
this.fetchCurrentStates();
|
||||||
this.pollingInterval = setInterval(() => {
|
this.pollingInterval = setInterval(() => {
|
||||||
this.checkForStateChanges();
|
this.checkForStateChanges();
|
||||||
@@ -3665,6 +3723,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchCurrentStates(): Promise<void> {
|
private async fetchCurrentStates(): Promise<void> {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { using, methodCalls } = this.buildStatePollingRequest();
|
const { using, methodCalls } = this.buildStatePollingRequest();
|
||||||
const response = await this.authenticatedFetch(this.apiUrl, {
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
||||||
@@ -3688,6 +3749,9 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkForStateChanges(): Promise<void> {
|
private async checkForStateChanges(): Promise<void> {
|
||||||
|
if (this.isRateLimited()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { using, methodCalls } = this.buildStatePollingRequest();
|
const { using, methodCalls } = this.buildStatePollingRequest();
|
||||||
const response = await this.authenticatedFetch(this.apiUrl, {
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
||||||
@@ -3749,6 +3813,77 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.connectionChangeCallback = callback;
|
this.connectionChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void {
|
||||||
|
this.rateLimitCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRateLimited(): boolean {
|
||||||
|
return Date.now() < this.rateLimitedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRateLimitRemainingMs(): number {
|
||||||
|
return Math.max(0, this.rateLimitedUntil - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private setRateLimited(retryAfterMs: number): void {
|
||||||
|
this.rateLimitedUntil = Date.now() + retryAfterMs;
|
||||||
|
|
||||||
|
if (this.rateLimitTimeout) {
|
||||||
|
clearTimeout(this.rateLimitTimeout);
|
||||||
|
this.rateLimitTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rateLimitCallback?.(true, retryAfterMs);
|
||||||
|
|
||||||
|
// Pause live updates until the server's rate-limit window expires.
|
||||||
|
const stateChangeCallback = this.stateChangeCallback;
|
||||||
|
this.closePushNotifications();
|
||||||
|
this.stateChangeCallback = stateChangeCallback;
|
||||||
|
|
||||||
|
// Schedule clearing the rate-limit flag and notifying listeners.
|
||||||
|
this.rateLimitTimeout = setTimeout(() => {
|
||||||
|
this.rateLimitTimeout = null;
|
||||||
|
if (!this.isRateLimited()) {
|
||||||
|
this.rateLimitCallback?.(false, 0);
|
||||||
|
if (this.session && this.stateChangeCallback) {
|
||||||
|
this.setupPushNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyRateLimitBlocked(retryAfterMs: number): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if ((now - this.lastRateLimitNoticeAt) < JMAPClient.RATE_LIMIT_TOAST_THROTTLE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastRateLimitNoticeAt = now;
|
||||||
|
window.dispatchEvent(new CustomEvent('bulwark:rate-limit-blocked', {
|
||||||
|
detail: { retryAfterMs },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseRetryAfter(response: Response): number {
|
||||||
|
const header = response.headers.get('Retry-After');
|
||||||
|
if (!header) return 60_000; // default 60s if no header
|
||||||
|
const seconds = Number(header);
|
||||||
|
if (!Number.isNaN(seconds) && seconds > 0) {
|
||||||
|
return Math.min(seconds * 1000, 300_000); // cap at 5 minutes
|
||||||
|
}
|
||||||
|
// Try HTTP-date format
|
||||||
|
const date = Date.parse(header);
|
||||||
|
if (!Number.isNaN(date)) {
|
||||||
|
const ms = date - Date.now();
|
||||||
|
return ms > 0 ? Math.min(ms, 300_000) : 60_000;
|
||||||
|
}
|
||||||
|
return 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
onStateChange(callback: (change: StateChange) => void): void {
|
onStateChange(callback: (change: StateChange) => void): void {
|
||||||
this.stateChangeCallback = callback;
|
this.stateChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"unknown": "Unbekannt",
|
"unknown": "Unbekannt",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…"
|
"reconnecting": "Verbindung verloren. Verbindung wird wiederhergestellt…",
|
||||||
|
"rate_limited_title": "Die Serverauthentifizierung ist vorubergehend begrenzt.",
|
||||||
|
"rate_limited_detail": "Bulwark hat Hintergrundanfragen pausiert, um eine Sperre zu vermeiden. Neuer Versuch in {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Anfrage pausiert, um eine Sperre zu vermeiden.",
|
||||||
|
"rate_limited_action_detail": "Bulwark wartet, bis die Serverabklingzeit endet, bevor weitere authentifizierte Anfragen gesendet werden. Versuchen Sie es in {seconds}s erneut."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "E-Mail erfolgreich gesendet",
|
"email_sent": "E-Mail erfolgreich gesendet",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Connection lost. Attempting to reconnect…"
|
"reconnecting": "Connection lost. Attempting to reconnect…",
|
||||||
|
"rate_limited_title": "Server authentication is temporarily rate limited.",
|
||||||
|
"rate_limited_detail": "Bulwark has paused background requests to avoid lockout. Retrying in {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Request paused to avoid lockout.",
|
||||||
|
"rate_limited_action_detail": "Bulwark is waiting for the server cooldown to end before sending more authenticated requests. Try again in {seconds}s."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Email sent successfully",
|
"email_sent": "Email sent successfully",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"unknown": "Desconocido",
|
"unknown": "Desconocido",
|
||||||
"app_title": "Correo Web",
|
"app_title": "Correo Web",
|
||||||
"reconnecting": "Conexión perdida. Intentando reconectar…"
|
"reconnecting": "Conexión perdida. Intentando reconectar…",
|
||||||
|
"rate_limited_title": "La autenticación del servidor está limitada temporalmente.",
|
||||||
|
"rate_limited_detail": "Bulwark ha pausado las solicitudes en segundo plano para evitar un bloqueo. Reintentando en {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Solicitud pausada para evitar el bloqueo.",
|
||||||
|
"rate_limited_action_detail": "Bulwark está esperando a que termine el enfriamiento del servidor antes de enviar más solicitudes autenticadas. Inténtalo de nuevo en {seconds}s."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Correo enviado exitosamente",
|
"email_sent": "Correo enviado exitosamente",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "Non",
|
"no": "Non",
|
||||||
"unknown": "Inconnu",
|
"unknown": "Inconnu",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Connexion perdue. Tentative de reconnexion…"
|
"reconnecting": "Connexion perdue. Tentative de reconnexion…",
|
||||||
|
"rate_limited_title": "L'authentification du serveur est temporairement limitee.",
|
||||||
|
"rate_limited_detail": "Bulwark a suspendu les requetes en arriere-plan pour eviter un blocage. Nouvelle tentative dans {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Requete suspendue pour eviter le blocage.",
|
||||||
|
"rate_limited_action_detail": "Bulwark attend la fin du delai impose par le serveur avant d'envoyer d'autres requetes authentifiees. Reessayez dans {seconds}s."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Email envoyé avec succès",
|
"email_sent": "Email envoyé avec succès",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"unknown": "Sconosciuto",
|
"unknown": "Sconosciuto",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Connessione persa. Tentativo di riconnessione…"
|
"reconnecting": "Connessione persa. Tentativo di riconnessione…",
|
||||||
|
"rate_limited_title": "L'autenticazione del server e temporaneamente limitata.",
|
||||||
|
"rate_limited_detail": "Bulwark ha sospeso le richieste in background per evitare il blocco. Nuovo tentativo tra {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Richiesta sospesa per evitare il blocco.",
|
||||||
|
"rate_limited_action_detail": "Bulwark attende che termini il cooldown del server prima di inviare altre richieste autenticate. Riprova tra {seconds}s."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Messaggio inviato con successo",
|
"email_sent": "Messaggio inviato con successo",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "いいえ",
|
"no": "いいえ",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"app_title": "ウェブメール",
|
"app_title": "ウェブメール",
|
||||||
"reconnecting": "接続が切れました。再接続を試みています…"
|
"reconnecting": "接続が切れました。再接続を試みています…",
|
||||||
|
"rate_limited_title": "サーバー認証は一時的に制限されています。",
|
||||||
|
"rate_limited_detail": "ロックアウトを避けるため、Bulwark はバックグラウンド要求を一時停止しました。{seconds} 秒後に再試行します。",
|
||||||
|
"rate_limited_action_title": "ロックアウトを避けるため要求を一時停止しました。",
|
||||||
|
"rate_limited_action_detail": "Bulwark はサーバーのクールダウンが終わるまで、追加の認証付きリクエストを送信しません。{seconds} 秒後に再試行してください。"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "メールを送信しました",
|
"email_sent": "メールを送信しました",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "Nee",
|
"no": "Nee",
|
||||||
"unknown": "Onbekend",
|
"unknown": "Onbekend",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Verbinding verloren. Opnieuw verbinden…"
|
"reconnecting": "Verbinding verloren. Opnieuw verbinden…",
|
||||||
|
"rate_limited_title": "Serverauthenticatie is tijdelijk beperkt.",
|
||||||
|
"rate_limited_detail": "Bulwark heeft achtergrondverzoeken gepauzeerd om een blokkade te voorkomen. Nieuwe poging over {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Verzoek gepauzeerd om blokkade te voorkomen.",
|
||||||
|
"rate_limited_action_detail": "Bulwark wacht tot de servercooldown voorbij is voordat er nieuwe geauthenticeerde verzoeken worden verzonden. Probeer het over {seconds}s opnieuw."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "E-mail succesvol verzonden",
|
"email_sent": "E-mail succesvol verzonden",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "Não",
|
"no": "Não",
|
||||||
"unknown": "Desconhecido",
|
"unknown": "Desconhecido",
|
||||||
"app_title": "Webmail",
|
"app_title": "Webmail",
|
||||||
"reconnecting": "Conexão perdida. Tentando reconectar…"
|
"reconnecting": "Conexão perdida. Tentando reconectar…",
|
||||||
|
"rate_limited_title": "A autenticação do servidor está temporariamente limitada.",
|
||||||
|
"rate_limited_detail": "O Bulwark pausou as solicitações em segundo plano para evitar bloqueio. Nova tentativa em {seconds}s.",
|
||||||
|
"rate_limited_action_title": "Solicitação pausada para evitar bloqueio.",
|
||||||
|
"rate_limited_action_detail": "O Bulwark está aguardando o fim do cooldown do servidor antes de enviar mais solicitações autenticadas. Tente novamente em {seconds}s."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "E-mail enviado com sucesso",
|
"email_sent": "E-mail enviado com sucesso",
|
||||||
|
|||||||
@@ -511,7 +511,11 @@
|
|||||||
"no": "Нет",
|
"no": "Нет",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"app_title": "Веб-почта",
|
"app_title": "Веб-почта",
|
||||||
"reconnecting": "Соединение потеряно. Попытка переподключения…"
|
"reconnecting": "Соединение потеряно. Попытка переподключения…",
|
||||||
|
"rate_limited_title": "Аутентификация на сервере временно ограничена.",
|
||||||
|
"rate_limited_detail": "Bulwark приостановил фоновые запросы, чтобы избежать блокировки. Повтор через {seconds}с.",
|
||||||
|
"rate_limited_action_title": "Запрос приостановлен, чтобы избежать блокировки.",
|
||||||
|
"rate_limited_action_detail": "Bulwark ждет окончания серверного тайм-аута перед отправкой новых аутентифицированных запросов. Повторите через {seconds}с."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"email_sent": "Письмо успешно отправлено",
|
"email_sent": "Письмо успешно отправлено",
|
||||||
|
|||||||
+161
-47
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { JMAPClient } from '@/lib/jmap/client';
|
import { JMAPClient, RateLimitError } from '@/lib/jmap/client';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
import { useIdentityStore } from './identity-store';
|
import { useIdentityStore } from './identity-store';
|
||||||
import { useContactStore } from './contact-store';
|
import { useContactStore } from './contact-store';
|
||||||
@@ -21,6 +21,8 @@ interface AuthState {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
isRateLimited: boolean;
|
||||||
|
rateLimitUntil: number | null;
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
client: IJMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
@@ -64,6 +66,63 @@ function classifyLoginError(error: unknown): string {
|
|||||||
return 'generic';
|
return 'generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRateLimitError(error: unknown): error is RateLimitError {
|
||||||
|
return error instanceof RateLimitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
|
||||||
|
if (!client) {
|
||||||
|
return { isRateLimited: false, rateLimitUntil: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = client.getRateLimitRemainingMs();
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
return { isRateLimited: false, rateLimitUntil: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRateLimited: true,
|
||||||
|
rateLimitUntil: Date.now() + remainingMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindClientStatusHandlers(
|
||||||
|
client: IJMAPClient,
|
||||||
|
set: (state: Partial<AuthState>) => void,
|
||||||
|
get: () => AuthState,
|
||||||
|
accountId?: string,
|
||||||
|
): void {
|
||||||
|
client.onConnectionChange((connected) => {
|
||||||
|
if (!accountId || get().activeAccountId === accountId) {
|
||||||
|
set({ connectionLost: !connected });
|
||||||
|
}
|
||||||
|
if (accountId) {
|
||||||
|
useAccountStore.getState().updateAccount(accountId, { isConnected: connected });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.onRateLimit((rateLimited, retryAfterMs) => {
|
||||||
|
const isActiveAccount = !accountId || get().activeAccountId === accountId;
|
||||||
|
const nextRateLimitUntil = rateLimited ? Date.now() + retryAfterMs : null;
|
||||||
|
|
||||||
|
if (isActiveAccount) {
|
||||||
|
set({
|
||||||
|
isRateLimited: rateLimited,
|
||||||
|
rateLimitUntil: nextRateLimitUntil,
|
||||||
|
connectionLost: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
useAccountStore.getState().updateAccount(accountId, {
|
||||||
|
isConnected: !rateLimited,
|
||||||
|
hasError: rateLimited,
|
||||||
|
errorMessage: rateLimited ? 'Temporarily rate limited by server' : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function emailMatchesUsername(email: string, username: string): boolean {
|
function emailMatchesUsername(email: string, username: string): boolean {
|
||||||
if (email === username) return true;
|
if (email === username) return true;
|
||||||
// Handle local-part login: username "user" should match "user@domain.tld"
|
// Handle local-part login: username "user" should match "user@domain.tld"
|
||||||
@@ -240,6 +299,8 @@ function performFullLogout(set: (state: Partial<AuthState>) => void): void {
|
|||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
username: null,
|
username: null,
|
||||||
client: null,
|
client: null,
|
||||||
@@ -269,6 +330,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
username: null,
|
username: null,
|
||||||
client: null,
|
client: null,
|
||||||
@@ -284,13 +347,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
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;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
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);
|
||||||
@@ -313,6 +373,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Store client in multi-account map
|
// Store client in multi-account map
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
|
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
label: primaryIdentity?.name || username,
|
label: primaryIdentity?.name || username,
|
||||||
@@ -362,6 +423,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
username,
|
username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: 'basic',
|
||||||
@@ -388,6 +450,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: classifyLoginError(error),
|
error: classifyLoginError(error),
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -395,7 +459,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loginDemo: async () => {
|
loginDemo: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
try {
|
try {
|
||||||
// Clear all store data before re-initializing with fresh demo data
|
// Clear all store data before re-initializing with fresh demo data
|
||||||
clearAllStores();
|
clearAllStores();
|
||||||
@@ -432,6 +496,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: 'demo.example.com',
|
serverUrl: 'demo.example.com',
|
||||||
username,
|
username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: 'basic',
|
||||||
@@ -450,6 +515,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'generic',
|
error: 'generic',
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -457,7 +524,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine slot for this account (use slot from sessionStorage if re-adding)
|
// Determine slot for this account (use slot from sessionStorage if re-adding)
|
||||||
@@ -481,9 +548,6 @@ 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 jmapUsername = client.getUsername();
|
const jmapUsername = client.getUsername();
|
||||||
@@ -506,6 +570,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
|
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
label: primaryIdentity?.name || username,
|
label: primaryIdentity?.name || username,
|
||||||
@@ -528,6 +593,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
username,
|
username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'oauth',
|
authMode: 'oauth',
|
||||||
@@ -564,6 +630,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -571,7 +639,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loginWithServerSso: async (code, state) => {
|
loginWithServerSso: async (code, state) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
|
// Server-side SSO: the server holds the PKCE verifier in an encrypted cookie
|
||||||
@@ -601,9 +669,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => refreshFn());
|
||||||
client.onConnectionChange((connected) => {
|
|
||||||
set({ connectionLost: !connected });
|
|
||||||
});
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const jmapUsername = client.getUsername();
|
const jmapUsername = client.getUsername();
|
||||||
@@ -623,6 +688,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
|
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
label: primaryIdentity?.name || username,
|
label: primaryIdentity?.name || username,
|
||||||
@@ -645,6 +711,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: ssoServerUrl,
|
serverUrl: ssoServerUrl,
|
||||||
username,
|
username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'oauth',
|
authMode: 'oauth',
|
||||||
@@ -675,6 +742,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -866,7 +935,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Null out the client immediately so the page doesn't fire data-loading
|
// Null out the client immediately so the page doesn't fire data-loading
|
||||||
// effects with the old client while stores are being cleared.
|
// effects with the old client while stores are being cleared.
|
||||||
set({ isLoading: true, client: null });
|
set({ isLoading: true, client: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
// Snapshot current account
|
// Snapshot current account
|
||||||
if (state.activeAccountId) {
|
if (state.activeAccountId) {
|
||||||
@@ -879,6 +948,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// Get or create client for target account
|
// Get or create client for target account
|
||||||
let targetClient = clients.get(accountId);
|
let targetClient = clients.get(accountId);
|
||||||
|
let targetRestoreRateLimited = false;
|
||||||
|
|
||||||
if (!targetClient) {
|
if (!targetClient) {
|
||||||
// Client not connected — try to restore
|
// Client not connected — try to restore
|
||||||
@@ -889,12 +959,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { access_token, expires_in } = await res.json();
|
const { access_token, expires_in } = await res.json();
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
|
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
|
||||||
targetClient.onConnectionChange((connected) => {
|
bindClientStatusHandlers(targetClient, set, get, accountId);
|
||||||
if (get().activeAccountId === accountId) {
|
|
||||||
set({ connectionLost: !connected });
|
|
||||||
}
|
|
||||||
accountStore.updateAccount(accountId, { isConnected: connected });
|
|
||||||
});
|
|
||||||
await targetClient.connect();
|
await targetClient.connect();
|
||||||
clients.set(accountId, targetClient);
|
clients.set(accountId, targetClient);
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||||
@@ -904,22 +969,47 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { serverUrl, username, password } = await res.json();
|
const { serverUrl, username, password } = await res.json();
|
||||||
targetClient = new JMAPClient(serverUrl, username, password);
|
targetClient = new JMAPClient(serverUrl, username, password);
|
||||||
targetClient.onConnectionChange((connected) => {
|
bindClientStatusHandlers(targetClient, set, get, accountId);
|
||||||
if (get().activeAccountId === accountId) {
|
|
||||||
set({ connectionLost: !connected });
|
|
||||||
}
|
|
||||||
accountStore.updateAccount(accountId, { isConnected: connected });
|
|
||||||
});
|
|
||||||
await targetClient.connect();
|
await targetClient.connect();
|
||||||
clients.set(accountId, targetClient);
|
clients.set(accountId, targetClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error(`Failed to restore client for ${accountId}:`, err);
|
debug.error(`Failed to restore client for ${accountId}:`, err);
|
||||||
|
if (isRateLimitError(err)) {
|
||||||
|
targetRestoreRateLimited = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetClient) {
|
if (!targetClient) {
|
||||||
|
if (targetRestoreRateLimited) {
|
||||||
|
if (state.activeAccountId && state.activeAccountId !== accountId) {
|
||||||
|
const prevClient = clients.get(state.activeAccountId);
|
||||||
|
const prevAccount = accountStore.getAccountById(state.activeAccountId);
|
||||||
|
if (prevClient && prevAccount) {
|
||||||
|
restoreAccount(state.activeAccountId);
|
||||||
|
accountStore.setActiveAccount(state.activeAccountId);
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
serverUrl: prevAccount.serverUrl,
|
||||||
|
username: prevAccount.username,
|
||||||
|
client: prevClient,
|
||||||
|
...getClientRateLimitState(prevClient),
|
||||||
|
authMode: prevAccount.authMode,
|
||||||
|
rememberMe: prevAccount.rememberMe,
|
||||||
|
connectionLost: false,
|
||||||
|
error: 'connection_failed',
|
||||||
|
activeAccountId: state.activeAccountId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cannot restore — remove the stale account and redirect to login
|
// Cannot restore — remove the stale account and redirect to login
|
||||||
evictAccount(accountId);
|
evictAccount(accountId);
|
||||||
accountStore.removeAccount(accountId);
|
accountStore.removeAccount(accountId);
|
||||||
@@ -937,6 +1027,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: prevAccount.serverUrl,
|
serverUrl: prevAccount.serverUrl,
|
||||||
username: prevAccount.username,
|
username: prevAccount.username,
|
||||||
client: prevClient,
|
client: prevClient,
|
||||||
|
...getClientRateLimitState(prevClient),
|
||||||
authMode: prevAccount.authMode,
|
authMode: prevAccount.authMode,
|
||||||
rememberMe: prevAccount.rememberMe,
|
rememberMe: prevAccount.rememberMe,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
@@ -967,6 +1058,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: targetAccount.serverUrl,
|
serverUrl: targetAccount.serverUrl,
|
||||||
username: targetAccount.username,
|
username: targetAccount.username,
|
||||||
client: targetClient,
|
client: targetClient,
|
||||||
|
...getClientRateLimitState(targetClient),
|
||||||
authMode: targetAccount.authMode,
|
authMode: targetAccount.authMode,
|
||||||
rememberMe: targetAccount.rememberMe,
|
rememberMe: targetAccount.rememberMe,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
@@ -1029,12 +1121,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const { access_token, expires_in } = await res.json();
|
const { access_token, expires_in } = await res.json();
|
||||||
const refreshFn = get().refreshAccessToken;
|
const refreshFn = get().refreshAccessToken;
|
||||||
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
|
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
|
||||||
client.onConnectionChange((connected) => {
|
bindClientStatusHandlers(client, set, get, account.id);
|
||||||
if (get().activeAccountId === account.id) {
|
|
||||||
set({ connectionLost: !connected });
|
|
||||||
}
|
|
||||||
accountStore.updateAccount(account.id, { isConnected: connected });
|
|
||||||
});
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
clients.set(account.id, client);
|
clients.set(account.id, client);
|
||||||
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
||||||
@@ -1047,12 +1134,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const { serverUrl, username, password } = await res.json();
|
const { serverUrl, username, password } = await res.json();
|
||||||
const client = new JMAPClient(serverUrl, username, password);
|
const client = new JMAPClient(serverUrl, username, password);
|
||||||
client.onConnectionChange((connected) => {
|
bindClientStatusHandlers(client, set, get, account.id);
|
||||||
if (get().activeAccountId === account.id) {
|
|
||||||
set({ connectionLost: !connected });
|
|
||||||
}
|
|
||||||
accountStore.updateAccount(account.id, { isConnected: connected });
|
|
||||||
});
|
|
||||||
await client.connect();
|
await client.connect();
|
||||||
clients.set(account.id, client);
|
clients.set(account.id, client);
|
||||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||||
@@ -1065,6 +1147,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug.error(`Failed to restore account ${account.id}:`, err);
|
debug.error(`Failed to restore account ${account.id}:`, err);
|
||||||
|
if (isRateLimitError(err)) {
|
||||||
|
accountStore.updateAccount(account.id, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: 'Temporarily rate limited by server',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Remove unrestorable accounts so the user is prompted to log in
|
// Remove unrestorable accounts so the user is prompted to log in
|
||||||
// again rather than seeing a stale error entry forever.
|
// again rather than seeing a stale error entry forever.
|
||||||
evictAccount(account.id);
|
evictAccount(account.id);
|
||||||
@@ -1087,6 +1177,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: targetAccount.serverUrl,
|
serverUrl: targetAccount.serverUrl,
|
||||||
username: targetAccount.username,
|
username: targetAccount.username,
|
||||||
client: targetClient,
|
client: targetClient,
|
||||||
|
...getClientRateLimitState(targetClient),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: targetAccount.authMode,
|
authMode: targetAccount.authMode,
|
||||||
@@ -1119,6 +1210,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl: acc.serverUrl,
|
serverUrl: acc.serverUrl,
|
||||||
username: acc.username,
|
username: acc.username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: acc.authMode,
|
authMode: acc.authMode,
|
||||||
@@ -1132,10 +1224,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No accounts could be restored
|
// No accounts could be restored
|
||||||
|
if (accounts.some((account) => accountStore.getAccountById(account.id))) {
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
|
client: null,
|
||||||
|
error: 'connection_failed',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
markSessionExpired();
|
markSessionExpired();
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
username: null,
|
username: null,
|
||||||
@@ -1152,19 +1258,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (state.isAuthenticated && !state.client) {
|
if (state.isAuthenticated && !state.client) {
|
||||||
if (state.authMode === 'oauth' && state.serverUrl) {
|
if (state.authMode === 'oauth' && state.serverUrl) {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
|
||||||
try {
|
try {
|
||||||
const token = await get().refreshAccessToken();
|
const token = await get().refreshAccessToken();
|
||||||
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 accountId = generateAccountId(state.username || '', state.serverUrl);
|
const accountId = generateAccountId(state.username || '', state.serverUrl);
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
|
|
||||||
// Migrate to account registry
|
// Migrate to account registry
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
@@ -1189,6 +1293,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
@@ -1205,12 +1310,16 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('OAuth session restore failed:', error);
|
debug.error('OAuth session restore failed:', error);
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearRefreshTimer();
|
clearRefreshTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.authMode === 'basic') {
|
if (state.authMode === 'basic') {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true, isRateLimited: false, rateLimitUntil: null });
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/session');
|
const res = await fetch('/api/auth/session');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -1221,13 +1330,11 @@ 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 accountId = generateAccountId(username, serverUrl);
|
const accountId = generateAccountId(username, serverUrl);
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
|
|
||||||
// Migrate to account registry
|
// Migrate to account registry
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
@@ -1254,6 +1361,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
username,
|
username,
|
||||||
client,
|
client,
|
||||||
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: 'basic',
|
||||||
@@ -1270,6 +1378,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Basic session restore failed:', error);
|
debug.error('Basic session restore failed:', error);
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1278,6 +1390,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isRateLimited: false,
|
||||||
|
rateLimitUntil: null,
|
||||||
client: null,
|
client: null,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
username: null,
|
username: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user