Behind a reverse proxy with strip-prefix (e.g., Traefik at /server/), the OIDC redirect_uri must include the prefix so the callback routes back through the proxy. Now uses config.basePath (from <base href>) instead of hardcoding '/'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|
import { Navigate } from 'react-router';
|
|
import { useAuthStore } from './auth-store';
|
|
import { api } from '../api/client';
|
|
import { config } from '../config';
|
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
|
import styles from './LoginPage.module.css';
|
|
|
|
interface OidcInfo {
|
|
clientId: string;
|
|
authorizationEndpoint: string;
|
|
}
|
|
|
|
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 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);
|
|
|
|
useEffect(() => {
|
|
api.GET('/auth/oidc/config')
|
|
.then(({ data }) => {
|
|
if (data?.authorizationEndpoint && data?.clientId) {
|
|
setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint });
|
|
if (data.endSessionEndpoint) {
|
|
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
if (isAuthenticated) return <Navigate to="/" replace />;
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
e.preventDefault();
|
|
login(username, password);
|
|
};
|
|
|
|
const handleOidcLogin = () => {
|
|
if (!oidc) return;
|
|
setOidcLoading(true);
|
|
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
|
|
const params = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: oidc.clientId,
|
|
redirect_uri: redirectUri,
|
|
scope: 'openid email profile',
|
|
});
|
|
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
|
|
};
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<Card className={styles.card}>
|
|
<div className={styles.loginForm}>
|
|
<div className={styles.logo}>
|
|
<img src="/favicon.svg" alt="" className={styles.logoImg} />
|
|
cameleer3
|
|
</div>
|
|
<p className={styles.subtitle}>{subtitle}</p>
|
|
|
|
{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>
|
|
</>
|
|
)}
|
|
|
|
<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>
|
|
);
|
|
}
|