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
@@ -6,6 +6,7 @@ import { RequireAuth } from '@/app/RequireAuth';
|
|||||||
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
||||||
import { LoginPage } from '@/features/auth/LoginPage';
|
import { LoginPage } from '@/features/auth/LoginPage';
|
||||||
import { RegisterPage } from '@/features/auth/RegisterPage';
|
import { RegisterPage } from '@/features/auth/RegisterPage';
|
||||||
|
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
|
||||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||||
import { ConversationView } from '@/features/messages/ConversationView';
|
import { ConversationView } from '@/features/messages/ConversationView';
|
||||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||||
@@ -17,6 +18,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||||
{ path: 'login', element: <LoginPage /> },
|
{ path: 'login', element: <LoginPage /> },
|
||||||
{ path: 'register', element: <RegisterPage /> },
|
{ path: 'register', element: <RegisterPage /> },
|
||||||
|
{ path: 'reset-password', element: <ResetPasswordPage /> },
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { connect } from '@/lib/tinode/connection';
|
import { Agreement } from '@/features/auth/components/Agreement';
|
||||||
import { useTinode } from '@/lib/tinode/hooks';
|
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';
|
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() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const tenant = useSessionStore((s) => s.tenant);
|
const tenant = useSessionStore((s) => s.tenant);
|
||||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||||
const tinode = useTinode();
|
const tinode = getTinode();
|
||||||
const [login, setLogin] = useState('');
|
const [login, setLogin] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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) {
|
if (!tenant) {
|
||||||
// No tenant resolved yet this session -- can't build a valid {hi} packet without it.
|
|
||||||
return <Navigate to="/tenant" replace />;
|
return <Navigate to="/tenant" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!login.trim() || !password || submitting) return;
|
if (!login.trim() || !password || !agreed || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@@ -45,9 +42,7 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
const ctrl = await tinode.loginBasic(login.trim(), password);
|
const ctrl = await tinode.loginBasic(login.trim(), password);
|
||||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||||
// Account created but needs credential confirmation (email/SMS code) -- full flow lands
|
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||||
// with the register/credential-confirm screens later in Phase 1.
|
|
||||||
setError('账号需要先完成验证,此流程正在实现中');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const authToken = tinode.getAuthToken();
|
const authToken = tinode.getAuthToken();
|
||||||
@@ -62,37 +57,61 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
<div className="flex h-dvh w-full">
|
||||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-4">
|
<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">
|
||||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
<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%)]" />
|
||||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
<div className="absolute left-16 top-10 flex items-center gap-2.5">
|
||||||
{error ? (
|
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white text-lg font-bold text-accent">M</div>
|
||||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||||
) : null}
|
</div>
|
||||||
<input
|
<h2 className="m-0 max-w-md text-4xl leading-tight font-semibold text-balance">
|
||||||
value={login}
|
企业内部沟通,安全、即时、多端同步。
|
||||||
onChange={(e) => setLogin(e.target.value)}
|
</h2>
|
||||||
placeholder="用户名或手机号"
|
<p className="mt-4 max-w-md text-base text-white/80">
|
||||||
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"
|
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
|
||||||
/>
|
</p>
|
||||||
<input
|
</div>
|
||||||
type="password"
|
|
||||||
value={password}
|
<div className="flex flex-1 items-center justify-center bg-white px-6">
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||||
placeholder="密码"
|
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
||||||
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"
|
<span className="material-icons text-[16px]">arrow_back_ios_new</span>
|
||||||
/>
|
切换企业
|
||||||
<button
|
</Link>
|
||||||
type="submit"
|
<div className="flex flex-col gap-1">
|
||||||
disabled={!login.trim() || !password || submitting}
|
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||||
>
|
</div>
|
||||||
{submitting ? '登录中…' : '登录'}
|
{error ? (
|
||||||
</button>
|
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||||
<Link to="/register" className="text-center text-sm text-accent no-underline">
|
) : null}
|
||||||
没有账户?马上注册
|
<input
|
||||||
</Link>
|
value={login}
|
||||||
</form>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
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 { 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';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
function toE164(phone: string): string {
|
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
|
||||||
return phone.startsWith('+') ? phone : `+86${phone}`;
|
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() {
|
export function RegisterPage() {
|
||||||
@@ -16,114 +28,170 @@ export function RegisterPage() {
|
|||||||
const tenant = useSessionStore((s) => s.tenant);
|
const tenant = useSessionStore((s) => s.tenant);
|
||||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [invite, setInvite] = useState('');
|
||||||
const [challengeId, setChallengeId] = useState('');
|
const [challengeId, setChallengeId] = useState('');
|
||||||
const [resendIn, setResendIn] = useState(0);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [sendingCode, setSendingCode] = useState(false);
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const timerRef = useRef<number | undefined>(undefined);
|
|
||||||
|
|
||||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||||
|
|
||||||
const phoneValid = /^1\d{10}$/.test(phone);
|
const usingPhone = !!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 submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setSubmitting(true);
|
||||||
setError('');
|
let verificationToken: string | undefined;
|
||||||
try {
|
try {
|
||||||
const { verification_token } = await verifyRegisterCode(challengeId, code);
|
if (usingPhone) {
|
||||||
const phoneE164 = toE164(phone);
|
const verified = await verifyRegisterCode(challengeId, code);
|
||||||
const ctrl = await registerAccountByPhone({
|
verificationToken = verified.verification_token;
|
||||||
phoneE164,
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setErrors((e) => ({ ...e, code: err instanceof ImApiError ? err.message : '验证码错误或已过期' }));
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ctrl = await registerAccount({
|
||||||
|
username,
|
||||||
|
phone,
|
||||||
password,
|
password,
|
||||||
verificationToken: verification_token,
|
inviteCode: invite,
|
||||||
challengeId,
|
verificationToken,
|
||||||
|
challengeId: usingPhone ? challengeId : undefined,
|
||||||
});
|
});
|
||||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||||
setError('账号已创建,但还需要完成验证,此流程正在实现中');
|
setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||||
return;
|
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');
|
navigate('/messages');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ImApiError ? err.message : (err as Error)?.message || '注册失败,请稍后重试');
|
setFormError(registerErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
<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-[380px] flex-col gap-4">
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||||
<h1 className="m-0 text-[28px] font-bold text-text-title">创建账号</h1>
|
<div className="mb-1 flex flex-col items-center gap-3">
|
||||||
{error ? (
|
<div className="grid h-[56px] w-[56px] place-items-center rounded-2xl bg-accent text-2xl font-bold text-white">
|
||||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
M
|
||||||
) : null}
|
</div>
|
||||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5">
|
<strong className="text-xl font-bold text-text-heading">{tenant.name}</strong>
|
||||||
<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>
|
</div>
|
||||||
<input
|
<h1 className="m-0 text-[22px] font-bold text-text-title">创建账号</h1>
|
||||||
type="password"
|
{formError ? (
|
||||||
value={password}
|
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{formError}</div>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
) : null}
|
||||||
placeholder="密码(至少 8 位)"
|
|
||||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
<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
|
<button
|
||||||
type="submit"
|
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"
|
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{submitting ? '注册中…' : '注册'}
|
{submitting ? '注册中…' : '注册'}
|
||||||
@@ -131,6 +199,7 @@ export function RegisterPage() {
|
|||||||
<Link to="/login" className="text-center text-sm text-accent no-underline">
|
<Link to="/login" className="text-center text-sm text-accent no-underline">
|
||||||
已有账号?返回登录
|
已有账号?返回登录
|
||||||
</Link>
|
</Link>
|
||||||
|
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同MeetingIM" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
<div className="flex h-dvh w-full items-center justify-center bg-surface-page px-6">
|
||||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-5">
|
<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="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">
|
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">
|
||||||
M
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,3 +21,28 @@ export function verifyRegisterCode(challengeId: string, code: string) {
|
|||||||
body: { code },
|
body: { code },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetVerifyResponse {
|
||||||
|
reset_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset verifies through an entirely different endpoint family than registration
|
||||||
|
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though both share the same
|
||||||
|
* `/auth/verification-codes` code-request endpoint via `scene`. No Idempotency-Key, same reasoning
|
||||||
|
* as verifyRegisterCode.
|
||||||
|
*/
|
||||||
|
export function verifyPasswordResetCode(challengeId: string, code: string) {
|
||||||
|
return imRequest<PasswordResetVerifyResponse>(
|
||||||
|
`/auth/password-reset/challenges/${encodeURIComponent(challengeId)}/verify`,
|
||||||
|
{ method: 'POST', auth: false, body: { code } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmPasswordReset(resetToken: string, newPassword: string) {
|
||||||
|
return imPost<void>(
|
||||||
|
'/auth/password-reset/confirm',
|
||||||
|
{ reset_token: resetToken, new_password: newPassword },
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,43 +124,62 @@ export function subscribeConnectionStatus(callback: () => void): () => void {
|
|||||||
return () => listeners.delete(callback);
|
return () => listeners.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterPhoneParams {
|
export interface RegisterParams {
|
||||||
phoneE164: string;
|
/** `''` when omitted -- valid to leave blank as long as `phone` is filled. */
|
||||||
|
username: string;
|
||||||
|
/** Bare 11-digit phone, no "+86" prefix. `''` on the username-only path (no phone at all). */
|
||||||
|
phone: string;
|
||||||
password: string;
|
password: string;
|
||||||
displayName?: string;
|
/** `''` when omitted. Required (and validated) by the caller on the username-only path. */
|
||||||
verificationToken: string;
|
inviteCode: string;
|
||||||
challengeId: string;
|
/** Only meaningful when `phone` is non-empty -- from verifyRegisterCode()'s response. */
|
||||||
|
verificationToken?: string;
|
||||||
|
challengeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phone-based registration (product Ch.1.4's phone path). Two DIFFERENT phone representations are
|
* Unified username-or-phone registration (product Ch.1.4). Two DIFFERENT phone representations are
|
||||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed
|
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed live
|
||||||
* live against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
* against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
||||||
* - `login_` (this function's first createAccountBasic arg, becomes the secret's account half)
|
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
|
||||||
* must equal what resolveRegistrationAccount computes as secretAccount -- for phone
|
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
|
||||||
* registration that's the E.164-normalized phone, e.g. "+8613900005232".
|
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
|
||||||
|
* also typed, the username itself takes precedence (server/user.go: username!="" && phone!=""
|
||||||
|
* => account=username), matching the old app's fixed bug where a typed username used to get
|
||||||
|
* silently dropped on the phone path.
|
||||||
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
||||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit
|
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
|
||||||
* number with no "+86" prefix, e.g. "13900005232". Sending the E.164 form here fails that
|
* with no "+86" prefix.
|
||||||
* length check immediately, which is exactly the bug this comment is warning future edits away
|
*
|
||||||
* from reintroducing.
|
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
|
||||||
|
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
|
||||||
|
* shape, and `typeof null === 'object'` is true in JS, so passing it a real `null` crashes with
|
||||||
|
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
|
||||||
|
* at all (confirmed by reading the old app's own history of this exact bug).
|
||||||
*/
|
*/
|
||||||
export async function registerAccountByPhone({
|
export async function registerAccount({
|
||||||
phoneE164,
|
username,
|
||||||
|
phone,
|
||||||
password,
|
password,
|
||||||
displayName,
|
inviteCode,
|
||||||
verificationToken,
|
verificationToken,
|
||||||
challengeId,
|
challengeId,
|
||||||
}: RegisterPhoneParams): Promise<LoginCtrl> {
|
}: RegisterParams): Promise<LoginCtrl> {
|
||||||
const t = getTinode();
|
const t = getTinode();
|
||||||
if (!t.isConnected()) {
|
if (!t.isConnected()) {
|
||||||
await connect();
|
await connect();
|
||||||
}
|
}
|
||||||
const phoneBare = phoneE164.replace(/^\+86/, '');
|
const usingPhone = !!phone;
|
||||||
queueAccExtra({ phone: phoneBare });
|
queueAccExtra({ username: username || '', phone: phone || '', invite_code: inviteCode || '' });
|
||||||
const cred = Tinode.credential({ meth: 'tel', val: phoneE164, resp: verificationToken, params: { challenge_id: challengeId } });
|
|
||||||
const trimmedName = displayName?.trim();
|
const secretAccount = usingPhone ? username || `+86${phone}` : username;
|
||||||
return t.createAccountBasic(phoneE164, password, {
|
const cred =
|
||||||
|
usingPhone && verificationToken && challengeId
|
||||||
|
? Tinode.credential({ meth: 'tel', val: `+86${phone}`, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const trimmedName = username.trim();
|
||||||
|
return t.createAccountBasic(secretAccount, password, {
|
||||||
public: trimmedName ? { fn: trimmedName } : undefined,
|
public: trimmedName ? { fn: trimmedName } : undefined,
|
||||||
cred: cred ?? undefined,
|
cred: cred ?? undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Tinode WS {ctrl} error translation. Registration/login go over the native WS protocol, not
|
||||||
|
// REST, so client.ts's REASON_MESSAGES table (REST-only) never sees these -- the SDK's own
|
||||||
|
// CommError text is raw English with a status-code suffix baked in ("authentication failed (401)"),
|
||||||
|
// confirmed live against the real backend. Ported from the old app's tinode-web.jsx (CTRL_ERROR_TEXT_CN
|
||||||
|
// / translateCtrlError / loginErrorMessage), not guessed -- this exact table is what made login/register
|
||||||
|
// error copy match the product spec instead of leaking SDK internals.
|
||||||
|
const CTRL_ERROR_TEXT_CN: Record<string, string> = {
|
||||||
|
'authentication failed': '账号或密码错误',
|
||||||
|
'authentication required': '请先登录',
|
||||||
|
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
|
||||||
|
'policy violation': '当前操作不符合安全策略要求',
|
||||||
|
'permission denied': '没有权限执行此操作,请重新登录',
|
||||||
|
'internal error': '服务器繁忙,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
function translateCtrlError(err: unknown): string | null {
|
||||||
|
const raw = (err as Error)?.message;
|
||||||
|
if (!raw) return null;
|
||||||
|
const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase();
|
||||||
|
return CTRL_ERROR_TEXT_CN[normalized] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginErrorMessage(err: unknown): string {
|
||||||
|
const known = translateCtrlError(err);
|
||||||
|
if (known) return known;
|
||||||
|
const code = (err as { code?: number })?.code;
|
||||||
|
if (code === 401 || code === 403) return '账号或密码错误';
|
||||||
|
return (err as Error)?.message || '登录失败,请重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerErrorMessage(err: unknown): string {
|
||||||
|
const known = translateCtrlError(err);
|
||||||
|
return known || (err as Error)?.message || '注册失败,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only INVITE_CODE_REQUIRED/INVALID were confirmed against a live server; REVOKED/EXPIRED are
|
||||||
|
* inferred siblings under the same naming pattern (not individually exercised).
|
||||||
|
*/
|
||||||
|
const INVITE_CODE_REASON_TEXT_CN: Record<string, string> = {
|
||||||
|
INVITE_CODE_REQUIRED: '请填写邀请码',
|
||||||
|
INVITE_CODE_INVALID: '邀请码错误,请重新输入',
|
||||||
|
INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码',
|
||||||
|
INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ValidateCredentialsParams {
|
||||||
|
reason?: string;
|
||||||
|
cred?: string[];
|
||||||
|
token?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created, now
|
||||||
|
* confirm a real credential (email/SMS code)" -- ctrl.params.cred is a non-empty array in that case.
|
||||||
|
* This backend reuses the exact same code/text pair for its own invite-code rejections, which carry
|
||||||
|
* `reason` instead of `cred`. This app's registration flow always pre-verifies the phone credential
|
||||||
|
* over REST before calling createAccountBasic, so a genuine pending-credential case here would mean
|
||||||
|
* an account created some other way (e.g. by an admin) -- there's no credential-confirmation screen
|
||||||
|
* built yet for that (out of scope for now), so it gets an honest, terminal message rather than a
|
||||||
|
* silent dead end.
|
||||||
|
*/
|
||||||
|
export function validateCredentialsMessage(params: ValidateCredentialsParams | undefined): string {
|
||||||
|
if (Array.isArray(params?.cred) && params.cred.length) {
|
||||||
|
return '账号存在未确认的验证信息,请联系企业管理员处理';
|
||||||
|
}
|
||||||
|
const reason = params?.reason;
|
||||||
|
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user