mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: implement network recovery for authenticated users and enhance error handling in auth store
This commit is contained in:
@@ -193,6 +193,18 @@ export default function App() {
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && !useAuthStore.getState().session) {
|
||||
void useAuthStore.getState().retrySession();
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
|
||||
+35
-6
@@ -117,18 +117,38 @@ export class JMAPClient {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
creds = JSON.parse(stored);
|
||||
} catch {
|
||||
// Corrupt entry — credentials can't be used. Caller should evict.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Errors past this point propagate so callers can distinguish
|
||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||
// avoid clearing credentials when the server is just unreachable.
|
||||
this.credentials = creds;
|
||||
try {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
this.credentials = creds;
|
||||
this.session = this.rewriteSessionUrls(
|
||||
await this.fetchSession(creds.serverUrl),
|
||||
creds.serverUrl,
|
||||
);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Don't leave a half-populated client behind; the caller needs to know
|
||||
// the session is unavailable. Credentials stay in memory so a retry
|
||||
// after the network comes back doesn't need a re-login.
|
||||
this.session = null;
|
||||
this._accountId = null;
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
// Anything else is treated as transport-level. Wrapping rather than
|
||||
// rethrowing the raw fetch error gives callers a single check.
|
||||
throw new NetworkError(
|
||||
err instanceof Error ? err.message : 'Server unreachable',
|
||||
);
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
@@ -332,6 +352,15 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Server unreachable / transient transport failure. Callers should keep
|
||||
// stored credentials and surface an offline state rather than logging out.
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
|
||||
@@ -487,7 +487,9 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
|
||||
// dark-mode value, which left light mode unreadable (dark bg + dark text).
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
@@ -537,7 +539,7 @@ function makeStyles(c: ThemePalette) {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: '#1f1f1f',
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
|
||||
@@ -191,7 +191,7 @@ function makeStyles(c: ThemePalette) {
|
||||
left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
|
||||
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { useAccountStore } from './account-store';
|
||||
import { useEmailStore } from './email-store';
|
||||
@@ -39,6 +39,7 @@ export interface AuthState {
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
retrySession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -286,15 +287,56 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkError) {
|
||||
// Server unreachable. Keep credentials, mark account offline, and
|
||||
// surface the cached UI so the user can still browse persisted
|
||||
// mail / contacts / calendar. The login screen would lose their
|
||||
// settings without recourse, which is the bug we're fixing here.
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: null,
|
||||
serverUrl: target.serverUrl,
|
||||
username: target.username,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: target.id,
|
||||
client: jmapClient,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Server reachable but credentials rejected — drop them.
|
||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
const session = jmapClient.currentSession!;
|
||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||
@@ -309,5 +351,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Re-attempt session establishment for the currently active account
|
||||
// without disturbing UI state on failure. Used by the network-recovery
|
||||
// watcher and any explicit "retry" button. Idempotent: returns true if
|
||||
// a session is already live.
|
||||
retrySession: async () => {
|
||||
const { activeAccountId, session } = get();
|
||||
if (!activeAccountId) return false;
|
||||
if (session) return true;
|
||||
const accountStore = useAccountStore.getState();
|
||||
const target = accountStore.getAccountById(activeAccountId);
|
||||
if (!target) return false;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(activeAccountId);
|
||||
if (!ok) return false;
|
||||
accountStore.updateAccount(activeAccountId, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
const fresh = jmapClient.currentSession!;
|
||||
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
|
||||
refetchFeatureStores();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Now we know the credentials are bad — fall back to logout flow.
|
||||
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
|
||||
accountStore.removeAccount(activeAccountId);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: 'Session expired',
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: null,
|
||||
client: null,
|
||||
});
|
||||
}
|
||||
// NetworkError or anything else: stay where we are.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user