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>
175 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|