Files
cameleer-saas/ui/sign-in/src/SignInPage.tsx
hsiegeln 0cfa359fc5
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 44s
fix(sign-in): detect register mode from URL path, not just query param
Newer Logto versions redirect to /register?app_id=... instead of
/sign-in?first_screen=register. Check the pathname in addition to
the query param so the registration form shows correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:57 +02:00

328 lines
11 KiB
TypeScript

import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { signIn, startRegistration, completeRegistration } from './experience-api';
import styles from './SignInPage.module.css';
type Mode = 'signIn' | 'register' | 'verifyCode';
const SIGN_IN_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",
];
const REGISTER_SUBTITLES = [
"Every great journey starts with a single sign-up",
"Welcome to the caravan — let's get you registered",
"A new cameleer approaches the oasis",
"Join the caravan. We have dashboards.",
"The desert is better with company",
"First time here? The camels don't bite.",
"Pack your bags, you're joining the caravan",
"Room for one more on this caravan",
"New rider? Excellent. Credentials, please.",
"The Silk Road awaits — just need your email first",
];
function pickRandom(arr: string[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function getInitialMode(): Mode {
const params = new URLSearchParams(window.location.search);
if (params.get('first_screen') === 'register') return 'register';
if (window.location.pathname.endsWith('/register')) return 'register';
return 'signIn';
}
export function SignInPage() {
const [mode, setMode] = useState<Mode>(getInitialMode);
const subtitle = useMemo(
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
[mode === 'signIn' ? 'signIn' : 'register'],
);
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [code, setCode] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verificationId, setVerificationId] = useState('');
// Reset error when switching modes
useEffect(() => { setError(null); }, [mode]);
const switchMode = (next: Mode) => {
setMode(next);
setPassword('');
setConfirmPassword('');
setCode('');
setShowPassword(false);
setVerificationId('');
};
// --- Sign-in ---
const handleSignIn = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const redirectTo = await signIn(identifier, password);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed');
setLoading(false);
}
};
// --- Register step 1: send verification code ---
const handleRegister = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!identifier.includes('@')) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
const vId = await startRegistration(identifier);
setVerificationId(vId);
setMode('verifyCode');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
// --- Register step 2: verify code + complete ---
const handleVerifyCode = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
};
const passwordToggle = (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={styles.passwordToggle}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
);
return (
<div className={styles.page}>
<Card className={styles.card}>
<div className={styles.formContainer}>
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
Cameleer
</div>
<p className={styles.subtitle}>{subtitle}</p>
{error && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
{/* --- Sign-in form --- */}
{mode === 'signIn' && (
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
<FormField label="Email or username" htmlFor="login-identifier">
<Input
id="login-identifier"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@company.com"
autoFocus
autoComplete="username"
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password">
<div className={styles.passwordWrapper}>
<Input
id="login-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier || !password}
className={styles.submitButton}
>
Sign in
</Button>
<p className={styles.switchText}>
Don't have an account?{' '}
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
Sign up
</button>
</p>
</form>
)}
{/* --- Register form --- */}
{mode === 'register' && (
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
<FormField label="Email" htmlFor="register-email">
<Input
id="register-email"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@company.com"
autoFocus
autoComplete="email"
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="register-password">
<div className={styles.passwordWrapper}>
<Input
id="register-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<FormField label="Confirm password" htmlFor="register-confirm">
<Input
id="register-confirm"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier || !password || !confirmPassword}
className={styles.submitButton}
>
Create account
</Button>
<p className={styles.switchText}>
Already have an account?{' '}
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
Sign in
</button>
</p>
</form>
)}
{/* --- Verification code form --- */}
{mode === 'verifyCode' && (
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
<p className={styles.verifyHint}>
We sent a verification code to <strong>{identifier}</strong>
</p>
<FormField label="Verification code" htmlFor="verify-code">
<Input
id="verify-code"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
autoComplete="one-time-code"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || code.length < 6}
className={styles.submitButton}
>
Verify & create account
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
Back
</button>
</p>
</form>
)}
</div>
</Card>
</div>
);
}