From 14d708ecf8b0bd4b6f1f5b8421b65c2443c44f52 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 15:50:10 -0700 Subject: [PATCH] Go straight to the server's sign-in page, with no account-name step INBUXA Admin talks to one known server whose own page signs in every account, so it reads the server's OpenID configuration instead of looking endpoints up per account. The login card now only shows while redirecting, or to retry when that fails. --- src/pages/LoginPage.tsx | 81 ++++++++++++++------------------- src/services/auth/oauth.test.ts | 47 +++++++++++++++++++ src/services/auth/oauth.ts | 23 ++++++++-- 3 files changed, 99 insertions(+), 52 deletions(-) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 56c891e..24f4182 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { type FormEvent, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowRight, Loader2 } from 'lucide-react'; @@ -12,35 +12,42 @@ import { ArrowRight, Loader2 } from 'lucide-react'; import Logo from '@/components/common/Logo'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { startAuthFlow } from '@/services/auth/oauth'; +/** + * INBUXA: straight to the server's own sign-in page, which asks for the + * username and password. There is no account-name step first: INBUXA Admin + * talks to one known server, so there is nothing to look up per account (see + * `discover`). The card stays only for when getting there fails. + */ export default function LoginPage() { const { t } = useTranslation(); const location = useLocation(); const originalPath = (location.state as { from?: string } | null)?.from ?? null; - const [username, setUsername] = useState(''); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); + const started = useRef(false); useDocumentTitle(t('login.title', 'Sign in')); - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - const trimmed = username.trim(); - if (!trimmed) return; - + const go = useCallback(async () => { setError(null); setLoading(true); - try { - await startAuthFlow(trimmed, originalPath); + await startAuthFlow(null, originalPath); } catch (err) { setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred')); setLoading(false); } - } + }, [originalPath, t]); + + useEffect(() => { + // Once, even under StrictMode's double effect. + if (started.current) return; + started.current = true; + void go(); + }, [go]); return (
@@ -49,42 +56,22 @@ export default function LoginPage() { - -
-
-

- {t('login.prompt', 'Enter your account name to continue')} -

- setUsername(e.target.value)} - disabled={loading} - aria-label={t('login.prompt', 'Enter your account name to continue')} - /> -
- - {error && ( -

- {error} -

+ + {error && ( +

+ {error} +

+ )} + - +
diff --git a/src/services/auth/oauth.test.ts b/src/services/auth/oauth.test.ts index cfc4fd6..1563435 100644 --- a/src/services/auth/oauth.test.ts +++ b/src/services/auth/oauth.test.ts @@ -102,3 +102,50 @@ describe('generateCodeChallenge', () => { } }); }); + +describe('startAuthFlow (INBUXA: no account-name step)', () => { + const metadata = { + authorization_endpoint: '/login', + token_endpoint: '/auth/token', + scopes_supported: ['openid', 'offline_access'], + }; + + async function run(username: string | null) { + document.head.innerHTML = ''; + const requested: string[] = []; + const fetchMock = async (url: string) => { + requested.push(url); + return new Response(JSON.stringify(metadata), { status: 200 }); + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as typeof fetch; + let href = ''; + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, origin: 'https://admin.example.org', pathname: '/', search: '', set href(v: string) { href = v; }, get href() { return href; } }, + }); + try { + const { startAuthFlow } = await import('./oauth'); + await startAuthFlow(username); + } finally { + globalThis.fetch = originalFetch; + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + } + return { requested, target: new URL(href) }; + } + + it('reads the server-wide configuration and goes straight to its sign-in page', async () => { + const { requested, target } = await run(null); + expect(requested).toEqual(['https://mail.example.org/.well-known/openid-configuration']); + expect(`${target.origin}${target.pathname}`).toBe('https://mail.example.org/login'); + expect(target.searchParams.has('login_hint')).toBe(false); + expect(target.searchParams.get('redirect_uri')).toBe('https://admin.example.org/oauth/callback'); + }); + + it('still looks an account up, and passes the hint, when given one', async () => { + const { requested, target } = await run('someone@example.org'); + expect(requested).toEqual(['https://mail.example.org/api/discover/someone%40example.org']); + expect(target.searchParams.get('login_hint')).toBe('someone@example.org'); + }); +}); diff --git a/src/services/auth/oauth.ts b/src/services/auth/oauth.ts index 5c30b72..67f9fa2 100644 --- a/src/services/auth/oauth.ts +++ b/src/services/auth/oauth.ts @@ -20,13 +20,24 @@ interface DiscoveryResponse { scopes_supported?: string[]; } -export async function discover(username: string): Promise { - const url = `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`; +/** + * The server's OAuth endpoints. + * + * Upstream asks for the account name first and looks the endpoints up per + * account, because a domain there can sign in at its own identity provider. + * INBUXA Admin talks to one known server whose own sign-in page handles every + * account, so it reads the server's published OpenID configuration instead, + * which needs no account name. Given one, it still asks per account. + */ +export async function discover(username?: string | null): Promise { + const url = username + ? `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}` + : `${getApiBaseUrl()}/.well-known/openid-configuration`; const response = await fetch(url); if (!response.ok) { throw new Error( i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', { - username, + username: username ?? getApiBaseUrl(), status: response.status, statusText: response.statusText, }), @@ -136,7 +147,7 @@ function getRedirectUri(): string { return `${window.location.origin}${basePath}/oauth/callback`; } -export async function startAuthFlow(username: string, returnUrl?: string | null): Promise { +export async function startAuthFlow(username: string | null, returnUrl?: string | null): Promise { const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username); const codeVerifier = generateCodeVerifier(); @@ -170,9 +181,11 @@ export async function startAuthFlow(username: string, returnUrl?: string | null) code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, state, - login_hint: username, prompt: 'login', }); + if (username) { + params.set('login_hint', username); + } let scope: string; if (SCOPES && SCOPES.length > 0) {