feat(ui): capability-driven LoginPage; drop prompt=none silent SSO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,3 +91,32 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
130
ui/src/auth/LoginPage.test.tsx
Normal file
130
ui/src/auth/LoginPage.test.tsx
Normal file
@@ -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 (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<LoginPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import { type FormEvent, useMemo, useState } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router';
|
import { Link, Navigate, useSearchParams } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { config } from '../config';
|
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 } from '@cameleer/design-system';
|
||||||
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import styles from './LoginPage.module.css';
|
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 PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
||||||
|
|
||||||
const SUBTITLES = [
|
const SUBTITLES = [
|
||||||
@@ -53,66 +45,50 @@ export function LoginPage() {
|
|||||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [oidc, setOidc] = useState<OidcInfo | null>(null);
|
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
const autoRedirected = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
||||||
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]);
|
|
||||||
|
|
||||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||||
|
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) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
login(username, password);
|
login(username, password);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOidcLogin = () => {
|
const handleOidcLogin = async () => {
|
||||||
if (!oidc) return;
|
|
||||||
setOidcLoading(true);
|
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 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({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
client_id: oidc.clientId,
|
client_id: data.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
scope: scopes.join(' '),
|
scope: scopes.join(' '),
|
||||||
});
|
});
|
||||||
if (oidc.resource) params.set('resource', oidc.resource);
|
if (data.resource) params.set('resource', data.resource);
|
||||||
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
// 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 (
|
return (
|
||||||
@@ -125,33 +101,45 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className={styles.subtitle}>{subtitle}</p>
|
<p className={styles.subtitle}>{subtitle}</p>
|
||||||
|
|
||||||
|
{capsFailed && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="warning">Sign-in options couldn't load. Refresh or use the form below.</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdminRecoveryBanner && (
|
||||||
|
<div className={styles.adminRecoveryBanner}>
|
||||||
|
<Alert variant="warning">
|
||||||
|
Admin recovery login. Use SSO for normal sign-in.
|
||||||
|
</Alert>
|
||||||
|
<Link to="/login" className={styles.backToSsoLink}>← Back to SSO</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<Alert variant="error">{error}</Alert>
|
<Alert variant="error">{error}</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{oidc && (
|
{showSsoPrimary && (
|
||||||
<>
|
|
||||||
<div className={styles.socialSection}>
|
<div className={styles.socialSection}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
className={styles.ssoButton}
|
className={styles.ssoButton}
|
||||||
onClick={handleOidcLogin}
|
onClick={handleOidcLogin}
|
||||||
disabled={oidcLoading}
|
disabled={oidcLoading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
{oidcLoading ? 'Redirecting\u2026' : `Sign in with ${providerName}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Link to="/login?local" className={styles.adminRecoveryLink}>
|
||||||
|
Admin recovery →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.divider}>
|
|
||||||
<div className={styles.dividerLine} />
|
|
||||||
<span className={styles.dividerText}>or</span>
|
|
||||||
<div className={styles.dividerLine} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showLocalForm && (
|
||||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||||
<FormField label="Username" htmlFor="login-username">
|
<FormField label="Username" htmlFor="login-username">
|
||||||
<Input
|
<Input
|
||||||
@@ -187,6 +175,7 @@ export function LoginPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user