mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
e175db8e62
* feat(auth): add SSO-only authentication mode Let administrators disable interactive local password login when SSO is configured, with backend enforcement, activation safeguards, and host CLI recovery. Closes #1709 * fix: resolve CI failures in auth mode PR - Add useLicense mock to SSOSection test to prevent crash from AuthenticationModePanel rendering without LicenseProvider - Remove username from authMode console.log calls that CodeQL flags as clear-text logging of sensitive information * fix(auth): keep SSO-only on named disableSso and fail-closed login Named provider disable no longer reverts authentication_mode. Login initializes localLoginEnabled false so a status fetch failure cannot reveal the password form. Center a single OIDC provider button on the login card. * fix(auth): move SSO-only authentication mode from Admiral to Community tier Security-hardening features belong on the Community tier per the existing Community rebalance. The reporter of #1709 noted that disabling local password login after configuring SSO is a basic security measure, not an enterprise governance feature. LDAP provider configuration remains Admiral-gated via requireTierForSsoProvider. * fix(ui): keep SSO Active badge and ON toggle in sync Provider cards mounted before config fetch finished with enabled:false, so a saved Active provider showed OFF until the local draft was resynced. Drive both the badge and TogglePill from the synced local config. * feat(auth): auto-redirect to sole OIDC provider under SSO-only When authentication mode is SSO only and exactly one OIDC provider is enabled (no LDAP), skip the login chooser and send the browser to that provider's authorize URL. Returning sso_error stays on the login page so the failure message remains visible. * fix(ui): move oidcAutoRedirectUrl out of Login for fast refresh Exporting the helper alongside the Login component tripped react-refresh/only-export-components and failed Frontend lint CI. Keep Login as a component-only module and colocate the helper with its unit tests under lib/.
372 lines
15 KiB
TypeScript
372 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { cn } from '@/lib/utils';
|
|
import { oidcAutoRedirectUrl } from '@/lib/oidcAutoRedirect';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { ArrowRight, KeyRound, Loader2 } from 'lucide-react';
|
|
import { AuthCanvas } from '@/components/auth/AuthCanvas';
|
|
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
|
|
import { ErrorRail } from '@/components/auth/ErrorRail';
|
|
|
|
interface SSOProvider {
|
|
provider: string;
|
|
displayName: string;
|
|
type: 'ldap' | 'oidc';
|
|
}
|
|
|
|
const INPUT_CLASS =
|
|
'h-11 bg-background/60 border-card-border font-sans text-base shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] placeholder:text-stat-subtitle/60 focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-offset-0';
|
|
|
|
function getProviderIcon(provider: string) {
|
|
switch (provider) {
|
|
case 'oidc_google':
|
|
return (
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
</svg>
|
|
);
|
|
case 'oidc_github':
|
|
return (
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
);
|
|
case 'oidc_okta':
|
|
return (
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M12 0C5.389 0 0 5.389 0 12s5.389 12 12 12 12-5.389 12-12S18.611 0 12 0zm0 18c-3.314 0-6-2.686-6-6s2.686-6 6-6 6 2.686 6 6-2.686 6-6 6z" />
|
|
</svg>
|
|
);
|
|
case 'oidc_custom':
|
|
default:
|
|
return <KeyRound className="h-4 w-4" strokeWidth={1.5} aria-hidden />;
|
|
}
|
|
}
|
|
|
|
export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
|
const { login, ssoLdapLogin } = useAuth();
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const ssoError = params.get('sso_error');
|
|
if (ssoError) {
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
return ssoError;
|
|
}
|
|
return '';
|
|
});
|
|
// Capture once: returning from a failed OIDC attempt must stay on Login, not bounce again.
|
|
const hadSsoErrorRef = useRef(error.length > 0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local');
|
|
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
|
|
const [localLoginEnabled, setLocalLoginEnabled] = useState(false);
|
|
const [discoveryError, setDiscoveryError] = useState('');
|
|
const [discoveryReady, setDiscoveryReady] = useState(false);
|
|
const [oidcRedirecting, setOidcRedirecting] = useState(false);
|
|
const [capsLock, setCapsLock] = useState(false);
|
|
const [rememberMe, setRememberMe] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const [statusRes, providersRes] = await Promise.all([
|
|
fetch('/api/auth/status', { credentials: 'include' }),
|
|
fetch('/api/auth/sso/providers', { credentials: 'include' }),
|
|
]);
|
|
if (cancelled) return;
|
|
|
|
if (!statusRes.ok) {
|
|
// Fail closed: keep localLoginEnabled false so a status outage never
|
|
// reveals the password form under SSO-only.
|
|
setDiscoveryError('Could not load authentication status. Refresh the page and try again.');
|
|
setDiscoveryReady(true);
|
|
return;
|
|
}
|
|
const status = await statusRes.json() as { localLoginEnabled?: boolean };
|
|
const enabled = status.localLoginEnabled !== false;
|
|
setLocalLoginEnabled(enabled);
|
|
|
|
if (!providersRes.ok) {
|
|
if (!enabled) {
|
|
setDiscoveryError('Could not load identity providers. Refresh the page and try again.');
|
|
}
|
|
setSsoProviders([]);
|
|
setDiscoveryReady(true);
|
|
return;
|
|
}
|
|
const providers = await providersRes.json() as SSOProvider[];
|
|
const list = Array.isArray(providers) ? providers : [];
|
|
const autoUrl = oidcAutoRedirectUrl({
|
|
localLoginEnabled: enabled,
|
|
providers: list,
|
|
hadSsoError: hadSsoErrorRef.current,
|
|
});
|
|
if (autoUrl) {
|
|
if (cancelled) return;
|
|
setOidcRedirecting(true);
|
|
setDiscoveryReady(true);
|
|
window.location.replace(autoUrl);
|
|
return;
|
|
}
|
|
setSsoProviders(list);
|
|
if (!enabled && list.some((p) => p.type === 'ldap')) {
|
|
setLoginMode('ldap');
|
|
}
|
|
if (!enabled && list.length === 0) {
|
|
setDiscoveryError('No identity providers are available. Contact your administrator.');
|
|
}
|
|
setDiscoveryReady(true);
|
|
} catch (e) {
|
|
console.warn('[Login] Auth discovery failed:', e);
|
|
if (!cancelled) {
|
|
setDiscoveryError('Could not load authentication options. Refresh the page and try again.');
|
|
setDiscoveryReady(true);
|
|
}
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const hasLdap = ssoProviders.some((p) => p.type === 'ldap');
|
|
const oidcProviders = ssoProviders.filter((p) => p.type === 'oidc');
|
|
const showPasswordForm = localLoginEnabled || (hasLdap && loginMode === 'ldap');
|
|
const showLocalLdapToggle = localLoginEnabled && hasLdap;
|
|
// Fail closed: under SSO-only, never show the local form after a discovery error.
|
|
const blockLocalFallback = !localLoginEnabled && !!discoveryError;
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!localLoginEnabled && loginMode !== 'ldap') return;
|
|
setError('');
|
|
setIsLoading(true);
|
|
const result =
|
|
loginMode === 'ldap' && ssoLdapLogin
|
|
? await ssoLdapLogin(username, password, rememberMe)
|
|
: await login(username, password, rememberMe);
|
|
if (!result.success) setError(result.error || 'Login failed');
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const handlePasswordKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (typeof e.getModifierState === 'function') {
|
|
setCapsLock(e.getModifierState('CapsLock'));
|
|
}
|
|
};
|
|
|
|
const footerLabel = !discoveryReady
|
|
? 'Console'
|
|
: !localLoginEnabled
|
|
? 'Console · SSO'
|
|
: loginMode === 'ldap'
|
|
? 'Console · LDAP'
|
|
: 'Console · Local';
|
|
|
|
return (
|
|
<div className={cn('relative', className)} {...props}>
|
|
<AuthCanvas
|
|
footer={
|
|
<div className="flex items-center justify-between">
|
|
<span>{footerLabel}</span>
|
|
<span className="text-stat-subtitle/70">Secure by default</span>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-7">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<AuthStepHeader
|
|
kicker="AUTHENTICATE"
|
|
hero="Sign in"
|
|
caption={
|
|
!localLoginEnabled
|
|
? 'Sign in with your identity provider.'
|
|
: loginMode === 'ldap'
|
|
? 'Federated via your directory service.'
|
|
: 'Self-hosted fleet console.'
|
|
}
|
|
/>
|
|
{showLocalLdapToggle && (
|
|
<div className="mt-1 flex overflow-hidden rounded-md border border-card-border">
|
|
<ModePill
|
|
active={loginMode === 'local'}
|
|
label="Local"
|
|
onClick={() => setLoginMode('local')}
|
|
/>
|
|
<ModePill
|
|
active={loginMode === 'ldap'}
|
|
label="LDAP"
|
|
onClick={() => setLoginMode('ldap')}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{discoveryReady && blockLocalFallback && (
|
|
<ErrorRail>{discoveryError}</ErrorRail>
|
|
)}
|
|
|
|
{discoveryReady && oidcRedirecting && (
|
|
<div className="flex items-center justify-center gap-2 text-stat-subtitle">
|
|
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} aria-hidden />
|
|
<span className="font-sans text-sm">Redirecting to your identity provider...</span>
|
|
</div>
|
|
)}
|
|
|
|
{discoveryReady && !blockLocalFallback && !oidcRedirecting && error && !showPasswordForm && (
|
|
<ErrorRail>{error}</ErrorRail>
|
|
)}
|
|
|
|
{discoveryReady && !blockLocalFallback && !oidcRedirecting && showPasswordForm && (
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label
|
|
htmlFor="username"
|
|
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
|
|
>
|
|
Username
|
|
</label>
|
|
<Input
|
|
id="username"
|
|
type="text"
|
|
placeholder="admin"
|
|
required
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className={INPUT_CLASS}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label
|
|
htmlFor="password"
|
|
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
|
|
>
|
|
Password
|
|
</label>
|
|
{capsLock && (
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
|
|
Caps Lock On
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={handlePasswordKey}
|
|
onKeyUp={handlePasswordKey}
|
|
className={INPUT_CLASS}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="remember-me"
|
|
checked={rememberMe}
|
|
onCheckedChange={(c) => setRememberMe(c === true)}
|
|
/>
|
|
<label
|
|
htmlFor="remember-me"
|
|
className="text-sm text-stat-subtitle cursor-pointer select-none"
|
|
>
|
|
Stay signed in
|
|
</label>
|
|
</div>
|
|
|
|
{error && <ErrorRail>{error}</ErrorRail>}
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="animate-spin" strokeWidth={1.5} />
|
|
Signing in
|
|
</>
|
|
) : (
|
|
<>
|
|
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
|
|
<ArrowRight strokeWidth={1.5} />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
|
|
{discoveryReady && !blockLocalFallback && !oidcRedirecting && oidcProviders.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
{showPasswordForm && (
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-px flex-1 bg-card-border" />
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
|
Or continue with
|
|
</span>
|
|
<div className="h-px flex-1 bg-card-border" />
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'grid gap-2',
|
|
oidcProviders.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-2',
|
|
)}
|
|
>
|
|
{oidcProviders.map((p) => (
|
|
<Button
|
|
key={p.provider}
|
|
type="button"
|
|
variant="outline"
|
|
className={cn(
|
|
'h-10 justify-center gap-2 font-sans',
|
|
oidcProviders.length === 1 && 'w-full max-w-[14rem]',
|
|
)}
|
|
onClick={() => {
|
|
window.location.href = `/api/auth/sso/oidc/${p.provider}/authorize`;
|
|
}}
|
|
>
|
|
{getProviderIcon(p.provider)}
|
|
<span className="truncate">{p.displayName}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{discoveryReady && !blockLocalFallback && !oidcRedirecting && !showPasswordForm && oidcProviders.length === 0 && !discoveryError && (
|
|
<ErrorRail>No identity providers are available. Contact your administrator.</ErrorRail>
|
|
)}
|
|
</div>
|
|
</AuthCanvas>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModePill({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'px-3 py-1 font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
|
active ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-stat-value',
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|