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:
co-authored by
Claude Sonnet 5
parent
8cdd84152d
commit
63c1b84b41
@@ -5,6 +5,7 @@ import { AppShell } from '@/app/AppShell';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { RegisterPage } from '@/features/auth/RegisterPage';
|
||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||
import { ConversationView } from '@/features/messages/ConversationView';
|
||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||
@@ -15,6 +16,7 @@ const queryClient = new QueryClient();
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { imPost, imRequest } from './client';
|
||||
|
||||
export interface VerificationCodeResponse {
|
||||
challenge_id: string;
|
||||
resend_after_seconds: number;
|
||||
}
|
||||
|
||||
export function requestVerificationCode(scene: 'register' | 'reset_password', phone: string) {
|
||||
return imPost<VerificationCodeResponse>('/auth/verification-codes', { scene, phone }, { auth: false });
|
||||
}
|
||||
|
||||
export interface VerifyCodeResponse {
|
||||
verification_token: string;
|
||||
}
|
||||
|
||||
/** No Idempotency-Key here, matching the old app's im-api.js -- a plain code check, not a mutation. */
|
||||
export function verifyRegisterCode(challengeId: string, code: string) {
|
||||
return imRequest<VerifyCodeResponse>(`/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`, {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { code },
|
||||
});
|
||||
}
|
||||
@@ -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 } from 'tinode-sdk';
|
||||
import { Tinode, type LoginCtrl } from 'tinode-sdk';
|
||||
|
||||
import { API_KEY, APP_NAME, LOGGING_ENABLED } from '@/lib/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
@@ -25,18 +25,32 @@ function setStatus(next: ConnectionStatus) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The server extends the stock {hi} packet with a `tenant` field (server/datamodel.go) that the
|
||||
* vendored JS SDK has no API for. `#initPacket`/the connection's packet builder is private, so the
|
||||
* only usable hook is wrapping the connection's raw sendText and injecting the field into the
|
||||
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is
|
||||
* a real backend protocol extension, not an old-codebase-specific workaround.
|
||||
* Registration-only extra fields (username/phone/invite_code) queued by registerAccount() below,
|
||||
* consumed the next time an {acc} packet actually goes out. A module-level slot, not a per-call
|
||||
* argument, because the SDK's own createAccountBasic() has no parameter for them at all -- same
|
||||
* one-shot-queue shape as the old app's `tinode.__pendingAccExtra`.
|
||||
*/
|
||||
let pendingAccExtra: Record<string, unknown> | null = null;
|
||||
|
||||
export function queueAccExtra(extra: Record<string, unknown>) {
|
||||
pendingAccExtra = extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server extends two stock packets with fields the vendored JS SDK has no API for
|
||||
* (server/datamodel.go): {hi}'s `tenant` (resolved via POST /v1/im/tenants/resolve) and {acc}'s
|
||||
* `username`/`phone`/`invite_code` (used by server/user.go's replyCreateUser during registration).
|
||||
* The packet builder is private, so the only usable hook is wrapping the connection's raw sendText
|
||||
* and injecting fields into the already-serialized JSON. Ported as-is from the old app's
|
||||
* tnSetup/installTenantHiPatch -- this is a real backend protocol extension, not an old-codebase-
|
||||
* specific workaround.
|
||||
*/
|
||||
function installTenantHiPatch(t: Tinode) {
|
||||
const conn = t._connection;
|
||||
if (!conn || conn.__tenantPatched) return;
|
||||
const original = conn.sendText.bind(conn);
|
||||
conn.sendText = (msg: unknown) => {
|
||||
if (typeof msg === 'string' && msg.indexOf('"hi":') >= 0) {
|
||||
if (typeof msg === 'string' && (msg.indexOf('"hi":') >= 0 || msg.indexOf('"acc":') >= 0)) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.hi) {
|
||||
@@ -45,6 +59,10 @@ function installTenantHiPatch(t: Tinode) {
|
||||
parsed.hi.tenant = tenant.code;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} else if (parsed.acc && pendingAccExtra) {
|
||||
Object.assign(parsed.acc, pendingAccExtra);
|
||||
pendingAccExtra = null;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON -- send as is.
|
||||
@@ -105,3 +123,45 @@ export function subscribeConnectionStatus(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
export interface RegisterPhoneParams {
|
||||
phoneE164: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
verificationToken: string;
|
||||
challengeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone-based registration (product Ch.1.4's phone path). Two DIFFERENT phone representations are
|
||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed
|
||||
* live 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)
|
||||
* must equal what resolveRegistrationAccount computes as secretAccount -- for phone
|
||||
* registration that's the E.164-normalized phone, e.g. "+8613900005232".
|
||||
* - 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
|
||||
* number with no "+86" prefix, e.g. "13900005232". Sending the E.164 form here fails that
|
||||
* length check immediately, which is exactly the bug this comment is warning future edits away
|
||||
* from reintroducing.
|
||||
*/
|
||||
export async function registerAccountByPhone({
|
||||
phoneE164,
|
||||
password,
|
||||
displayName,
|
||||
verificationToken,
|
||||
challengeId,
|
||||
}: RegisterPhoneParams): Promise<LoginCtrl> {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const phoneBare = phoneE164.replace(/^\+86/, '');
|
||||
queueAccExtra({ phone: phoneBare });
|
||||
const cred = Tinode.credential({ meth: 'tel', val: phoneE164, resp: verificationToken, params: { challenge_id: challengeId } });
|
||||
const trimmedName = displayName?.trim();
|
||||
return t.createAccountBasic(phoneE164, password, {
|
||||
public: trimmedName ? { fn: trimmedName } : undefined,
|
||||
cred: cred ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+33
-2
@@ -19,8 +19,31 @@ declare module 'tinode-sdk' {
|
||||
|
||||
export type AuthLevel = 'anon' | 'auth' | 'root';
|
||||
|
||||
/**
|
||||
* login()/loginBasic()/loginToken()/createAccountBasic() all resolve with this shape directly --
|
||||
* NOT wrapped in an outer `{ctrl: ...}` key, confirmed against the SDK's own source (the resolved
|
||||
* value is literally the promise executor's `ctrl` parameter, one level flatter than the *wire*
|
||||
* message's `{"ctrl": {...}}` envelope). Getting this wrong throws "Cannot read properties of
|
||||
* undefined" on the very first successful (non-throwing) call, since `.ctrl` doesn't exist -- hit
|
||||
* live registering a real account before this type was corrected.
|
||||
*/
|
||||
export interface LoginCtrl {
|
||||
ctrl: { code: number; text?: string; params?: Record<string, unknown> };
|
||||
code: number;
|
||||
text?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CredentialParam {
|
||||
meth: string;
|
||||
val?: string;
|
||||
resp?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
export interface CredentialInput {
|
||||
meth: string;
|
||||
val?: string;
|
||||
resp?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AccessMode {
|
||||
@@ -103,7 +126,7 @@ declare module 'tinode-sdk' {
|
||||
disconnect(): void;
|
||||
isConnected(): boolean;
|
||||
isAuthenticated(): boolean;
|
||||
getAuthenticatedUserID(): string | undefined;
|
||||
getCurrentUserID(): string;
|
||||
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
loginBasic(username: string, password: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
@@ -114,6 +137,14 @@ declare module 'tinode-sdk' {
|
||||
getTopic(topicName: string): Topic;
|
||||
|
||||
account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise<unknown>;
|
||||
createAccountBasic(
|
||||
username: string,
|
||||
password: string,
|
||||
params: { public?: TopicDescPublic; tags?: string[]; cred?: CredentialParam[]; attachments?: string[] },
|
||||
): Promise<LoginCtrl>;
|
||||
|
||||
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
|
||||
static credential(cred: CredentialInput): CredentialParam[] | null;
|
||||
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: (err?: Error) => void;
|
||||
|
||||
Reference in New Issue
Block a user