feat(web): full auth screens rework to match design + old-app business rules
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
63c1b84b41
commit
aa1c46ff22
@@ -1,42 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { connect } from '@/lib/tinode/connection';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { Agreement } from '@/features/auth/components/Agreement';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
import { connect, getTinode } from '@/lib/tinode/connection';
|
||||
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* ctrl.code/text -> Chinese message. Ported from the old app's loginErrorMessage() -- the SDK's
|
||||
* own login promise rejects with an Error whose `.code` is the ctrl code (401/404/500/...), not
|
||||
* with an ImApiError, since this is the native WS protocol, not the /v1/im REST layer.
|
||||
*/
|
||||
function loginErrorMessage(err: unknown): string {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code === 401) return '账号或密码错误';
|
||||
if (code === 404) return '账号不存在';
|
||||
if (code && code >= 500) return '服务器繁忙,请稍后重试';
|
||||
return (err as Error)?.message || '登录失败,请重试';
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const tinode = useTinode();
|
||||
const tinode = getTinode();
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Real login failures (wrong password, disabled account, ...) clear the password and keep the
|
||||
// account, matching the product spec -- ported from the old app's own effect for this.
|
||||
useEffect(() => {
|
||||
if (error) setPassword('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error]);
|
||||
|
||||
if (!tenant) {
|
||||
// No tenant resolved yet this session -- can't build a valid {hi} packet without it.
|
||||
return <Navigate to="/tenant" replace />;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password || submitting) return;
|
||||
if (!login.trim() || !password || !agreed || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -45,9 +42,7 @@ export function LoginPage() {
|
||||
}
|
||||
const ctrl = await tinode.loginBasic(login.trim(), password);
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
// Account created but needs credential confirmation (email/SMS code) -- full flow lands
|
||||
// with the register/credential-confirm screens later in Phase 1.
|
||||
setError('账号需要先完成验证,此流程正在实现中');
|
||||
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||
return;
|
||||
}
|
||||
const authToken = tinode.getAuthToken();
|
||||
@@ -62,37 +57,61 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-4">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<input
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder="用户名或手机号"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || 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>
|
||||
<Link to="/register" className="text-center text-sm text-accent no-underline">
|
||||
没有账户?马上注册
|
||||
</Link>
|
||||
</form>
|
||||
<div className="flex h-dvh w-full">
|
||||
<div className="relative hidden flex-1 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
|
||||
<div className="absolute left-16 top-10 flex items-center gap-2.5">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white text-lg font-bold text-accent">M</div>
|
||||
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||
</div>
|
||||
<h2 className="m-0 max-w-md text-4xl leading-tight font-semibold text-balance">
|
||||
企业内部沟通,安全、即时、多端同步。
|
||||
</h2>
|
||||
<p className="mt-4 max-w-md text-base text-white/80">
|
||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center bg-white px-6">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<Link to="/tenant" 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">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<input
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder="请输入用户名/手机号"
|
||||
autoComplete="username"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="请输入密码" autoComplete="current-password" />
|
||||
<Link to="/reset-password" className="-mt-2 self-end text-sm text-accent no-underline">
|
||||
忘记密码
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || !agreed || 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>
|
||||
<p className="m-0 text-center text-sm text-text-tertiary-1">
|
||||
没有账户?{' '}
|
||||
<Link to="/register" className="text-accent no-underline">
|
||||
马上注册
|
||||
</Link>
|
||||
</p>
|
||||
<Agreement checked={agreed} onChange={setAgreed} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { requestVerificationCode, verifyRegisterCode } from '@/lib/api/auth';
|
||||
import { Agreement } from '@/features/auth/components/Agreement';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
||||
import { verifyRegisterCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { registerAccountByPhone } from '@/lib/tinode/connection';
|
||||
import { getTinode, registerAccount } from '@/lib/tinode/connection';
|
||||
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
function toE164(phone: string): string {
|
||||
return phone.startsWith('+') ? phone : `+86${phone}`;
|
||||
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
phone?: string;
|
||||
code?: string;
|
||||
password?: string;
|
||||
invite?: string;
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
@@ -16,114 +28,170 @@ export function RegisterPage() {
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [invite, setInvite] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [resendIn, setResendIn] = useState(0);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
|
||||
const phoneValid = /^1\d{10}$/.test(phone);
|
||||
|
||||
const sendCode = async () => {
|
||||
if (!phoneValid || sendingCode || resendIn > 0) return;
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await requestVerificationCode('register', phone);
|
||||
setChallengeId(res.challenge_id);
|
||||
setResendIn(res.resend_after_seconds);
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setResendIn((s) => {
|
||||
if (s <= 1) {
|
||||
window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
const usingPhone = !!phone;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!phoneValid || !code || password.length < 8 || !challengeId || submitting) return;
|
||||
const next: FormErrors = {};
|
||||
if (!username && !phone) next.username = '用户名和手机号至少填写一项';
|
||||
if (username && !USERNAME_RE.test(username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
|
||||
if (usingPhone) {
|
||||
if (!PHONE_RE.test(phone)) next.phone = '请输入 11 位手机号';
|
||||
if (!challengeId) next.code = '请先获取验证码';
|
||||
else if (!/^\d{6}$/.test(code)) next.code = '请输入 6 位验证码';
|
||||
} else if (!INVITE_RE.test(invite)) {
|
||||
next.invite = '用户名注册需填写 5 位字母邀请码';
|
||||
}
|
||||
if (password.length < 8) next.password = '密码至少 8 位';
|
||||
setErrors(next);
|
||||
setFormError('');
|
||||
if (Object.keys(next).length || !agreed || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
let verificationToken: string | undefined;
|
||||
try {
|
||||
const { verification_token } = await verifyRegisterCode(challengeId, code);
|
||||
const phoneE164 = toE164(phone);
|
||||
const ctrl = await registerAccountByPhone({
|
||||
phoneE164,
|
||||
if (usingPhone) {
|
||||
const verified = await verifyRegisterCode(challengeId, code);
|
||||
verificationToken = verified.verification_token;
|
||||
}
|
||||
} catch (err) {
|
||||
setErrors((e) => ({ ...e, code: err instanceof ImApiError ? err.message : '验证码错误或已过期' }));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ctrl = await registerAccount({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
verificationToken: verification_token,
|
||||
challengeId,
|
||||
inviteCode: invite,
|
||||
verificationToken,
|
||||
challengeId: usingPhone ? challengeId : undefined,
|
||||
});
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
setError('账号已创建,但还需要完成验证,此流程正在实现中');
|
||||
setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||
return;
|
||||
}
|
||||
setMyUserId(ctrl.params?.user as string | null);
|
||||
const authToken = getTinode().getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : (err as Error)?.message || '注册失败,请稍后重试');
|
||||
setFormError(registerErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-4">
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">创建账号</h1>
|
||||
{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">
|
||||
<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="手机号"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="验证码"
|
||||
className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendCode}
|
||||
disabled={!phoneValid || sendingCode || resendIn > 0}
|
||||
className="w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{resendIn > 0 ? `重新获取 (${resendIn}s)` : '获取验证码'}
|
||||
</button>
|
||||
<div className="flex h-dvh w-full items-center justify-center overflow-y-auto bg-surface-page px-6 py-8">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<div className="mb-1 flex flex-col items-center gap-3">
|
||||
<div className="grid h-[56px] w-[56px] place-items-center rounded-2xl bg-accent text-2xl font-bold text-white">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-xl font-bold text-text-heading">{tenant.name}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码(至少 8 位)"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
||||
/>
|
||||
<h1 className="m-0 text-[22px] font-bold text-text-title">创建账号</h1>
|
||||
{formError ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{formError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10))}
|
||||
placeholder="用户名(1-10位小写字母或数字)"
|
||||
autoComplete="username"
|
||||
className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${
|
||||
errors.username ? 'border border-danger-border' : ''
|
||||
}`}
|
||||
/>
|
||||
{errors.username ? <div className="text-xs text-danger-border">{errors.username}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-text-tertiary-3">
|
||||
<div className="h-px flex-1 bg-border-row" />
|
||||
或
|
||||
<div className="h-px flex-1 bg-border-row" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className={`flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
|
||||
errors.phone ? 'border border-danger-border' : 'has-focus:border-focus-border'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{errors.phone ? <div className="text-xs text-danger-border">{errors.phone}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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 ${
|
||||
errors.code ? 'border border-danger-border' : ''
|
||||
}`}
|
||||
/>
|
||||
<VerificationCodeButton
|
||||
phone={phone}
|
||||
scene="register"
|
||||
onChallenge={setChallengeId}
|
||||
onError={(msg) => setErrors((e) => ({ ...e, code: msg }))}
|
||||
/>
|
||||
</div>
|
||||
{errors.code ? <div className="text-xs text-danger-border">{errors.code}</div> : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="设置密码" autoComplete="new-password" error={errors.password} />
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */}
|
||||
<input
|
||||
value={invite}
|
||||
onChange={(e) => setInvite(e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5))}
|
||||
placeholder="邀请码(5位英文字母,区分大小写)"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${
|
||||
errors.invite ? 'border border-danger-border' : ''
|
||||
}`}
|
||||
/>
|
||||
{errors.invite ? <div className="text-xs text-danger-border">{errors.invite}</div> : null}
|
||||
</div>
|
||||
|
||||
{!usingPhone ? (
|
||||
<p className="m-0 text-xs text-text-tertiary-3">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!phoneValid || !code || password.length < 8 || submitting}
|
||||
disabled={(!username && !phone) || !password || !agreed || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '注册中…' : '注册'}
|
||||
@@ -131,6 +199,7 @@ export function RegisterPage() {
|
||||
<Link to="/login" className="text-center text-sm text-accent no-underline">
|
||||
已有账号?返回登录
|
||||
</Link>
|
||||
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同MeetingIM" />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,8 @@ export function TenantEntryPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-5">
|
||||
<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-5">
|
||||
<div className="mb-2 flex flex-col items-center gap-3">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">
|
||||
M
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
|
||||
interface AgreementProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export function Agreement({ checked, onChange, prefix = `同意${BASE_APP_NAME}` }: AgreementProps) {
|
||||
return (
|
||||
<label className="flex items-start gap-2 text-[13px] text-text-tertiary-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 accent-accent"
|
||||
/>
|
||||
<span>
|
||||
{prefix}{' '}
|
||||
<a href="#agreement" onClick={(e) => e.preventDefault()} className="text-accent no-underline">
|
||||
《用户协议》
|
||||
</a>{' '}
|
||||
和{' '}
|
||||
<a href="#privacy" onClick={(e) => e.preventDefault()} className="text-accent no-underline">
|
||||
《隐私政策》
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
autoComplete?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function PasswordInput({ value, onChange, placeholder, autoComplete, error }: PasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={`flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
|
||||
error ? 'border border-danger-border' : 'has-focus:border-focus-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
className="text-text-placeholder-1"
|
||||
>
|
||||
<Icon name={visible ? 'visibility' : 'visibility_off'} className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="text-xs text-danger-border">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { TOAST_DURATION } from '@/lib/config';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, onDone }: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(onDone, TOAST_DURATION);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onDone]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-8 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-[#1f2329] px-4 py-3 text-sm text-white shadow-modal"
|
||||
>
|
||||
<Icon name="check_circle" className="text-[18px] text-success" />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { requestVerificationCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
|
||||
interface VerificationCodeButtonProps {
|
||||
phone: string;
|
||||
scene: 'register' | 'reset_password';
|
||||
onChallenge: (challengeId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
|
||||
export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const tick = (initial: number) => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setSeconds(initial);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const click = async () => {
|
||||
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await requestVerificationCode(scene, phone);
|
||||
onChallenge(data.challenge_id);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
} catch (err) {
|
||||
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
|
||||
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
|
||||
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
|
||||
if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds);
|
||||
if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={click}
|
||||
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
|
||||
className="w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user