diff --git a/ui/sign-in/src/SignInPage.module.css b/ui/sign-in/src/SignInPage.module.css index 6980085..5fa956f 100644 --- a/ui/sign-in/src/SignInPage.module.css +++ b/ui/sign-in/src/SignInPage.module.css @@ -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)); diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index 14f1419..affaa4e 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -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(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(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 = ( + )} + + +

+ +

+ + )} + + {/* --- Forgot password: verify code + new password --- */} + {mode === 'forgotPasswordVerify' && ( +
+

+ We sent a verification code to {identifier} +

+ + + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + autoFocus + autoComplete="one-time-code" + disabled={loading} + /> + + + +
+ setNewPassword(e.target.value)} + placeholder="At least 8 characters" + autoComplete="new-password" + disabled={loading} + /> + {passwordToggle} +
+
+ + + setConfirmNewPassword(e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + disabled={loading} + /> + + + + +

+ +

+
+ )}