diff --git a/web/src/App.tsx b/web/src/App.tsx index 898196b7..b3e61394 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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: }, { path: 'login', element: }, + { path: 'register', element: }, { element: , children: [ diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index 312f83b1..134cd3f8 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -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 ? '登录中…' : '登录'} + + 没有账户?马上注册 + ); diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx new file mode 100644 index 00000000..490bcd57 --- /dev/null +++ b/web/src/features/auth/RegisterPage.tsx @@ -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(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 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 ( +
+
+

创建账号

+ {error ? ( +
{error}
+ ) : null} + +
+ setCode(e.target.value)} + placeholder="验证码" + className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" + /> + +
+ setPassword(e.target.value)} + placeholder="密码(至少 8 位)" + className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" + /> + + + 已有账号?返回登录 + +
+
+ ); +} diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts new file mode 100644 index 00000000..212e31e7 --- /dev/null +++ b/web/src/lib/api/auth.ts @@ -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('/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(`/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`, { + method: 'POST', + auth: false, + body: { code }, + }); +} diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts index 813b32b8..27b3d3a2 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 } 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 | null = null; + +export function queueAccExtra(extra: Record) { + 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 { + 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, + }); +} diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index 4632b944..f1329e8a 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -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 }; + code: number; + text?: string; + params?: Record; + } + + export interface CredentialParam { + meth: string; + val?: string; + resp?: string; + params?: Record; + } + export interface CredentialInput { + meth: string; + val?: string; + resp?: string; + params?: Record; } 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; loginBasic(username: string, password: string, cred?: unknown): Promise; @@ -114,6 +137,14 @@ declare module 'tinode-sdk' { getTopic(topicName: string): Topic; account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise; + createAccountBasic( + username: string, + password: string, + params: { public?: TopicDescPublic; tags?: string[]; cred?: CredentialParam[]; attachments?: string[] }, + ): Promise; + + /** 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;