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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user