diff --git a/web/src/App.tsx b/web/src/App.tsx
index b3e61394..410899a7 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -6,6 +6,7 @@ import { RequireAuth } from '@/app/RequireAuth';
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
import { LoginPage } from '@/features/auth/LoginPage';
import { RegisterPage } from '@/features/auth/RegisterPage';
+import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
import { ConversationView } from '@/features/messages/ConversationView';
import { EmptyMain } from '@/features/messages/EmptyMain';
@@ -17,6 +18,7 @@ const router = createBrowserRouter([
{ path: 'tenant', element: },
{ path: 'login', element: },
{ path: 'register', element: },
+ { path: 'reset-password', element: },
{
element: ,
children: [
diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx
index 134cd3f8..36fe2bb6 100644
--- a/web/src/features/auth/LoginPage.tsx
+++ b/web/src/features/auth/LoginPage.tsx
@@ -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 ;
}
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 (
-
-
+
+
+
+
+
+ 企业内部沟通,安全、即时、多端同步。
+
+
+ 单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
+
+
+
+
);
}
diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx
index 490bcd57..f74fe835 100644
--- a/web/src/features/auth/RegisterPage.tsx
+++ b/web/src/features/auth/RegisterPage.tsx
@@ -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
({});
+ const [formError, setFormError] = useState('');
const [submitting, setSubmitting] = useState(false);
- const [error, setError] = useState('');
- const timerRef = useRef(undefined);
if (!tenant) return ;
- 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 (
-