mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
perf: show the cached inbox at start before the server's session arrives
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const secure = new Map<string, string>();
|
||||||
|
vi.mock('expo-secure-store', () => ({
|
||||||
|
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
|
||||||
|
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
|
||||||
|
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { JMAPClient, AuthenticationError } from '../jmap-client';
|
||||||
|
import type { JMAPSession } from '../types';
|
||||||
|
|
||||||
|
// Start-up used to wait for the server's session document before the
|
||||||
|
// account could show. The client now keeps the last one per account and
|
||||||
|
// starts from it (loadAccountFromCache), then fetches a fresh one.
|
||||||
|
|
||||||
|
const ACCOUNT = 'user@mail.example.com';
|
||||||
|
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
|
||||||
|
|
||||||
|
const SESSION: JMAPSession = {
|
||||||
|
apiUrl: 'https://mail.example.com/jmap/',
|
||||||
|
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
|
||||||
|
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
|
||||||
|
eventSourceUrl: 'https://mail.example.com/eventsource/',
|
||||||
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
|
||||||
|
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
|
||||||
|
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
|
||||||
|
state: 'state-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
|
||||||
|
let i = 0;
|
||||||
|
const fn = vi.fn(async (url: string) => {
|
||||||
|
const r = responses[i++] ?? responses[responses.length - 1];
|
||||||
|
return {
|
||||||
|
ok: r.status >= 200 && r.status < 300,
|
||||||
|
status: r.status,
|
||||||
|
statusText: r.status === 200 ? 'OK' : 'Error',
|
||||||
|
url,
|
||||||
|
redirected: false,
|
||||||
|
headers: { get: () => null },
|
||||||
|
json: async () => r.json,
|
||||||
|
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fn);
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeCredentials() {
|
||||||
|
const SecureStore = await import('expo-secure-store');
|
||||||
|
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
secure.clear();
|
||||||
|
await AsyncStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saved session', () => {
|
||||||
|
it('starts from the session of the last connect, without a request', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
mockFetch([{ status: 200, json: SESSION }]);
|
||||||
|
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
|
||||||
|
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
|
||||||
|
|
||||||
|
const fetch = mockFetch([{ status: 500 }]);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(client.accountId).toBe('acc-1');
|
||||||
|
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has nothing to start from before the first connect', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a session saved for another server', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
`jmap_session_cache__${ACCOUNT}`,
|
||||||
|
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
|
||||||
|
);
|
||||||
|
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is dropped with the credentials', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
`jmap_session_cache__${ACCOUNT}`,
|
||||||
|
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||||
|
);
|
||||||
|
await new JMAPClient().clearAccountCredentials(ACCOUNT);
|
||||||
|
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revalidates against the server and reports refused credentials', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
`jmap_session_cache__${ACCOUNT}`,
|
||||||
|
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||||
|
);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.loadAccountFromCache(ACCOUNT);
|
||||||
|
mockFetch([{ status: 401 }]);
|
||||||
|
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a revalidation that finishes after a switch to another account', async () => {
|
||||||
|
await storeCredentials();
|
||||||
|
await AsyncStorage.setItem(
|
||||||
|
`jmap_session_cache__${ACCOUNT}`,
|
||||||
|
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
|
||||||
|
);
|
||||||
|
const client = new JMAPClient();
|
||||||
|
await client.loadAccountFromCache(ACCOUNT);
|
||||||
|
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
|
||||||
|
const pending = client.revalidateSession();
|
||||||
|
client.reset();
|
||||||
|
expect(await pending).toBeNull();
|
||||||
|
expect(client.currentSession).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type {
|
import type {
|
||||||
JMAPSession,
|
JMAPSession,
|
||||||
JMAPMethodCall,
|
JMAPMethodCall,
|
||||||
@@ -44,6 +45,20 @@ function credentialsKey(accountId: string): string {
|
|||||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session document from the account's last connect (URLs, account ids,
|
||||||
|
// capabilities; no secrets). Start-up uses it to show the account without
|
||||||
|
// waiting for the network, then fetches a fresh one (revalidateSession).
|
||||||
|
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
|
||||||
|
|
||||||
|
function sessionCacheKey(accountId: string): string {
|
||||||
|
return SESSION_CACHE_PREFIX + accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedSession {
|
||||||
|
serverUrl: string;
|
||||||
|
session: JMAPSession;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoredCredentials {
|
export interface StoredCredentials {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -524,9 +539,74 @@ export class JMAPClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.rememberSession(accountId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start-up without a network round trip: the account's credentials and the
|
||||||
|
* session document saved at its last connect. Returns false when either is
|
||||||
|
* missing (or the saved one is for another server); the caller then uses
|
||||||
|
* loadAccount. A wrong password or revoked token surfaces on the first
|
||||||
|
* request (401, onAuthFailure) or in revalidateSession.
|
||||||
|
*/
|
||||||
|
async loadAccountFromCache(accountId: string): Promise<boolean> {
|
||||||
|
const [stored, cachedRaw] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(credentialsKey(accountId)),
|
||||||
|
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!stored || !cachedRaw) return false;
|
||||||
|
let creds: StoredCredentials;
|
||||||
|
let cached: CachedSession;
|
||||||
|
try {
|
||||||
|
creds = JSON.parse(stored);
|
||||||
|
cached = JSON.parse(cachedRaw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
|
||||||
|
let accountIdInSession: string;
|
||||||
|
try {
|
||||||
|
accountIdInSession = this.resolveAccountId(cached.session);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.credentials = creds;
|
||||||
|
this.firstTouchGate.reset();
|
||||||
|
this.session = cached.session;
|
||||||
|
this._accountId = accountIdInSession;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a fresh session document for the account the client holds now,
|
||||||
|
* after a start from the saved one. Resolves null when the client moved to
|
||||||
|
* another account meanwhile (the result is dropped). Throws
|
||||||
|
* AuthenticationError / NetworkError like loadAccount.
|
||||||
|
*/
|
||||||
|
async revalidateSession(): Promise<JMAPSession | null> {
|
||||||
|
const creds = this.credentials;
|
||||||
|
if (!creds) return null;
|
||||||
|
let fresh: JMAPSession;
|
||||||
|
try {
|
||||||
|
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
|
||||||
|
} catch (err) {
|
||||||
|
if (this.credentials !== creds) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (this.credentials !== creds) return null;
|
||||||
|
this.session = fresh;
|
||||||
|
this._accountId = this.resolveAccountId(fresh);
|
||||||
|
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberSession(accountId: string): void {
|
||||||
|
if (!this.session || !this.credentials) return;
|
||||||
|
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
|
||||||
|
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of the live connection so a failed "add account" / switch can put
|
* Snapshot of the live connection so a failed "add account" / switch can put
|
||||||
* the previous account back without a network round-trip.
|
* the previous account back without a network round-trip.
|
||||||
@@ -548,12 +628,14 @@ export class JMAPClient {
|
|||||||
|
|
||||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||||
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
await SecureStore.deleteItemAsync(credentialsKey(accountId));
|
||||||
|
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearAllCredentials(accountIds: string[]): Promise<void> {
|
async clearAllCredentials(accountIds: string[]): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
|
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
|
||||||
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
|
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
|
||||||
|
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +698,7 @@ export class JMAPClient {
|
|||||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||||
if (currentAccountId) {
|
if (currentAccountId) {
|
||||||
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
|
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
|
||||||
|
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,6 +918,7 @@ export class JMAPClient {
|
|||||||
const baseUrl = this.credentials.serverUrl;
|
const baseUrl = this.credentials.serverUrl;
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
|
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
|
||||||
return this.session;
|
return this.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
restoreSession: vi.fn(),
|
restoreSession: vi.fn(),
|
||||||
loadAccount: vi.fn(),
|
loadAccount: vi.fn(),
|
||||||
|
loadAccountFromCache: vi.fn(async () => false),
|
||||||
|
revalidateSession: vi.fn(async () => null),
|
||||||
consumeLegacyCredentials: vi.fn(async () => null),
|
consumeLegacyCredentials: vi.fn(async () => null),
|
||||||
clearAccountCredentials: vi.fn(async () => undefined),
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
clearAllCredentials: vi.fn(async () => undefined),
|
clearAllCredentials: vi.fn(async () => undefined),
|
||||||
@@ -35,7 +37,7 @@ vi.mock('../../lib/push-notifications', () => ({
|
|||||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient, AuthenticationError } from '../../api/jmap-client';
|
||||||
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
|
import { useAuthStore, HYDRATION_TIMEOUT_MS } from '../auth-store';
|
||||||
import { useAccountStore } from '../account-store';
|
import { useAccountStore } from '../account-store';
|
||||||
import { useCalendarStore } from '../calendar-store';
|
import { useCalendarStore } from '../calendar-store';
|
||||||
@@ -49,6 +51,8 @@ import { lastBodyHeight, rememberBodyHeight } from '../../lib/body-heights';
|
|||||||
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
||||||
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
||||||
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
||||||
|
const mockLoadFromCache = jmapClient.loadAccountFromCache as ReturnType<typeof vi.fn>;
|
||||||
|
const mockRevalidate = jmapClient.revalidateSession as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function resetAccountStore(): void {
|
function resetAccountStore(): void {
|
||||||
useAccountStore.setState({
|
useAccountStore.setState({
|
||||||
@@ -236,6 +240,61 @@ describe('auth-store', () => {
|
|||||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('from the session saved at the last connect', () => {
|
||||||
|
function registerAccount() {
|
||||||
|
useAccountStore.setState({
|
||||||
|
accounts: [{
|
||||||
|
id: 'acc-1', serverUrl: 'https://mail.example.com', username: 'user',
|
||||||
|
displayName: 'user', email: 'user', avatarColor: '#000', lastLoginAt: 0,
|
||||||
|
isConnected: true, hasError: false, isDefault: true,
|
||||||
|
}],
|
||||||
|
activeAccountId: 'acc-1',
|
||||||
|
defaultAccountId: 'acc-1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start-up used to wait for the server's session document before the
|
||||||
|
// cached inbox could show.
|
||||||
|
it('shows the account without waiting for the server', async () => {
|
||||||
|
registerAccount();
|
||||||
|
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||||
|
mockRevalidate.mockReturnValueOnce(new Promise(() => undefined));
|
||||||
|
|
||||||
|
const restored = await useAuthStore.getState().restoreSession();
|
||||||
|
|
||||||
|
expect(restored).toBe(true);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
|
||||||
|
expect(mockLoadAccount).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs out when the server refuses the stored credentials', async () => {
|
||||||
|
registerAccount();
|
||||||
|
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||||
|
mockRevalidate.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
|
||||||
|
mockLoadAccount.mockRejectedValueOnce(new AuthenticationError('Invalid credentials'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().restoreSession();
|
||||||
|
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().error).toBe('Session expired');
|
||||||
|
expect(useAccountStore.getState().accounts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays signed in and marks the account offline without network', async () => {
|
||||||
|
registerAccount();
|
||||||
|
mockLoadFromCache.mockResolvedValueOnce(true);
|
||||||
|
mockRevalidate.mockRejectedValueOnce(new Error('Network request failed'));
|
||||||
|
|
||||||
|
await useAuthStore.getState().restoreSession();
|
||||||
|
await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].hasError).toBe(true));
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(useAccountStore.getState().accounts[0].isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('stops waiting for a persisted store that never finishes hydrating', async () => {
|
it('stops waiting for a persisted store that never finishes hydrating', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
|||||||
@@ -811,6 +811,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// data from a previous session.
|
// data from a previous session.
|
||||||
useEmailStore.getState().setActiveAccount(target.id);
|
useEmailStore.getState().setActiveAccount(target.id);
|
||||||
|
|
||||||
|
// Start from the session saved at the last connect: the cached mail
|
||||||
|
// shows without waiting for the server, and a fresh session follows.
|
||||||
|
let fromCache = false;
|
||||||
|
try {
|
||||||
|
fromCache = await jmapClient.loadAccountFromCache(target.id);
|
||||||
|
} catch {
|
||||||
|
fromCache = false;
|
||||||
|
}
|
||||||
|
if (fromCache) {
|
||||||
|
accountStore.setActiveAccount(target.id);
|
||||||
|
applyConnectedState(set, jmapClient.currentSession!, target.serverUrl, target.username, target.id);
|
||||||
|
perfMark('restore-done', { cached: true });
|
||||||
|
refetchFeatureStores();
|
||||||
|
void revalidateRestoredSession(target.id);
|
||||||
|
void syncAccountDisplayName(target.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(target.id);
|
const ok = await jmapClient.loadAccount(target.id);
|
||||||
perfMark('restore-session');
|
perfMark('restore-session');
|
||||||
@@ -929,6 +947,35 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a start from the saved session: fetch a fresh one. New capabilities
|
||||||
|
* or URLs replace the saved ones; bad credentials go the way of any 401 on a
|
||||||
|
* live session (retrySession, then the sign-in screen); no network marks the
|
||||||
|
* account offline, as a start without network always did.
|
||||||
|
*/
|
||||||
|
async function revalidateRestoredSession(accountId: string): Promise<void> {
|
||||||
|
const before = jmapClient.accountId;
|
||||||
|
try {
|
||||||
|
const fresh = await jmapClient.revalidateSession();
|
||||||
|
if (!fresh || useAuthStore.getState().activeAccountId !== accountId) return;
|
||||||
|
useAuthStore.setState({ session: fresh, accountId: jmapClient.accountId });
|
||||||
|
useAccountStore.getState().updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
||||||
|
if (jmapClient.accountId !== before) refetchFeatureStores();
|
||||||
|
} catch (err) {
|
||||||
|
if (useAuthStore.getState().activeAccountId !== accountId) return;
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
useAuthStore.setState({ session: null });
|
||||||
|
await useAuthStore.getState().retrySession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useAccountStore.getState().updateAccount(accountId, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A 401 on a live session (revoked password/token, expired refresh token)
|
// A 401 on a live session (revoked password/token, expired refresh token)
|
||||||
// used to leave the user on a dead session until relaunch: nothing outside
|
// used to leave the user on a dead session until relaunch: nothing outside
|
||||||
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
||||||
|
|||||||
Reference in New Issue
Block a user