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:
@@ -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<OidcInfo | null>(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 <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) => {
|
||||
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() {
|
||||
</div>
|
||||
<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 && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oidc && (
|
||||
<>
|
||||
<div className={styles.socialSection}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={styles.ssoButton}
|
||||
onClick={handleOidcLogin}
|
||||
disabled={oidcLoading}
|
||||
type="button"
|
||||
>
|
||||
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>or</span>
|
||||
<div className={styles.dividerLine} />
|
||||
</div>
|
||||
</>
|
||||
{showSsoPrimary && (
|
||||
<div className={styles.socialSection}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={styles.ssoButton}
|
||||
onClick={handleOidcLogin}
|
||||
disabled={oidcLoading}
|
||||
type="button"
|
||||
>
|
||||
{oidcLoading ? 'Redirecting\u2026' : `Sign in with ${providerName}`}
|
||||
</Button>
|
||||
<Link to="/login?local" className={styles.adminRecoveryLink}>
|
||||
Admin recovery →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||
<FormField label="Username" htmlFor="login-username">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
{showLocalForm && (
|
||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||
<FormField label="Username" htmlFor="login-username">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !username || !password}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !username || !password}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user