import { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { PasswordInput } from '@/features/auth/components/PasswordInput'; import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton'; import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth'; import { ImApiError } from '@/lib/api/client'; import { useSessionStore } from '@/stores/session'; const PHONE_RE = /^\d{11}$/; /** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a * 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently * drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */ function normalizePhoneInput(raw: string): string { const digits = raw.replace(/\D/g, ''); const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits; return withoutCountryCode.slice(0, 11); } export function ResetPasswordPage() { const navigate = useNavigate(); const tenant = useSessionStore((s) => s.tenant); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [challengeId, setChallengeId] = useState(''); const [error, setError] = useState(''); // README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE // field error under the password input, not just the shared top banner every other validation // failure here uses -- this mirrors that without touching the other (phone/code) failure paths. const [passwordFieldError, setPasswordFieldError] = useState(''); const [submitting, setSubmitting] = useState(false); const [toast, setToast] = useState(''); // A challengeId/code pair is only valid for the phone number it was issued for -- without this, // editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that // verifyPasswordResetCode() has no way to cross-check against the NEW phone in the field. useEffect(() => { setChallengeId(''); setCode(''); }, [phone]); if (!tenant) return ; // Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded // to 8 here, matching Register's OWN hardcode by coincidence, not because either ever read the // real value; SecuritySettingsPage's in-place change had drifted to a DIFFERENT hardcoded 6. const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; const submit = async (e: React.FormEvent) => { e.preventDefault(); setPasswordFieldError(''); setError(''); if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号'); if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码'); // Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy // only enforces a minimum length, so the message says exactly that and nothing more). Inline-only // (no setError) -- these two are the password fields' OWN validation, already shown right under // the relevant input; also raising the shared top banner with the identical text duplicated the // same message on screen twice for no reason phone/code's genuinely-separate failures don't share. if (password.length < minPasswordLength) { return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`); } if (password !== confirm) { return setPasswordFieldError('两次输入的密码不一致'); } setSubmitting(true); setError(''); try { const verified = await verifyPasswordResetCode(challengeId, code); await confirmPasswordReset(verified.reset_token, password); setToast('密码修改成功,请重新登录'); window.setTimeout(() => navigate('/login'), 1200); } catch (err) { setError(err instanceof ImApiError ? err.message : '验证码错误或已过期'); setSubmitting(false); } }; return (
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link scaled down -- same pattern as RegisterPage's own min-[900px]:hidden header. */}
重置密码
返回登录

重置密码

通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。

{error ? (
{error}
) : null}
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="短信验证码" inputMode="numeric" className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" />
{ setPassword(v); setPasswordFieldError(''); }} placeholder="新密码" autoComplete="new-password" error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined} maxLength={tenant.passwordPolicy?.maxLength} /> { setConfirm(v); setPasswordFieldError(''); }} placeholder="再次输入新密码" autoComplete="new-password" error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined} maxLength={tenant.passwordPolicy?.maxLength} />
setToast('')} />
); }