From abca1237fbb4ce6926d4c94737e439c27a4d77cd Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 21:27:31 +0200 Subject: [PATCH] feat(login): offer each server's own OAuth sign-in whichever server is selected A server in the list with its own OAuth client, such as a Gmail bridge, now has its button on the login page whatever the server menu shows, so signing in with Google is one tap. The tap selects that server and runs its discovery first. --- app/(main)/[locale]/login/page.tsx | 96 ++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 32c2aabc6..ba4b63c32 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -708,12 +708,36 @@ function LoginPageContent() { window.location.href = authUrl.toString(); }; - const handleOAuthLogin = async () => { - if (LITE_OAUTH_AVAILABLE) { + /** + * Starts OAuth for the selected server, or for `target`: a server in the + * list with its own OAuth client (a Gmail bridge, say), offered on the page + * whichever server is selected so that "Sign in with Google" is one tap. + */ + const handleOAuthLogin = async (target?: PublicJmapServerEntry) => { + if (LITE_OAUTH_AVAILABLE && !target) { await startLiteOAuth(probeTarget); return; } - if (!oauthMetadata || !effectiveOauthClientId) return; + const server = target ?? selectedServer; + const clientId = target ? target.oauth?.clientId : effectiveOauthClientId; + let metadata = target ? null : oauthMetadata; + if (target) { + setOauthLoading(true); + setSelectedServerId(target.id); + try { + const res = await apiFetch(`/api/auth/oauth/metadata?server_id=${encodeURIComponent(target.id)}`); + metadata = res.ok ? ((await res.json()) as OAuthMetadata) : null; + } catch { + metadata = null; + } + if (!metadata) { + // The target is now the selected server: its discovery runs again and + // the page shows the usual "discovery failed" notice. + setOauthLoading(false); + return; + } + } + if (!metadata || !clientId) return; // In mobile-handoff mode the client-side PKCE flow doesn't help us: // tokens would land in sessionStorage on the webmail origin and the // mobile app couldn't read them. Route through the server-side SSO @@ -733,14 +757,14 @@ function LoginPageContent() { // 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 + const oauthServerUrl = server?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_server_url", oauthServerUrl!); - if (selectedServer?.id) { - sessionStorage.setItem("oauth_server_id", selectedServer.id); + if (server?.id) { + sessionStorage.setItem("oauth_server_id", server.id); } else { sessionStorage.removeItem("oauth_server_id"); } @@ -758,9 +782,9 @@ function LoginPageContent() { const nextSlot = useAccountStore.getState().getNextCookieSlot(); sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString()); - const authUrl = new URL(oauthMetadata.authorization_endpoint); + const authUrl = new URL(metadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", effectiveOauthClientId); + authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", oauthScopes || DEFAULT_OAUTH_SCOPES); authUrl.searchParams.set("state", state); @@ -887,6 +911,27 @@ function LoginPageContent() { ) : null; + // Other servers in the list that sign in with their own OAuth client get a + // button of their own, so signing in with Google does not first mean finding + // the bridge in the server menu. Not in mobile handoff, which signs in + // server-side against the selected server. + const otherOauthServers = isMobileHandoff + ? [] + : jmapServers.filter((s) => s.oauth?.clientId && s.id !== selectedServer?.id); + const otherOauthButtons = otherOauthServers.map((s) => ( + + )); + // Demo-only mode: show only a large demo login button if (demoMode && !isAddAccountMode) { return ( @@ -1203,7 +1248,7 @@ function LoginPageContent() { - {(oauthMetadata || liteSsoOffered) && ( + {(oauthMetadata || liteSsoOffered || otherOauthButtons.length > 0) && ( <>
@@ -1445,20 +1490,23 @@ function LoginPageContent() {
- + {(oauthMetadata || liteSsoOffered) && ( + + )} + {otherOauthButtons} )}