Rebuilt tenant/login/register/reset-password to match the design handoff's 01-account screens precisely (previously only loosely approximated), and ported the old app's real business rules instead of guessing: - LoginPage: desktop split branding panel (>=900px), terms-agreement checkbox gating submit, forgot-password link, password visibility toggle, full ctrl-error translation table (lib/tinode/errors.ts, ported from the old app's CTRL_ERROR_TEXT_CN/translateCtrlError -- registration/login go over native WS, not REST, so client.ts's REASON_MESSAGES never applied). - RegisterPage: rebuilt as the real unified username-OR-phone form (not phone-only) -- username optional unless phone is blank, invite code required+validated only on the username-only path, matching the server's actual resolveRegistrationAccount rules. connection.ts's registerAccountByPhone generalized into registerAccount() supporting both paths, including the Tinode.credential(null) crash the old app had to work around on the username-only path. - New ResetPasswordPage wired to /auth/password-reset/* (a different endpoint family than registration's /auth/verification-codes/*). - Shared components: PasswordInput, VerificationCodeButton, Agreement, Toast. - Fixed tinode-sdk.d.ts: the real method is getCurrentUserID(), not the invented getAuthenticatedUserID() -- caught live, login crashed past a successful auth. - Fixed a responsive bug present on all four auth pages: fixed w-[380px] forms overflowed past the viewport edge below ~430px wide. Verified end-to-end against the real backend (api.118im.com, tenant "default"): fresh phone registration, password reset, re-login with the new password, and the username-only path's invite-code rejection all round-tripped correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
105 lines
4.6 KiB
TypeScript
105 lines
4.6 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
|
|
|
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
|
import { Toast } from '@/features/auth/components/Toast';
|
|
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}$/;
|
|
|
|
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('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [toast, setToast] = useState('');
|
|
|
|
if (!tenant) return <Navigate to="/tenant" replace />;
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
|
|
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
|
|
// Matches Register's rule (>= 8, no upper bound -- the backend's checkPasswordPolicy only
|
|
// enforces a minimum length).
|
|
if (password.length < 8) return setError('密码至少 8 位,需包含字母和数字');
|
|
if (password !== confirm) return setError('两次输入的密码不一致');
|
|
|
|
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 items-center justify-center bg-surface-page px-6">
|
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
|
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
|
<span className="material-icons text-[16px]">arrow_back_ios_new</span>
|
|
返回登录
|
|
</Link>
|
|
<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="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{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(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
|
placeholder="请输入手机号"
|
|
inputMode="numeric"
|
|
className="w-full bg-transparent text-[15px] outline-none"
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex 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={setPassword} placeholder="新密码" autoComplete="new-password" />
|
|
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入新密码" autoComplete="new-password" />
|
|
|
|
<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>
|
|
<Toast message={toast} onDone={() => setToast('')} />
|
|
</div>
|
|
);
|
|
}
|