feat: add forgot-password UI flow to custom sign-in page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 13:42:51 +02:00
parent 08a3ad03b7
commit 1f3a9551c5
2 changed files with 192 additions and 1 deletions

View File

@@ -96,6 +96,23 @@
opacity: 0.8;
}
.forgotLink {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 13px;
padding: 0;
text-decoration: underline;
text-align: right;
align-self: flex-end;
margin-top: -8px;
}
.forgotLink:hover {
color: var(--text-link, #C6820E);
}
.verifyHint {
font-size: 14px;
color: var(--text-secondary, var(--text-muted));

View File

@@ -10,7 +10,7 @@ import {
} from './experience-api';
import styles from './SignInPage.module.css';
type Mode = 'signIn' | 'register' | 'verifyCode';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage",
@@ -67,6 +67,7 @@ function getInitialMode(): Mode {
export function SignInPage() {
const [mode, setMode] = useState<Mode>(getInitialMode);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
const subtitle = useMemo(
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
[mode === 'signIn' ? 'signIn' : 'register'],
@@ -80,6 +81,8 @@ export function SignInPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verificationId, setVerificationId] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
// Fetch sign-in experience to check if registration is enabled
useEffect(() => {
@@ -89,6 +92,8 @@ export function SignInPage() {
const enabled = data.signInMode === 'SignInAndRegister';
setRegistrationEnabled(enabled);
if (!enabled && mode !== 'signIn') setMode('signIn');
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
setEmailConnectorConfigured(hasEmailConnector);
})
.catch(() => {});
}, []);
@@ -100,6 +105,8 @@ export function SignInPage() {
setMode(next);
setPassword('');
setConfirmPassword('');
setNewPassword('');
setConfirmNewPassword('');
setCode('');
setShowPassword(false);
setVerificationId('');
@@ -161,6 +168,59 @@ export function SignInPage() {
}
};
// --- Forgot password step 1: send reset code ---
const handleForgotPassword = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!identifier.includes('@')) {
setError('Please enter your email address');
return;
}
setLoading(true);
try {
const vId = await startForgotPassword(identifier);
setVerificationId(vId);
setMode('forgotPasswordVerify');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send reset code');
} finally {
setLoading(false);
}
};
// --- Forgot password step 2: verify code + set new password ---
const handleForgotPasswordVerify = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== confirmNewPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
// Send security notification email (fire-and-forget)
fetch('/platform/api/password-reset-notification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: identifier }),
}).catch(() => {});
// Reset to sign-in with success message
switchMode('signIn');
setError(null);
setIdentifier(identifier); // preserve email for convenience
alert('Password reset successful. Please sign in with your new password.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Password reset failed');
} finally {
setLoading(false);
}
};
const passwordToggle = (
<button
type="button"
@@ -219,6 +279,16 @@ export function SignInPage() {
</div>
</FormField>
{emailConnectorConfigured && (
<button
type="button"
className={styles.forgotLink}
onClick={() => { setError(null); setMode('forgotPassword'); }}
>
Forgot password?
</button>
)}
<Button
variant="primary"
type="submit"
@@ -340,6 +410,110 @@ export function SignInPage() {
</p>
</form>
)}
{/* --- Forgot password: email entry --- */}
{mode === 'forgotPassword' && (
<form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
<p className={styles.verifyHint}>
Enter your email address and we'll send you a code to reset your password.
</p>
<FormField label="Email" htmlFor="forgot-email">
<Input
id="forgot-email"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@company.com"
autoFocus
autoComplete="email"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier}
className={styles.submitButton}
>
Send reset code
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
Back to sign in
</button>
</p>
</form>
)}
{/* --- Forgot password: verify code + new password --- */}
{mode === 'forgotPasswordVerify' && (
<form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
<p className={styles.verifyHint}>
We sent a verification code to <strong>{identifier}</strong>
</p>
<FormField label="Verification code" htmlFor="forgot-code">
<Input
id="forgot-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>
<FormField label="New password" htmlFor="forgot-new-password">
<div className={styles.passwordWrapper}>
<Input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<FormField label="Confirm new password" htmlFor="forgot-confirm-password">
<Input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
className={styles.submitButton}
>
Reset password
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
Back
</button>
</p>
</form>
)}
</div>
</Card>
</div>