feat: QR-code SSO login between webmail and mobile app

This commit is contained in:
Linus Rath
2026-06-05 18:33:20 +02:00
parent 1d050f8469
commit 569f688fbf
2 changed files with 34 additions and 5 deletions
@@ -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 <a> tag and throw
// INVALID_TAG.
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
{isOAuth ? (
<div className="space-y-6">
<LinkDeviceSection />
<div className="border-t border-border" />
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
</div>
) : (
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
)}
</SettingsSection>
);
}
+9 -1
View File
@@ -201,7 +201,15 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((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) {