From d3d79be64c4ee790de652f3f9f33d96292da4b9b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 6 May 2026 17:33:55 +0200 Subject: [PATCH] feat: multi-server JMAP support --- app/[locale]/auth/callback/page.tsx | 4 +- app/[locale]/login/page.tsx | 196 ++++++++++++---- app/admin/_tabs/_jmap-servers-section.tsx | 272 ++++++++++++++++++++++ app/admin/_tabs/settings.tsx | 27 +++ app/api/admin/config/route.ts | 18 ++ app/api/auth/session/route.ts | 16 +- app/api/auth/sso/complete/route.ts | 11 +- app/api/auth/sso/start/route.ts | 10 +- app/api/auth/stalwart-context/route.ts | 14 +- app/api/auth/token/route.ts | 37 ++- app/api/auth/totp-token-exchange/route.ts | 57 +++-- app/api/config/route.ts | 3 + hooks/use-config.ts | 9 + lib/admin/config-manager.ts | 6 + lib/admin/jmap-servers.ts | 168 +++++++++++++ lib/admin/types.ts | 4 +- lib/oauth/token-exchange.ts | 51 ++-- lib/oauth/tokens.ts | 6 + locales/en/common.json | 2 + stores/auth-store.ts | 15 +- 20 files changed, 818 insertions(+), 108 deletions(-) create mode 100644 app/admin/_tabs/_jmap-servers-section.tsx create mode 100644 lib/admin/jmap-servers.ts diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index 8032d75a..b03e2313 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -43,6 +43,7 @@ function OAuthCallbackInner() { const codeVerifier = sessionStorage.getItem("oauth_code_verifier"); const serverUrl = sessionStorage.getItem("oauth_server_url"); + const serverId = sessionStorage.getItem("oauth_server_id") || undefined; if (!codeVerifier || !serverUrl) { setError("missing_params"); @@ -52,12 +53,13 @@ function OAuthCallbackInner() { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; - loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) + loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId) .then((success) => { if (success) { sessionStorage.removeItem("oauth_state"); sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_server_url"); + sessionStorage.removeItem("oauth_server_id"); sessionStorage.removeItem("oauth_add_account_mode"); let redirectTo = `${prefix}/${params.locale}`; try { diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 5bdd1938..9708799a 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -18,6 +18,14 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; +import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; + +function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined { + if (!email || !email.includes("@")) return undefined; + const domain = email.split("@")[1]?.trim().toLowerCase(); + if (!domain) return undefined; + return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain)); +} const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -109,7 +117,7 @@ export default function LoginPage() { const isAddAccountMode = searchParams.get("mode") === "add-account"; const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig(); + const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -117,6 +125,18 @@ export default function LoginPage() { password: "", }); const [jmapEndpoint, setJmapEndpoint] = useState(""); + const [selectedServerId, setSelectedServerId] = useState(null); + const [domainAutoLocked, setDomainAutoLocked] = useState(false); + + const hasServerList = jmapServers.length > 0; + const selectedServer = hasServerList + ? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0] + : undefined; + + // Effective values: per-server overrides win, then global config. + const serverUrl = selectedServer?.url || configuredServerUrl; + const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId; + const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl; const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); @@ -157,6 +177,27 @@ export default function LoginPage() { } }, [serverUrl, jmapEndpoint]); + // Initialize selected server when the server list arrives. Picks the first + // entry; the auto-pick effect below may override based on the email domain. + useEffect(() => { + if (!hasServerList) return; + if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return; + setSelectedServerId(jmapServers[0].id); + }, [hasServerList, jmapServers, selectedServerId]); + + // Auto-pick by email domain. Locks the dropdown to the matched server until + // the user clears the email or types a domain we don't recognize. + useEffect(() => { + if (!jmapServerAutoPickByDomain || !hasServerList) return; + const match = findServerByDomain(jmapServers, formData.username); + if (match) { + if (selectedServerId !== match.id) setSelectedServerId(match.id); + setDomainAutoLocked(true); + } else { + setDomainAutoLocked(false); + } + }, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]); + useEffect(() => { try { if (sessionStorage.getItem('session_expired') === 'true') { @@ -254,7 +295,9 @@ export default function LoginPage() { useEffect(() => { if (!oauthEnabled || !serverUrl) return; - discoverOAuth(oauthIssuerUrl || serverUrl) + setOauthDiscoveryDone(false); + setOauthMetadata(null); + discoverOAuth(effectiveOauthIssuerUrl || serverUrl) .then((metadata) => { setOauthMetadata(metadata); setOauthDiscoveryDone(true); @@ -263,7 +306,7 @@ export default function LoginPage() { setOauthMetadata(null); setOauthDiscoveryDone(true); }); - }, [oauthEnabled, serverUrl, oauthIssuerUrl]); + }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]); // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely const ssoError = searchParams.get("sso_error"); @@ -278,7 +321,11 @@ export default function LoginPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }), + body: JSON.stringify({ + redirect_uri: redirectUri, + locale: params.locale, + server_id: selectedServer?.id, + }), }); if (!res.ok) { @@ -304,7 +351,7 @@ export default function LoginPage() { } catch { setOauthLoading(false); } - }, [params.locale]); + }, [params.locale, selectedServer?.id]); useEffect(() => { if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; @@ -445,7 +492,7 @@ export default function LoginPage() { }; const handleOAuthLogin = async () => { - if (!oauthMetadata || !oauthClientId) return; + if (!oauthMetadata || !effectiveOauthClientId) return; setOauthLoading(true); const verifier = generateCodeVerifier(); @@ -454,9 +501,19 @@ export default function LoginPage() { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + // Resolve the JMAP URL to send to the callback. Server-list entries win + // over the custom-endpoint input, which wins over the global server URL. + const oauthServerUrl = selectedServer?.url + || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); - sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!); + sessionStorage.setItem("oauth_server_url", oauthServerUrl!); + if (selectedServer?.id) { + sessionStorage.setItem("oauth_server_id", selectedServer.id); + } else { + sessionStorage.removeItem("oauth_server_id"); + } if (isAddAccountMode) { sessionStorage.setItem("oauth_add_account_mode", "true"); } @@ -473,7 +530,7 @@ export default function LoginPage() { const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", oauthClientId); + authUrl.searchParams.set("client_id", effectiveOauthClientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", OAUTH_SCOPES); authUrl.searchParams.set("state", state); @@ -486,7 +543,10 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl; + // Server-list entries always win — `allowCustomJmapEndpoint` is only honored + // when the admin hasn't configured a server list. + const effectiveServerUrl = selectedServer?.url + || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); const success = await login( effectiveServerUrl, formData.username, @@ -607,13 +667,17 @@ export default function LoginPage() {
{error && (
- -

- {t(`error.${error}`) || t("error.generic")} -

+
+ +
+
+

+ {t(`error.${error}`) || t("error.generic")} +

+
)} @@ -754,37 +818,45 @@ export default function LoginPage() { {/* Session Expired Banner */} {sessionExpired && (
- -

- {t("session_expired")} -

- +
+ +
+
+

+ {t("session_expired")} +

+ +
)} {/* Error Message */} {error && (
- -

- {error === 'invalid_credentials' && showTotpField && totpCode - ? t('error.totp_invalid') - : t(`error.${error}`) || t("error.generic")} -

+
+ +
+
+

+ {error === 'invalid_credentials' && showTotpField && totpCode + ? t('error.totp_invalid') + : t(`error.${error}`) || t("error.generic")} +

+
)} @@ -836,11 +908,15 @@ export default function LoginPage() { )} ) : oauthDiscoveryDone ? ( -
- -

- {t("error.oauth_discovery_failed")} -

+
+
+ +
+
+

+ {t("error.oauth_discovery_failed")} +

+
) : (
@@ -852,8 +928,32 @@ export default function LoginPage() { /* Login Form */
- {/* JMAP Endpoint field (when custom endpoints are allowed) */} - {allowCustomJmapEndpoint && ( + {/* Server picker (when admin has configured a server list) */} + {hasServerList && jmapServers.length > 1 && ( +
+ + + {domainAutoLocked && ( +

+ {t("jmap_server_auto_picked")} +

+ )} +
+ )} + {/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */} + {!hasServerList && allowCustomJmapEndpoint && (