diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8447cc85..a13108c3 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -10,6 +10,7 @@ import { RequireAuth } from '@/app/RequireAuth';
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
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 { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
@@ -62,6 +63,7 @@ function RouteErrorBoundary() {
const router = createBrowserRouter([
{ path: 'tenant', element: },
{ path: 'login', element: },
+ { path: 'register', element: },
{ path: 'reset-password', element: },
{ path: 'enterprise-register', element: },
{ path: 'feedback', element: },
diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx
index 239893fd..034da3c6 100644
--- a/web/src/features/auth/LoginPage.tsx
+++ b/web/src/features/auth/LoginPage.tsx
@@ -112,6 +112,9 @@ export function LoginPage() {
>
{submitting ? '登录中…' : '登录'}
+
+ 还没有账号?注册账号
+
diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx
new file mode 100644
index 00000000..9f276785
--- /dev/null
+++ b/web/src/features/auth/RegisterPage.tsx
@@ -0,0 +1,157 @@
+import { useRef, useState } from 'react';
+
+import { Link, Navigate, useNavigate } from 'react-router-dom';
+
+import { Icon } from '@/app/Icon';
+import { PasswordInput } from '@/features/auth/components/PasswordInput';
+import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
+import { verifyRegistrationCode } from '@/lib/api/auth';
+import { apiBase, ImApiError } from '@/lib/api/client';
+import { getTinode, registerBasic } from '@/lib/tinode/connection';
+import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
+import { useSessionStore } from '@/stores/session';
+
+const USERNAME_RE = /^[a-z0-9]{3,10}$/;
+const PHONE_RE = /^\d{11}$/;
+const CODE_RE = /^(?:\d{6}|[A-Za-z]{5})$/;
+
+function normalizePhone(raw: string): string {
+ const digits = raw.replace(/\D/g, '');
+ return (digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits).slice(0, 11);
+}
+
+export function RegisterPage() {
+ const navigate = useNavigate();
+ 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 [challengeId, setChallengeId] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [agreed, setAgreed] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState('');
+ const verifiedRef = useRef<{ key: string; response: string } | null>(null);
+ const submitRef = useRef(false);
+
+ if (!tenant) return ;
+ const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
+ const legalOrigin = new URL(apiBase(), window.location.href).origin;
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (submitRef.current) return;
+ setError('');
+ if (!username && !phone) return setError('请填写用户名或手机号');
+ if (username && !USERNAME_RE.test(username)) return setError('用户名须为 3~10 位小写字母或数字');
+ if (phone && !PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
+ if (phone && (!challengeId || !CODE_RE.test(code))) return setError('请先获取验证码并填写 6 位短信验证码或 5 位邀请码');
+ if (password.length < minPasswordLength || (tenant.passwordPolicy?.maxLength && password.length > tenant.passwordPolicy.maxLength)) {
+ return setError(`密码长度须为 ${minPasswordLength}~${tenant.passwordPolicy?.maxLength ?? 64} 位`);
+ }
+ if (password !== confirm) return setError('两次输入的密码不一致');
+ if (!agreed) return setError('请先阅读并同意用户条款与隐私政策');
+
+ submitRef.current = true;
+ setSubmitting(true);
+ try {
+ let credential: { meth: string; val: string; resp: string; params: { challenge_id: string } } | undefined;
+ if (phone) {
+ const key = `${phone}:${challengeId}:${code}`;
+ let response = verifiedRef.current?.key === key ? verifiedRef.current.response : '';
+ if (!response) {
+ const verified = await verifyRegistrationCode(challengeId, code);
+ response = verified.verification_mode === 'invite_code' ? code : verified.verification_token || '';
+ if (!response) throw new Error('验证结果无效,请重新获取验证码');
+ verifiedRef.current = { key, response };
+ }
+ credential = { meth: 'tel', val: phone, resp: response, params: { challenge_id: challengeId } };
+ }
+ const ctrl = await registerBasic({ username, phone, password, tenantCode: tenant.code, credential });
+ if (ctrl.code >= 300) {
+ setError(ctrl.text === 'validate credentials'
+ ? validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)
+ : '注册失败,请检查资料后重试');
+ return;
+ }
+ const tinode = getTinode();
+ const token = tinode.getAuthToken()?.token;
+ const userId = tinode.getCurrentUserID();
+ if (!token || !userId) throw new Error('账号已创建,请返回登录页登录');
+ sessionStorage.setItem('im-session-token', token);
+ setMyUserId(userId);
+ navigate('/messages', { replace: true });
+ } catch (err) {
+ setError(err instanceof ImApiError ? err.message : loginErrorMessage(err));
+ } finally {
+ submitRef.current = false;
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ 返回登录
+
+ {tenant.name}
+
+
+
+
+
+ );
+}
diff --git a/web/src/features/auth/components/VerificationCodeButton.tsx b/web/src/features/auth/components/VerificationCodeButton.tsx
index 02f99dcd..ece658cf 100644
--- a/web/src/features/auth/components/VerificationCodeButton.tsx
+++ b/web/src/features/auth/components/VerificationCodeButton.tsx
@@ -7,16 +7,18 @@ const PHONE_RE = /^\d{11}$/;
interface VerificationCodeButtonProps {
phone: string;
- scene: 'reset_password';
+ scene: 'reset_password' | 'register';
+ tenantCode?: string;
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) {
+export function VerificationCodeButton({ phone, scene, tenantCode, onChallenge, onError }: VerificationCodeButtonProps) {
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const timerRef = useRef(undefined);
+ const phoneRef = useRef(phone);
// Without this, navigating away mid-countdown (e.g. switching from register back to login)
// leaves the interval running against an unmounted component indefinitely until it naturally
@@ -27,6 +29,7 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
// counting down for the old number) until the old timer happens to run out on its own.
useEffect(() => {
+ phoneRef.current = phone;
window.clearInterval(timerRef.current);
setSeconds(0);
}, [phone]);
@@ -55,12 +58,12 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
const requestedPhone = phone;
setBusy(true);
try {
- const data = await requestVerificationCode(scene, requestedPhone);
- if (requestedPhone !== phone) return;
+ const data = await requestVerificationCode(scene, requestedPhone, tenantCode);
+ if (requestedPhone !== phoneRef.current) return;
onChallenge(data.challenge_id);
tick(data.resend_after_seconds || 60);
} catch (err) {
- if (requestedPhone !== phone) return;
+ if (requestedPhone !== phoneRef.current) return;
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 | undefined) : undefined;
diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts
index 42819778..97c24218 100644
--- a/web/src/lib/api/auth.ts
+++ b/web/src/lib/api/auth.ts
@@ -16,8 +16,25 @@ export interface VerificationCodeResponse {
resend_after_seconds: number;
}
-export function requestVerificationCode(scene: 'reset_password', phone: string) {
- return imPost('/auth/verification-codes', { scene, phone }, { auth: false });
+export function requestVerificationCode(scene: 'reset_password' | 'register', phone: string, tenantCode?: string) {
+ return imPost(
+ '/auth/verification-codes',
+ scene === 'register' ? { scene, phone, registration_type: 0, tenant_code: tenantCode } : { scene, phone },
+ { auth: false },
+ );
+}
+
+export interface RegistrationVerifyResponse {
+ verification_token?: string;
+ verification_mode?: 'invite_code';
+}
+
+export function verifyRegistrationCode(challengeId: string, code: string) {
+ return imPost(
+ `/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`,
+ { code },
+ { auth: false },
+ );
}
export interface PasswordResetVerifyResponse {
diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts
index 3891c242..f4450ed4 100644
--- a/web/src/lib/tinode/connection.ts
+++ b/web/src/lib/tinode/connection.ts
@@ -7,7 +7,7 @@
// silently stomp on each other. This module claims all the SDK's callback slots exactly once and
// fans them out to any number of subscribers via its own Set, which is what hooks.ts's
// useSyncExternalStore-based hooks actually subscribe to.
-import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk';
+import { Tinode, type CredentialParam, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk';
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
import { isTenantMismatch } from '@/lib/tinode/errors';
@@ -166,6 +166,7 @@ export function clearKickedInfo() {
let sessionTenantCode: string | null = null;
/** One-shot override used by loginBasic's FORBIDDEN self-heal retry (see there). */
let forcedLoginType: 0 | 1 | null = null;
+let pendingRegistration: { username: string; phone: string; tenantCode: string } | null = null;
function installOutgoingPacketPatches(t: Tinode) {
const conn = t._connection;
@@ -188,6 +189,19 @@ function installOutgoingPacketPatches(t: Tinode) {
} catch {
// Not valid JSON -- send as is.
}
+ } else if (typeof msg === 'string' && pendingRegistration && msg.indexOf('"acc":') >= 0) {
+ try {
+ const parsed = JSON.parse(msg);
+ if (parsed.acc?.user === 'new') {
+ parsed.acc.username = pendingRegistration.username;
+ parsed.acc.phone = pendingRegistration.phone;
+ parsed.acc.registration_type = 0;
+ parsed.acc.tenant_code = pendingRegistration.tenantCode;
+ msg = JSON.stringify(parsed);
+ }
+ } catch {
+ // Not valid JSON -- send as is.
+ }
} else if (typeof msg === 'string' && msg.indexOf('"login":') >= 0) {
try {
const parsed = JSON.parse(msg);
@@ -403,3 +417,32 @@ export function loginBasic(login: string, password: string): Promise
});
return loginPromise;
}
+
+let registrationPromise: Promise | null = null;
+
+export function registerBasic(input: {
+ username: string;
+ phone: string;
+ password: string;
+ tenantCode: string;
+ credential?: CredentialParam;
+}): Promise {
+ if (registrationPromise) return registrationPromise;
+ registrationPromise = (async () => {
+ await connect();
+ const t = getTinode();
+ const account = input.username || `+86${input.phone}`;
+ pendingRegistration = { username: input.username, phone: input.phone, tenantCode: input.tenantCode };
+ try {
+ return await t.createAccountBasic(account, input.password, {
+ public: { fn: input.username || account },
+ cred: input.credential ? [input.credential] : [],
+ });
+ } finally {
+ pendingRegistration = null;
+ }
+ })().finally(() => {
+ registrationPromise = null;
+ });
+ return registrationPromise;
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 1827747e..cb3eb038 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -29,6 +29,7 @@ export default defineConfig({
proxy: {
'/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
'/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
+ '/legal': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
},
},
})