feat(web): phone registration + fix login/register response handling

- Add RegisterPage (phone + SMS code) and lib/api/auth.ts verification-code
  endpoints, wired into App.tsx routing.
- Fix registerAccountByPhone(): the injected acc.phone extra field must be
  bare 11-digit, not E.164 -- server checks digit length before normalizing.
- Fix LoginCtrl shape: SDK's login/loginBasic/createAccountBasic promises
  resolve with the ctrl fields flat, not wrapped in {ctrl: {...}}.
- Fix tinode-sdk.d.ts: the real method is getCurrentUserID(), there is no
  getAuthenticatedUserID().

All three bugs found and fixed via live testing against the real backend
(api.118im.com, tenant "default"), confirming registration and login both
work end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 00:51:06 +08:00
co-authored by Claude Sonnet 5
parent 8cdd84152d
commit 63c1b84b41
6 changed files with 268 additions and 12 deletions
+6 -3
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { connect } from '@/lib/tinode/connection';
import { useTinode } from '@/lib/tinode/hooks';
@@ -43,7 +43,7 @@ export function LoginPage() {
if (!tinode.isConnected()) {
await connect();
}
const { ctrl } = await tinode.loginBasic(login.trim(), password);
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.
@@ -52,7 +52,7 @@ export function LoginPage() {
}
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getAuthenticatedUserID() ?? null);
setMyUserId(tinode.getCurrentUserID() || null);
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
@@ -89,6 +89,9 @@ export function LoginPage() {
>
{submitting ? '登录中…' : '登录'}
</button>
<Link to="/register" className="text-center text-sm text-accent no-underline">
没有账户?马上注册
</Link>
</form>
</div>
);
+137
View File
@@ -0,0 +1,137 @@
import { useRef, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { requestVerificationCode, verifyRegisterCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { registerAccountByPhone } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
function toE164(phone: string): string {
return phone.startsWith('+') ? phone : `+86${phone}`;
}
export function RegisterPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [challengeId, setChallengeId] = useState('');
const [resendIn, setResendIn] = useState(0);
const [sendingCode, setSendingCode] = useState(false);
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 submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!phoneValid || !code || password.length < 8 || !challengeId || submitting) return;
setSubmitting(true);
setError('');
try {
const { verification_token } = await verifyRegisterCode(challengeId, code);
const phoneE164 = toE164(phone);
const ctrl = await registerAccountByPhone({
phoneE164,
password,
verificationToken: verification_token,
challengeId,
});
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
setError('账号已创建,但还需要完成验证,此流程正在实现中');
return;
}
setMyUserId(ctrl.params?.user as string | null);
navigate('/messages');
} catch (err) {
setError(err instanceof ImApiError ? err.message : (err as Error)?.message || '注册失败,请稍后重试');
} 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>
<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"
/>
<button
type="submit"
disabled={!phoneValid || !code || password.length < 8 || 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="/login" className="text-center text-sm text-accent no-underline">
已有账号?返回登录
</Link>
</form>
</div>
);
}