diff --git a/ui/src/auth/LoginPage.test.tsx b/ui/src/auth/LoginPage.test.tsx index acf7436f..8ba294f7 100644 --- a/ui/src/auth/LoginPage.test.tsx +++ b/ui/src/auth/LoginPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router'; import type { ReactNode } from 'react'; @@ -46,18 +46,40 @@ function mockCaps(body: any) { describe('LoginPage', () => { beforeEach(() => vi.clearAllMocks()); - it('SSO primary, no ?local: renders SSO button only and admin-recovery link, no local form', async () => { + it('SSO primary, no ?local: auto-redirects to OIDC provider and shows admin-recovery link', async () => { mockCaps({ oidc: { enabled: true, providerName: 'Logto', primary: true }, localAccounts: { enabled: true, adminRecoveryOnly: true }, }); - render(, { wrapper: wrapper(['/login']) }); + const originalLocation = window.location; + const hrefSetter = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, get href() { return ''; }, set href(v: string) { hrefSetter(v); } }, + }); - expect(await screen.findByRole('button', { name: /sign in with logto/i })).toBeInTheDocument(); - expect(screen.queryByLabelText(/username/i)).toBeNull(); - expect(screen.queryByLabelText(/password/i)).toBeNull(); - expect(screen.getByRole('link', { name: /admin recovery/i })).toBeInTheDocument(); + try { + render(, { wrapper: wrapper(['/login']) }); + + // Auto-redirect fires without user interaction + await waitFor(() => expect(hrefSetter).toHaveBeenCalled()); + const url: string = hrefSetter.mock.calls[0][0]; + expect(url).toMatch(/^https:\/\/auth\.logto\.example\/oidc\/auth\?/); + expect(url).toMatch(/response_type=code/); + expect(url).toMatch(/client_id=spa-client/); + + // No prompt parameter — let the OIDC provider manage its session + expect(url).not.toMatch(/prompt=/); + + // Admin recovery escape hatch is still visible + expect(screen.getByRole('link', { name: /admin recovery/i })).toBeInTheDocument(); + // No local form fields rendered + expect(screen.queryByLabelText(/username/i)).toBeNull(); + expect(screen.queryByLabelText(/password/i)).toBeNull(); + } finally { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + } }); it('SSO primary, ?local present: renders local form with amber recovery banner and back-to-SSO link', async () => { @@ -99,37 +121,7 @@ describe('LoginPage', () => { expect(screen.getByText(/sign-in options couldn't load/i)).toBeInTheDocument(); }); - it('SSO button click: navigates to authorize URL WITHOUT prompt=none', async () => { - mockCaps({ - oidc: { enabled: true, providerName: 'Logto', primary: true }, - localAccounts: { enabled: true, adminRecoveryOnly: true }, - }); - - const originalLocation = window.location; - const hrefSetter = vi.fn(); - Object.defineProperty(window, 'location', { - configurable: true, - value: { ...originalLocation, get href() { return ''; }, set href(v: string) { hrefSetter(v); } }, - }); - - try { - render(, { wrapper: wrapper(['/login']) }); - const btn = await screen.findByRole('button', { name: /sign in with logto/i }); - fireEvent.click(btn); - - await waitFor(() => expect(hrefSetter).toHaveBeenCalled()); - const url: string = hrefSetter.mock.calls[0][0]; - expect(url).toMatch(/^https:\/\/auth\.logto\.example\/oidc\/auth\?/); - expect(url).not.toMatch(/prompt=none/); - expect(url).toMatch(/response_type=code/); - expect(url).toMatch(/client_id=spa-client/); - expect(url).toMatch(/scope=/); - } finally { - Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); - } - }); - - it('SSO button click: when /auth/oidc/config fails, button unlocks and error is set', async () => { + it('SSO auto-redirect failure: shows manual SSO button as fallback', async () => { const setStateMock = vi.fn(); const useAuthStoreMock = vi.mocked(useAuthStore) as unknown as { setState: typeof setStateMock }; useAuthStoreMock.setState = setStateMock; @@ -144,13 +136,9 @@ describe('LoginPage', () => { }); render(, { wrapper: wrapper(['/login']) }); - const btn = await screen.findByRole('button', { name: /sign in with logto/i }); - fireEvent.click(btn); await waitFor(() => expect(setStateMock).toHaveBeenCalled()); const errorPayload = setStateMock.mock.calls[0][0]; expect(errorPayload.error).toMatch(/OIDC configuration unavailable/i); - // Button should not stay locked in "Redirecting…" - await waitFor(() => expect(btn).not.toHaveTextContent(/redirecting/i)); }); }); diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 63609aed..917eb3f2 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -1,10 +1,10 @@ -import { type FormEvent, useMemo, useState } from 'react'; +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, Navigate, useSearchParams } from 'react-router'; import { useAuthStore } from './auth-store'; import { api } from '../api/client'; import { config } from '../config'; import { useAuthCapabilities } from '../api/queries/auth'; -import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system'; +import { Card, Input, Button, Alert, FormField, Spinner } from '@cameleer/design-system'; import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import styles from './LoginPage.module.css'; @@ -46,6 +46,7 @@ export function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [oidcLoading, setOidcLoading] = useState(false); + const autoRedirected = useRef(false); // Mirrors cameleer-saas: when logout sets this flag, render a "Signed out" // confirmation instead of the regular form. The flag is one-shot — read + @@ -58,6 +59,61 @@ export function LoginPage() { const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities(); + // Derive render decisions before hooks that depend on them and before + // any conditional returns (React rules-of-hooks). + const oidcPrimary = caps?.oidc?.primary === true; + const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true; + const providerName = caps?.oidc?.providerName || 'Single Sign-On'; + const showSsoPrimary = oidcPrimary && adminRecoveryOnly && !forceLocal; + const showLocalForm = !oidcPrimary || forceLocal || !adminRecoveryOnly || capsFailed; + const showAdminRecoveryBanner = oidcPrimary && adminRecoveryOnly && forceLocal; + + // Standard OIDC RP redirect — shared between auto-redirect and manual retry. + // No `prompt` parameter: the OIDC provider decides whether to show a login + // form based on its own session state. If the user already has an active + // session (e.g. from the SaaS platform sharing the same provider) the + // provider issues a code immediately without showing a login form. + const triggerOidcRedirect = useCallback(async () => { + setOidcLoading(true); + try { + const { data } = await api.GET('/auth/oidc/config'); + if (!data?.authorizationEndpoint || !data?.clientId) { + useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); + return; + } + if (data.endSessionEndpoint) { + localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); + } + if (data.clientId) { + localStorage.setItem('cameleer-oidc-client-id', data.clientId); + } + const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; + const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; + const params = new URLSearchParams({ + response_type: 'code', + client_id: data.clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + }); + if (data.resource) params.set('resource', data.resource); + window.location.href = `${data.authorizationEndpoint}?${params}`; + } catch { + useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); + } finally { + setOidcLoading(false); + } + }, []); + + // When the OIDC provider is the primary auth method, redirect to its + // authorization endpoint immediately. The provider handles session + // management — the user only sees a login form if no session exists. + useEffect(() => { + if (showSsoPrimary && !signedOut && !autoRedirected.current) { + autoRedirected.current = true; + triggerOidcRedirect(); + } + }, [showSsoPrimary, signedOut, triggerOidcRedirect]); + if (isAuthenticated) return ; if (capsLoading) return null; @@ -84,56 +140,11 @@ export function LoginPage() { ); } - const oidcPrimary = caps?.oidc?.primary === true; - const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true; - const providerName = caps?.oidc?.providerName || 'Single Sign-On'; - - // Render decisions - const showSsoPrimary = oidcPrimary && adminRecoveryOnly && !forceLocal; - const showLocalForm = !oidcPrimary || forceLocal || !adminRecoveryOnly || capsFailed; - const showAdminRecoveryBanner = oidcPrimary && adminRecoveryOnly && forceLocal; - const handleSubmit = (e: FormEvent) => { e.preventDefault(); login(username, password); }; - const handleOidcLogin = async () => { - setOidcLoading(true); - try { - const { data } = await api.GET('/auth/oidc/config'); - if (!data?.authorizationEndpoint || !data?.clientId) { - useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); - return; - } - if (data.endSessionEndpoint) { - localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); - } - if (data.clientId) { - localStorage.setItem('cameleer-oidc-client-id', data.clientId); - } - const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; - const params = new URLSearchParams({ - response_type: 'code', - client_id: data.clientId, - redirect_uri: redirectUri, - scope: scopes.join(' '), - // Defence-in-depth: even if RP-Initiated Logout did not fully clear - // the IdP session (proxy/cookie edge cases), prompt=login forces the - // IdP to re-prompt for credentials instead of silent re-auth. - // OIDC Core 1.0 §3.1.2.1. - prompt: 'login', - }); - if (data.resource) params.set('resource', data.resource); - window.location.href = `${data.authorizationEndpoint}?${params}`; - } catch { - useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' }); - } finally { - setOidcLoading(false); - } - }; - return (
@@ -167,15 +178,21 @@ export function LoginPage() { {showSsoPrimary && (
- + {!error ? ( +
+ +
+ ) : ( + + )} Admin recovery →