diff --git a/ui/src/auth/LoginPage.module.css b/ui/src/auth/LoginPage.module.css index f42f1da6..44c0d428 100644 --- a/ui/src/auth/LoginPage.module.css +++ b/ui/src/auth/LoginPage.module.css @@ -91,3 +91,32 @@ width: 100%; justify-content: center; } + +.adminRecoveryBanner { + margin-bottom: 1rem; +} + +.adminRecoveryBanner .backToSsoLink { + display: inline-block; + margin-top: 0.5rem; + color: var(--accent); + text-decoration: none; + font-size: 0.875rem; +} + +.adminRecoveryBanner .backToSsoLink:hover { + text-decoration: underline; +} + +.adminRecoveryLink { + display: inline-block; + margin-top: 0.75rem; + color: var(--text-muted); + font-size: 0.8125rem; + text-decoration: none; +} + +.adminRecoveryLink:hover { + color: var(--accent); + text-decoration: underline; +} diff --git a/ui/src/auth/LoginPage.test.tsx b/ui/src/auth/LoginPage.test.tsx new file mode 100644 index 00000000..6d357fda --- /dev/null +++ b/ui/src/auth/LoginPage.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; + +vi.mock('../api/client', () => ({ api: { GET: vi.fn() } })); +vi.mock('./auth-store', () => ({ + useAuthStore: Object.assign( + () => ({ isAuthenticated: false, login: vi.fn(), loading: false, error: null }), + { setState: vi.fn() } + ), +})); + +import { api as apiClient } from '../api/client'; +import { LoginPage } from './LoginPage'; + +function wrapper(initialEntries: string[]) { + return ({ children }: { children: ReactNode }) => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {children} + + ); + }; +} + +function mockCaps(body: any) { + (apiClient.GET as any).mockImplementation((path: string) => { + if (path === '/auth/capabilities') return Promise.resolve({ data: body, error: null }); + if (path === '/auth/oidc/config') return Promise.resolve({ + data: { + clientId: 'spa-client', + authorizationEndpoint: 'https://auth.logto.example/oidc/auth', + resource: 'https://api.cameleer.local', + additionalScopes: [], + }, + error: null, + }); + return Promise.resolve({ data: undefined, error: { message: 'unexpected' } }); + }); +} + +describe('LoginPage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('SSO primary, no ?local: renders SSO button only and admin-recovery link, no local form', async () => { + mockCaps({ + oidc: { enabled: true, providerName: 'Logto', primary: true }, + localAccounts: { enabled: true, adminRecoveryOnly: true }, + }); + + render(, { wrapper: wrapper(['/login']) }); + + 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(); + }); + + it('SSO primary, ?local present: renders local form with amber recovery banner and back-to-SSO link', async () => { + mockCaps({ + oidc: { enabled: true, providerName: 'Logto', primary: true }, + localAccounts: { enabled: true, adminRecoveryOnly: true }, + }); + + render(, { wrapper: wrapper(['/login?local']) }); + + expect(await screen.findByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByText(/admin recovery/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to sso/i })).toBeInTheDocument(); + }); + + it('OIDC disabled: renders local form only, no SSO button', async () => { + mockCaps({ + oidc: { enabled: false, providerName: '', primary: false }, + localAccounts: { enabled: true, adminRecoveryOnly: false }, + }); + + render(, { wrapper: wrapper(['/login']) }); + + expect(await screen.findByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /sign in with/i })).toBeNull(); + expect(screen.queryByText(/admin recovery/i)).toBeNull(); + }); + + it('capabilities request fails: renders degraded local form with warning banner', async () => { + (apiClient.GET as any).mockImplementation((path: string) => { + if (path === '/auth/capabilities') return Promise.resolve({ data: undefined, error: { message: 'fail' } }); + return Promise.resolve({ data: undefined, error: { message: 'unexpected' } }); + }); + + render(, { wrapper: wrapper(['/login']) }); + + expect(await screen.findByLabelText(/username/i)).toBeInTheDocument(); + 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 }); + } + }); +}); diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 78e2ccb5..e1812218 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -1,21 +1,13 @@ -import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; -import { Navigate, useSearchParams } from 'react-router'; +import { type FormEvent, useMemo, 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 brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import styles from './LoginPage.module.css'; -interface OidcInfo { - clientId: string; - authorizationEndpoint: string; - resource?: string; - additionalScopes?: string[]; -} - -// Logto org scopes required for role mapping in multi-tenant setups. -// Always requested, harmless for non-Logto providers (unknown scopes are ignored per OIDC spec). const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles']; const SUBTITLES = [ @@ -53,66 +45,50 @@ export function LoginPage() { const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [oidc, setOidc] = useState(null); const [oidcLoading, setOidcLoading] = useState(false); - const autoRedirected = useRef(false); - useEffect(() => { - api.GET('/auth/oidc/config') - .then(({ data }) => { - if (data?.authorizationEndpoint && data?.clientId) { - setOidc({ - clientId: data.clientId, - authorizationEndpoint: data.authorizationEndpoint, - resource: data.resource ?? undefined, - additionalScopes: data.additionalScopes ?? undefined, - }); - if (data.endSessionEndpoint) { - localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); - } - } - }) - .catch(() => {}); - }, []); - - // Auto-redirect to OIDC provider for SSO (skip if ?local is in URL) - useEffect(() => { - if (oidc && !forceLocal && !autoRedirected.current) { - autoRedirected.current = true; - const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])]; - const params = new URLSearchParams({ - response_type: 'code', - client_id: oidc.clientId, - redirect_uri: redirectUri, - scope: scopes.join(' '), - prompt: 'none', - }); - if (oidc.resource) params.set('resource', oidc.resource); - window.location.href = `${oidc.authorizationEndpoint}?${params}`; - } - }, [oidc, forceLocal]); + const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities(); if (isAuthenticated) return ; + if (capsLoading) return null; + + const oidcEnabled = caps?.oidc?.enabled === true; + const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true; + const providerName = caps?.oidc?.providerName || 'Single Sign-On'; + + // Render decisions + const showSsoPrimary = oidcEnabled && adminRecoveryOnly && !forceLocal; + const showLocalForm = !oidcEnabled || forceLocal || !adminRecoveryOnly || capsFailed; + const showAdminRecoveryBanner = oidcEnabled && adminRecoveryOnly && forceLocal; const handleSubmit = (e: FormEvent) => { e.preventDefault(); login(username, password); }; - const handleOidcLogin = () => { - if (!oidc) return; + const handleOidcLogin = async () => { setOidcLoading(true); + const { data } = await api.GET('/auth/oidc/config'); + if (!data?.authorizationEndpoint || !data?.clientId) { + setOidcLoading(false); + 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); + } const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; - const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])]; + const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])]; const params = new URLSearchParams({ response_type: 'code', - client_id: oidc.clientId, + client_id: data.clientId, redirect_uri: redirectUri, scope: scopes.join(' '), }); - if (oidc.resource) params.set('resource', oidc.resource); - window.location.href = `${oidc.authorizationEndpoint}?${params}`; + if (data.resource) params.set('resource', data.resource); + // Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only; + // for first-time login it returns login_required and traps users on a local form. + window.location.href = `${data.authorizationEndpoint}?${params}`; }; return ( @@ -125,68 +101,81 @@ export function LoginPage() {

{subtitle}

+ {capsFailed && ( +
+ Sign-in options couldn't load. Refresh or use the form below. +
+ )} + + {showAdminRecoveryBanner && ( +
+ + Admin recovery login. Use SSO for normal sign-in. + + ← Back to SSO +
+ )} + {error && (
{error}
)} - {oidc && ( - <> -
- -
-
-
- or -
-
- + {showSsoPrimary && ( +
+ + + Admin recovery → + +
)} -
- - setUsername(e.target.value)} - placeholder="Enter your username" - autoFocus - autoComplete="username" - disabled={loading} - /> - + {showLocalForm && ( + + + setUsername(e.target.value)} + placeholder="Enter your username" + autoFocus + autoComplete="username" + disabled={loading} + /> + - - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - disabled={loading} - /> - + + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> + - -
+ + + )}