mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900)
This commit is contained in:
@@ -63,7 +63,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
|||||||
<View style={styles.hintWrap}>
|
<View style={styles.hintWrap}>
|
||||||
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
|
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>
|
||||||
Settings → Devices → Add phone shows a code. Point the camera at it.
|
Settings → Security → Link device shows a code. Point the camera at it.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -36,6 +36,38 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
|||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
const host = hostLabel(context.serverUrl);
|
const host = hostLabel(context.serverUrl);
|
||||||
|
|
||||||
|
if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
|
||||||
|
return {
|
||||||
|
title: 'Enter your two-factor code',
|
||||||
|
detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'TotpLoginError' && lower.includes('invalid')) {
|
||||||
|
return {
|
||||||
|
title: "That didn't work",
|
||||||
|
detail: 'Check your password and the current code from your authenticator app, then try again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'RateLimitError' || /\b429\b/.test(message) || lower.includes('rate limited')) {
|
||||||
|
const retryMs = (err as { retryAfterMs?: number } | null)?.retryAfterMs;
|
||||||
|
const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null;
|
||||||
|
return {
|
||||||
|
title: 'Too many attempts',
|
||||||
|
detail: seconds
|
||||||
|
? `The server asked us to wait. Try again in about ${seconds} seconds.`
|
||||||
|
: 'The server asked us to wait a moment. Try again shortly.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.includes('session discovery failed') && /\b402\b/.test(message)) {
|
||||||
|
return {
|
||||||
|
title: 'Enter your two-factor code',
|
||||||
|
detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
||||||
return {
|
return {
|
||||||
title: "That didn't work",
|
title: "That didn't work",
|
||||||
@@ -76,6 +108,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lower.includes('session discovery failed') && /\b5\d\d\b/.test(message)) {
|
||||||
|
return {
|
||||||
|
title: `${host} is having trouble`,
|
||||||
|
detail: 'The server answered with an error. Try again in a few minutes.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (lower.includes('pairing code')) {
|
if (lower.includes('pairing code')) {
|
||||||
return {
|
return {
|
||||||
title: 'That code has expired',
|
title: 'That code has expired',
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
// when discovery actually found one.
|
// when discovery actually found one.
|
||||||
const [serverDiscovered, setServerDiscovered] = React.useState(false);
|
const [serverDiscovered, setServerDiscovered] = React.useState(false);
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
|
// Second factor. Shown only after the server asked for it (402), so the
|
||||||
|
// common no-2FA sign-in stays a two-field form.
|
||||||
|
const [totp, setTotp] = React.useState('');
|
||||||
|
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
@@ -198,18 +202,31 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totpRequired && !/^\d{6,8}$/.test(totp.trim())) {
|
||||||
|
setNotice({ title: 'Enter the 6-digit code from your authenticator app' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setBusy('connecting');
|
setBusy('connecting');
|
||||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
try {
|
try {
|
||||||
await login(target, email.trim(), password, { addAccount: isAddMode });
|
await login(target, email.trim(), password, {
|
||||||
|
addAccount: isAddMode,
|
||||||
|
totp: totpRequired ? totp.trim() : undefined,
|
||||||
|
});
|
||||||
finishIfSignedIn(wasAuthenticated);
|
finishIfSignedIn(wasAuthenticated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'TotpRequiredError') {
|
||||||
|
setTotpRequired(true);
|
||||||
|
setNotice(describeLoginError(err, { serverUrl: target }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNotice(describeLoginError(err, { serverUrl: target }));
|
setNotice(describeLoginError(err, { serverUrl: target }));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]);
|
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired]);
|
||||||
|
|
||||||
const handleScanned = React.useCallback(
|
const handleScanned = React.useCallback(
|
||||||
async (data: string) => {
|
async (data: string) => {
|
||||||
@@ -218,7 +235,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
setNotice({
|
setNotice({
|
||||||
title: "That code isn't a Bulwark sign-in code",
|
title: "That code isn't a Bulwark sign-in code",
|
||||||
detail: 'Open Bulwark on the web, then Settings → Devices to show one.',
|
detail: 'Open Bulwark on the web, then Settings → Security → Link device to show one.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -364,6 +381,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
setPassword(value);
|
setPassword(value);
|
||||||
}}
|
}}
|
||||||
|
totp={totp}
|
||||||
|
totpRequired={totpRequired}
|
||||||
|
onChangeTotp={(value) => {
|
||||||
|
setNotice(null);
|
||||||
|
setTotp(value.replace(/\s+/g, ''));
|
||||||
|
}}
|
||||||
onSubmit={() => void handlePasswordSubmit()}
|
onSubmit={() => void handlePasswordSubmit()}
|
||||||
notice={notice}
|
notice={notice}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ interface PasswordStepProps {
|
|||||||
password: string;
|
password: string;
|
||||||
onChangeEmail: (value: string) => void;
|
onChangeEmail: (value: string) => void;
|
||||||
onChangePassword: (value: string) => void;
|
onChangePassword: (value: string) => void;
|
||||||
|
/** Second factor, shown once the server answered 402 "MFA code required". */
|
||||||
|
totp?: string;
|
||||||
|
totpRequired?: boolean;
|
||||||
|
onChangeTotp?: (value: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
notice?: { title: string; detail?: string } | null;
|
notice?: { title: string; detail?: string } | null;
|
||||||
}
|
}
|
||||||
@@ -28,6 +32,9 @@ export default function PasswordStep({
|
|||||||
password,
|
password,
|
||||||
onChangeEmail,
|
onChangeEmail,
|
||||||
onChangePassword,
|
onChangePassword,
|
||||||
|
totp = '',
|
||||||
|
totpRequired = false,
|
||||||
|
onChangeTotp,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
notice,
|
notice,
|
||||||
}: PasswordStepProps) {
|
}: PasswordStepProps) {
|
||||||
@@ -65,8 +72,8 @@ export default function PasswordStep({
|
|||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showPassword}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
textContentType="password"
|
textContentType="password"
|
||||||
returnKeyType="go"
|
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||||
onSubmitEditing={onSubmit}
|
onSubmitEditing={totpRequired ? undefined : onSubmit}
|
||||||
rightIcon={
|
rightIcon={
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setShowPassword((v) => !v)}
|
onPress={() => setShowPassword((v) => !v)}
|
||||||
@@ -79,6 +86,22 @@ export default function PasswordStep({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{totpRequired ? (
|
||||||
|
<Input
|
||||||
|
label="Two-factor code"
|
||||||
|
placeholder="123456"
|
||||||
|
value={totp}
|
||||||
|
onChangeText={(value) => onChangeTotp?.(value)}
|
||||||
|
autoFocus
|
||||||
|
keyboardType="number-pad"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
textContentType="oneTimeCode"
|
||||||
|
maxLength={8}
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={onSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||||
|
|
||||||
<Button variant="default" size="md" onPress={onSubmit}>
|
<Button variant="default" size="md" onPress={onSubmit}>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
clearAccountCredentials: vi.fn(async () => undefined),
|
clearAccountCredentials: vi.fn(async () => undefined),
|
||||||
clearAllCredentials: vi.fn(async () => undefined),
|
clearAllCredentials: vi.fn(async () => undefined),
|
||||||
reset: vi.fn(),
|
reset: vi.fn(),
|
||||||
|
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
|
||||||
|
restoreSnapshot: vi.fn(),
|
||||||
|
onAuthFailure: vi.fn(() => () => undefined),
|
||||||
|
onTokenRefresh: vi.fn(() => () => undefined),
|
||||||
|
hasAccountCapability: vi.fn(() => false),
|
||||||
|
request: vi.fn(async () => { throw new Error('not mocked'); }),
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||||
username: 'user',
|
username: 'user',
|
||||||
|
|||||||
+126
-23
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
||||||
import type { JMAPSession } from '../api/types';
|
import type { JMAPSession } from '../api/types';
|
||||||
|
import { getIdentities } from '../api/identity';
|
||||||
|
import { fetchPrincipal, isStalwartSupported } from '../api/account-security';
|
||||||
import { useAccountStore } from './account-store';
|
import { useAccountStore } from './account-store';
|
||||||
import { useEmailStore } from './email-store';
|
import { useEmailStore } from './email-store';
|
||||||
import { useContactsStore } from './contacts-store';
|
import { useContactsStore } from './contacts-store';
|
||||||
@@ -39,12 +41,19 @@ export interface AuthState {
|
|||||||
activeAccountId: string | null;
|
activeAccountId: string | null;
|
||||||
client: typeof jmapClient | null;
|
client: typeof jmapClient | null;
|
||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
login: (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
opts?: { addAccount?: boolean; totp?: string },
|
||||||
|
) => Promise<void>;
|
||||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
logoutAll: () => Promise<void>;
|
logoutAll: () => Promise<void>;
|
||||||
switchAccount: (accountId: string) => Promise<void>;
|
switchAccount: (accountId: string) => Promise<void>;
|
||||||
|
/** Sign a non-active account out and drop its caches; the active one stays. */
|
||||||
|
removeAccount: (accountId: string) => Promise<void>;
|
||||||
restoreSession: () => Promise<boolean>;
|
restoreSession: () => Promise<boolean>;
|
||||||
retrySession: () => Promise<boolean>;
|
retrySession: () => Promise<boolean>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -98,6 +107,38 @@ function refetchFeatureStores(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh the registry's display name / address from the server (#900): the
|
||||||
|
// primary identity first, then the Stalwart principal's "Full name" when the
|
||||||
|
// account advertises the extension. Fire-and-forget; a failure keeps whatever
|
||||||
|
// the registry already had.
|
||||||
|
async function syncAccountDisplayName(accountId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
const entry = accountStore.getAccountById(accountId);
|
||||||
|
if (!entry) return;
|
||||||
|
const updates: { displayName?: string; email?: string } = {};
|
||||||
|
const identities = await getIdentities().catch(() => []);
|
||||||
|
const primary = identities.find((i) => i.email?.toLowerCase() === entry.email?.toLowerCase())
|
||||||
|
?? identities.find((i) => i.email?.toLowerCase() === entry.username?.toLowerCase())
|
||||||
|
?? identities[0];
|
||||||
|
if (primary?.name?.trim()) updates.displayName = primary.name.trim();
|
||||||
|
if (primary?.email && !entry.email.includes('@')) updates.email = primary.email;
|
||||||
|
if (isStalwartSupported()) {
|
||||||
|
const principal = await fetchPrincipal().catch(() => null);
|
||||||
|
if (principal?.displayName?.trim()) updates.displayName = principal.displayName.trim();
|
||||||
|
if (!updates.email && principal?.emails[0] && !entry.email.includes('@')) {
|
||||||
|
updates.email = principal.emails[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
if (useAccountStore.getState().getAccountById(accountId)) {
|
||||||
|
useAccountStore.getState().updateAccount(accountId, updates);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cosmetic - never block sign-in on it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
|
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
|
||||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||||
// registers the account, and flips the store to connected. Throws on failure
|
// registers the account, and flips the store to connected. Throws on failure
|
||||||
@@ -108,17 +149,24 @@ async function completeOAuthHandoff(
|
|||||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||||
opts?: { addAccount?: boolean },
|
opts?: { addAccount?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (opts?.addAccount && get().isAuthenticated) {
|
// Adding an account must not destroy the live one: the singleton keeps the
|
||||||
jmapClient.reset();
|
// previous connection until the new sign-in has actually succeeded, and a
|
||||||
|
// failure puts it straight back.
|
||||||
|
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
|
||||||
|
|
||||||
|
let connected: { session: JMAPSession; username: string; accountId: string };
|
||||||
|
try {
|
||||||
|
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
|
||||||
|
} catch (err) {
|
||||||
|
if (previous) jmapClient.restoreSnapshot(previous);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { session, username, accountId } = connected;
|
||||||
|
if (previous) {
|
||||||
useContactsStore.getState().reset();
|
useContactsStore.getState().reset();
|
||||||
useCalendarStore.getState().reset();
|
useCalendarStore.getState().reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
|
||||||
result.serverUrl,
|
|
||||||
result.tokens,
|
|
||||||
);
|
|
||||||
|
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||||
@@ -133,6 +181,7 @@ async function completeOAuthHandoff(
|
|||||||
useEmailStore.getState().setActiveAccount(accountId);
|
useEmailStore.getState().setActiveAccount(accountId);
|
||||||
|
|
||||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||||
|
void syncAccountDisplayName(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyConnectedState(
|
function applyConnectedState(
|
||||||
@@ -170,17 +219,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
login: async (serverUrl, username, password, opts) => {
|
login: async (serverUrl, username, password, opts) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
// Adding an additional account: keep the live connection until the new
|
||||||
|
// sign-in succeeded so a typo doesn't kill the current session.
|
||||||
|
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
|
||||||
try {
|
try {
|
||||||
// Adding an additional account - snapshot the current account away so
|
let session: JMAPSession;
|
||||||
// its cache survives, then reset the JMAP client for the new login.
|
try {
|
||||||
// Contacts/calendar are still single-bucket, so wipe those.
|
session = await jmapClient.connect(serverUrl, username, password, opts?.totp);
|
||||||
if (opts?.addAccount && get().isAuthenticated) {
|
} catch (err) {
|
||||||
jmapClient.reset();
|
if (previous) jmapClient.restoreSnapshot(previous);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Contacts/calendar are still single-bucket, so wipe those now that
|
||||||
|
// the new account is the one the client serves.
|
||||||
|
if (previous) {
|
||||||
useContactsStore.getState().reset();
|
useContactsStore.getState().reset();
|
||||||
useCalendarStore.getState().reset();
|
useCalendarStore.getState().reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await jmapClient.connect(serverUrl, username, password);
|
|
||||||
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
|
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
|
||||||
|
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
@@ -199,12 +254,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
useEmailStore.getState().setActiveAccount(accountId);
|
useEmailStore.getState().setActiveAccount(accountId);
|
||||||
|
|
||||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||||
|
void syncAccountDisplayName(accountId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof AuthenticationError
|
const message = err instanceof Error && err.name === 'TotpRequiredError'
|
||||||
? 'Invalid username or password'
|
? 'Two-factor code required'
|
||||||
: err instanceof Error
|
: err instanceof AuthenticationError
|
||||||
? err.message
|
? 'Invalid username or password'
|
||||||
: 'Connection failed';
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Connection failed';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: message });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -384,13 +442,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// jmapClient first. If it fails, restore the previous active account
|
// jmapClient first. If it fails, restore the previous active account
|
||||||
// so we don't leave the user stranded on a half-switched state.
|
// so we don't leave the user stranded on a half-switched state.
|
||||||
const previousActive = get().activeAccountId;
|
const previousActive = get().activeAccountId;
|
||||||
|
// loadAccount overwrites the client's credentials/session; keep the live
|
||||||
|
// connection around so a failed switch can put it back instead of
|
||||||
|
// leaving the previous account dead until relaunch.
|
||||||
|
const previousClient = jmapClient.snapshot();
|
||||||
|
const restorePrevious = () => {
|
||||||
|
jmapClient.restoreSnapshot(previousClient);
|
||||||
|
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(accountId);
|
const ok = await jmapClient.loadAccount(accountId);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
// Credentials missing - evict stale entry and surface error
|
// Credentials missing - evict stale entry and surface error
|
||||||
accountStore.removeAccount(accountId);
|
accountStore.removeAccount(accountId);
|
||||||
useEmailStore.getState().removeAccount(accountId);
|
useEmailStore.getState().removeAccount(accountId);
|
||||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
restorePrevious();
|
||||||
set({ isLoading: false, error: 'Session expired for this account' });
|
set({ isLoading: false, error: 'Session expired for this account' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -399,13 +465,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||||
accountStore.removeAccount(accountId);
|
accountStore.removeAccount(accountId);
|
||||||
useEmailStore.getState().removeAccount(accountId);
|
useEmailStore.getState().removeAccount(accountId);
|
||||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
restorePrevious();
|
||||||
set({ isLoading: false, error: 'Session expired for this account' });
|
set({ isLoading: false, error: 'Session expired for this account' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// NetworkError or anything else - keep the previous active account
|
// NetworkError or anything else - keep the previous active account
|
||||||
// intact instead of stranding the user on a half-switched state.
|
// intact instead of stranding the user on a half-switched state.
|
||||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
restorePrevious();
|
||||||
|
accountStore.updateAccount(accountId, {
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: err instanceof Error ? err.message : 'Failed to switch account',
|
||||||
|
});
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: err instanceof Error ? err.message : 'Failed to switch account',
|
error: err instanceof Error ? err.message : 'Failed to switch account',
|
||||||
@@ -429,6 +499,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
applyConnectedState(set, session, target.serverUrl, target.username, accountId);
|
applyConnectedState(set, session, target.serverUrl, target.username, accountId);
|
||||||
refetchFeatureStores();
|
refetchFeatureStores();
|
||||||
|
void syncAccountDisplayName(accountId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAccount: async (accountId) => {
|
||||||
|
if (get().activeAccountId === accountId) {
|
||||||
|
await get().logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accountStore = useAccountStore.getState();
|
||||||
|
if (!accountStore.getAccountById(accountId)) return;
|
||||||
|
await teardownPushNotificationsForAccount(accountId).catch(() => undefined);
|
||||||
|
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||||
|
useEmailStore.getState().removeAccount(accountId);
|
||||||
|
accountStore.removeAccount(accountId);
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreSession: async () => {
|
restoreSession: async () => {
|
||||||
@@ -537,6 +621,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
// is live. Feature stores show persisted data immediately; this swaps
|
// is live. Feature stores show persisted data immediately; this swaps
|
||||||
// in fresh data once the network round-trip completes.
|
// in fresh data once the network round-trip completes.
|
||||||
refetchFeatureStores();
|
refetchFeatureStores();
|
||||||
|
void syncAccountDisplayName(target.id);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
set({ isLoading: false, hasRestoredSession: true });
|
set({ isLoading: false, hasRestoredSession: true });
|
||||||
@@ -592,3 +677,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the login flow handled `AuthenticationError`, and `retrySession` bailed
|
||||||
|
// because `session` was still the stale object. Drop the session and retry
|
||||||
|
// once; if the credentials really are dead, `retrySession` evicts the
|
||||||
|
// account and shows the login screen with "Session expired".
|
||||||
|
let authFailureInFlight = false;
|
||||||
|
jmapClient.onAuthFailure(() => {
|
||||||
|
if (authFailureInFlight) return;
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
if (!state.isAuthenticated || !state.session) return;
|
||||||
|
authFailureInFlight = true;
|
||||||
|
useAuthStore.setState({ session: null });
|
||||||
|
void state.retrySession().finally(() => {
|
||||||
|
authFailureInFlight = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user