Previous logout fired fetch(end_session, {mode:'no-cors'}), which is a
no-op for OIDC: cross-origin fetch never clears the IdP's session cookie.
Result: subsequent SSO clicks silently re-authenticated the prior user.
New flow:
1. Best-effort POST /auth/logout to bump token_revoked_before.
2. Clear localStorage + Zustand state.
3. Set sessionStorage 'cameleer:signed_out=1' so /login renders a
confirmation splash (mirrors cameleer-saas pattern).
4. window.location.replace(end_session_endpoint?id_token_hint=...
&post_logout_redirect_uri=...&client_id=...) — top-level navigation,
the only form that actually clears the IdP session cookie.
client_id is now persisted at OIDC initiation alongside
end_session_endpoint and id_token, so logout has all three params
without an extra round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.2 KiB
TypeScript
192 lines
7.2 KiB
TypeScript
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';
|
|
|
|
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
|
|
|
|
const SUBTITLES = [
|
|
"Prove you're not a mirage",
|
|
"Only authorized cameleers beyond this dune",
|
|
"Halt, traveler — state your business",
|
|
"The caravan doesn't move without credentials",
|
|
"No hitchhikers on this caravan",
|
|
"This oasis requires a password",
|
|
"Camels remember faces. We use passwords.",
|
|
"You shall not pass... without logging in",
|
|
"The desert is vast. Your session has expired.",
|
|
"Another day, another dune to authenticate",
|
|
"Papers, please. The caravan master is watching.",
|
|
"Trust, but verify — ancient cameleer proverb",
|
|
"Even the Silk Road had checkpoints",
|
|
"Your camel is parked outside. Now identify yourself.",
|
|
"One does not simply walk into the dashboard",
|
|
"The sands shift, but your password shouldn't",
|
|
"Unauthorized access? In this economy?",
|
|
"Welcome back, weary traveler",
|
|
"The dashboard awaits on the other side of this dune",
|
|
"Keep calm and authenticate",
|
|
"Who goes there? Friend or rogue exchange?",
|
|
"Access denied looks the same in every desert",
|
|
"May your routes be green and your tokens valid",
|
|
"Forgot your password? That's between you and the dunes.",
|
|
"No ticket, no caravan",
|
|
];
|
|
|
|
export function LoginPage() {
|
|
const { isAuthenticated, login, loading, error } = useAuthStore();
|
|
const [searchParams] = useSearchParams();
|
|
const forceLocal = searchParams.has('local');
|
|
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [oidcLoading, setOidcLoading] = useState(false);
|
|
|
|
const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
|
|
|
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
|
if (capsLoading) return null;
|
|
|
|
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(' '),
|
|
});
|
|
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}`;
|
|
} catch {
|
|
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
|
|
} finally {
|
|
setOidcLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<Card className={styles.card}>
|
|
<div className={styles.loginForm}>
|
|
<div className={styles.logo}>
|
|
<img src={brandLogo} alt="" className={styles.logoImg} />
|
|
cameleer
|
|
</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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
loading={loading}
|
|
disabled={loading || !username || !password}
|
|
className={styles.submitButton}
|
|
>
|
|
Sign in
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|