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;
|
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 {
|
.verifyHint {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary, var(--text-muted));
|
color: var(--text-secondary, var(--text-muted));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from './experience-api';
|
} from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
type Mode = 'signIn' | 'register' | 'verifyCode';
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
|
||||||
|
|
||||||
const SIGN_IN_SUBTITLES = [
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
@@ -67,6 +67,7 @@ function getInitialMode(): Mode {
|
|||||||
export function SignInPage() {
|
export function SignInPage() {
|
||||||
const [mode, setMode] = useState<Mode>(getInitialMode);
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||||
|
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
|
||||||
const subtitle = useMemo(
|
const subtitle = useMemo(
|
||||||
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
||||||
[mode === 'signIn' ? 'signIn' : 'register'],
|
[mode === 'signIn' ? 'signIn' : 'register'],
|
||||||
@@ -80,6 +81,8 @@ export function SignInPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [verificationId, setVerificationId] = useState('');
|
const [verificationId, setVerificationId] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
|
|
||||||
// Fetch sign-in experience to check if registration is enabled
|
// Fetch sign-in experience to check if registration is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,6 +92,8 @@ export function SignInPage() {
|
|||||||
const enabled = data.signInMode === 'SignInAndRegister';
|
const enabled = data.signInMode === 'SignInAndRegister';
|
||||||
setRegistrationEnabled(enabled);
|
setRegistrationEnabled(enabled);
|
||||||
if (!enabled && mode !== 'signIn') setMode('signIn');
|
if (!enabled && mode !== 'signIn') setMode('signIn');
|
||||||
|
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
|
||||||
|
setEmailConnectorConfigured(hasEmailConnector);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -100,6 +105,8 @@ export function SignInPage() {
|
|||||||
setMode(next);
|
setMode(next);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmNewPassword('');
|
||||||
setCode('');
|
setCode('');
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setVerificationId('');
|
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 = (
|
const passwordToggle = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -219,6 +279,16 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
{emailConnectorConfigured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.forgotLink}
|
||||||
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -340,6 +410,110 @@ export function SignInPage() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user