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) {