Files
im_webapp/web/src/features/auth/ResetPasswordPage.tsx
T
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00

175 lines
8.3 KiB
TypeScript

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 <Navigate to="/tenant" replace />;
// 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 (
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
{/* 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. */}
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</Link>
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
</div>
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回登录
</Link>
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
<p className="m-0 text-sm text-text-tertiary-1">
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
</p>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="请输入手机号"
type="tel"
inputMode="numeric"
autoComplete="tel"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<div className="flex items-center gap-2">
<input
value={code}
onChange={(e) => 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"
/>
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
</div>
<PasswordInput
value={password}
onChange={(v) => {
setPassword(v);
setPasswordFieldError('');
}}
placeholder="新密码"
autoComplete="new-password"
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<PasswordInput
value={confirm}
onChange={(v) => {
setConfirm(v);
setPasswordFieldError('');
}}
placeholder="再次输入新密码"
autoComplete="new-password"
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<button
type="submit"
disabled={!phone || !code || !password || !confirm || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '提交中…' : '确认重置'}
</button>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}