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.
This commit is contained in:
2026-09-18 15:50:10 -07:00
parent 2399f5ce97
commit 14d708ecf8
3 changed files with 99 additions and 52 deletions
+34 -47
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowRight, Loader2 } from 'lucide-react'; import { ArrowRight, Loader2 } from 'lucide-react';
@@ -12,35 +12,42 @@ import { ArrowRight, Loader2 } from 'lucide-react';
import Logo from '@/components/common/Logo'; import Logo from '@/components/common/Logo';
import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { startAuthFlow } from '@/services/auth/oauth'; 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() { export default function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const originalPath = (location.state as { from?: string } | null)?.from ?? null; const originalPath = (location.state as { from?: string } | null)?.from ?? null;
const [username, setUsername] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const started = useRef(false);
useDocumentTitle(t('login.title', 'Sign in')); useDocumentTitle(t('login.title', 'Sign in'));
async function handleSubmit(e: FormEvent) { const go = useCallback(async () => {
e.preventDefault();
const trimmed = username.trim();
if (!trimmed) return;
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
await startAuthFlow(trimmed, originalPath); await startAuthFlow(null, originalPath);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred')); setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred'));
setLoading(false); setLoading(false);
} }
} }, [originalPath, t]);
useEffect(() => {
// Once, even under StrictMode's double effect.
if (started.current) return;
started.current = true;
void go();
}, [go]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-content-background px-4"> <div className="flex min-h-screen items-center justify-center bg-content-background px-4">
@@ -49,42 +56,22 @@ export default function LoginPage() {
<Logo /> <Logo />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4"> {error && (
<div className="space-y-2"> <p className="text-sm text-destructive" role="alert">
<p className="text-center text-sm text-muted-foreground"> {error}
{t('login.prompt', 'Enter your account name to continue')} </p>
</p> )}
<Input <Button type="button" className="w-full" disabled={loading} onClick={() => void go()}>
id="username" {loading ? (
type="text" <Loader2 className="animate-spin" />
autoComplete="username" ) : (
autoFocus <>
placeholder={t('login.usernamePlaceholder', '[email protected]')} {t('login.continue', 'Continue')}
value={username} <ArrowRight />
onChange={(e) => setUsername(e.target.value)} </>
disabled={loading}
aria-label={t('login.prompt', 'Enter your account name to continue')}
/>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)} )}
</Button>
<Button type="submit" className="w-full" disabled={loading || !username.trim()}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<>
{t('login.continue', 'Continue')}
<ArrowRight />
</>
)}
</Button>
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+47
View File
@@ -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 = '<meta name="api-base-url" content="https://mail.example.org" />';
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('[email protected]');
expect(requested).toEqual(['https://mail.example.org/api/discover/someone%40example.org']);
expect(target.searchParams.get('login_hint')).toBe('[email protected]');
});
});
+18 -5
View File
@@ -20,13 +20,24 @@ interface DiscoveryResponse {
scopes_supported?: string[]; scopes_supported?: string[];
} }
export async function discover(username: string): Promise<DiscoveryResponse> { /**
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<DiscoveryResponse> {
const url = username
? `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`
: `${getApiBaseUrl()}/.well-known/openid-configuration`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', { i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
username, username: username ?? getApiBaseUrl(),
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
}), }),
@@ -136,7 +147,7 @@ function getRedirectUri(): string {
return `${window.location.origin}${basePath}/oauth/callback`; return `${window.location.origin}${basePath}/oauth/callback`;
} }
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> { export async function startAuthFlow(username: string | null, returnUrl?: string | null): Promise<void> {
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username); const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username);
const codeVerifier = generateCodeVerifier(); const codeVerifier = generateCodeVerifier();
@@ -170,9 +181,11 @@ export async function startAuthFlow(username: string, returnUrl?: string | null)
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod, code_challenge_method: codeChallengeMethod,
state, state,
login_hint: username,
prompt: 'login', prompt: 'login',
}); });
if (username) {
params.set('login_hint', username);
}
let scope: string; let scope: string;
if (SCOPES && SCOPES.length > 0) { if (SCOPES && SCOPES.length > 0) {