From 569f688fbfc728991705a6cb017c25ae95b9772a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:33:20 +0200 Subject: [PATCH] feat: QR-code SSO login between webmail and mobile app --- .../settings/account-security-settings.tsx | 29 ++++++++++++++++--- stores/account-security-store.ts | 10 ++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 4a2e34ab..64a5b2d0 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -737,11 +737,16 @@ function LinkDeviceSection() { export function AccountSecuritySettings() { const t = useTranslations('settings.security'); const { isStalwart, isProbing, probe, fetchAll, fetchAuthInfo } = useAccountSecurityStore(); - const { isAuthenticated, authMode } = useAuthStore(); + const { isAuthenticated, authMode, client } = useAuthStore(); const isOAuth = authMode === 'oauth'; + // Wait for `client` before probing. On reload the persisted `isAuthenticated` + // flips true before the async OAuth reconnect sets `client`; probing in that + // window reads a null client, decides the server isn't Stalwart, and caches + // that wrong verdict. Gating on `client` (which is set only after connect() + // populates the session capabilities) makes the probe run with real data. useEffect(() => { - if (isAuthenticated && isStalwart === null) { + if (isAuthenticated && client && isStalwart === null) { probe().then((detected) => { if (detected) { if (isOAuth) { @@ -752,7 +757,7 @@ export function AccountSecuritySettings() { } }); } - }, [isAuthenticated, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]); + }, [isAuthenticated, client, isStalwart, probe, fetchAll, fetchAuthInfo, isOAuth]); if (isProbing) { return ( @@ -766,9 +771,25 @@ export function AccountSecuritySettings() { } if (isStalwart === false) { + // Even when Stalwart account-management isn't exposed (common for OAuth + // sessions, whose tokens may lack the management capability), the + // cross-device mobile pairing still works — it only needs the OAuth + // refresh-token cookie, not `urn:stalwart:jmap`. So surface the QR linker + // for OAuth sessions and show the "not available" note for the rest. + // Use t.raw (not t) because the message is hand-injected HTML; passing it + // through t() makes next-intl try to parse the tag and throw + // INVALID_TAG. return ( -
+ {isOAuth ? ( +
+ +
+
+
+ ) : ( +
+ )} ); } diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 07027fef..248a42f8 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -201,7 +201,15 @@ export const useAccountSecurityStore = create()((set, get) set({ isProbing: true }); try { const client = useAuthStore.getState().client; - const isStalwart = !!client?.hasAccountCapability?.('urn:stalwart:jmap'); + // No live client yet (e.g. the OAuth session is still reconnecting after + // a reload). Don't record a verdict — leave isStalwart null so the caller + // re-probes once the client is ready, instead of caching a false "not a + // Stalwart server" from a session that hasn't loaded its capabilities. + if (!client) { + set({ isProbing: false }); + return false; + } + const isStalwart = !!client.hasAccountCapability?.('urn:stalwart:jmap'); set({ isStalwart, isProbing: false }); return isStalwart; } catch (error) {