diff --git a/.project-docs/30-worklog/tasks/20260819-makelore-native-login-1f3a9c.md b/.project-docs/30-worklog/tasks/20260819-makelore-native-login-1f3a9c.md new file mode 100644 index 0000000..1b22101 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-makelore-native-login-1f3a9c.md @@ -0,0 +1,128 @@ +# Task: Implement Makelore native password and SMS login + +## Identity + +- Task ID: 20260819-makelore-native-login-1f3a9c +- Mode: Feature +- Branch: codex/20260819-makelore-native-login-1f3a9c-makelore-native-login +- Worktree: D:\w\makelore-native-login-1f3a9c +- Base commit: abece81fdb178711d4dcbe1f87b987f54d71d6dd +- Owner: codex-root +- Status: Ready for integration + +## Scope + +- Replace the renderer-triggered browser authorization flow with native password + and SMS-code login that uses the existing Works Square authentication facade. +- Preserve Main-owned session persistence, runtime cleanup, token refresh, + logout, and module-access projection behavior. +- Add focused Main, store, and login-page regression coverage; keep README's + current-product description aligned with the new login surface. + +## Intent And Constraints + +- Renderer must use the existing Host API abstraction and never receive a + refresh token or call Works Square directly. +- Password and SMS initial login must go through Works Square `/api/auth/*`, + so server-owned OAuth credentials, encryption, and SMS client configuration + remain server-side. +- Retain the existing fail-closed cross-account runtime cleanup before a new + login; do not weaken the seven-day inactivity/session ownership model. +- Do not change the established Main refresh/logout protocol until the + OneFeel-facing Works Square proxy contract has been separately verified. +- Browser authorization routes may be retired only after code search confirms + there is no remaining client dependency; Works Square desktop broker APIs + are outside this task. + +## Plan + +1. Replace Main's legacy direct OneFeel password flow with bounded proxies for + password login, captcha image, SMS send, and SMS login, committing the + managed session through the existing session service. +2. Replace the Renderer browser-login action with password and SMS actions + that consume only the safe Host API response and retain module-access + hydration semantics. +3. Replace the browser-login page with the Web-aligned password/SMS tabs, + agreement gate, captcha lifecycle, SMS cooldown, safe error display, and + controlled help links. +4. Run focused tests, typecheck, lint/build, a final independent review, and + task-scoped documentation checks. + +## Outcome + +- Replaced the retired browser-authorization entry point with native password + and SMS-code login in the Makelore client. +- The Renderer now uses only Host API routes. Main proxies initial-login, + captcha, SMS, and public-link requests through Works Square, commits the + refresh token only to Main-owned secure persistence, and returns only the + safe token/session projection to the Renderer. +- Preserved the existing managed-runtime cleanup, module-access hydration, + refresh, logout, and seven-day inactivity behaviors. The Main refresh/logout + transport remains on its existing direct protocol pending separately verified + Works Square lifecycle proxy support. +- Retired the local browser-start route and UI. The native page implements + password/SMS tabs, agreement and configured help links, UUID/no-store PNG + captcha handling, stale-response rejection, fresh captcha on every SMS-tab + entry, SMS cooldown, and model-config synchronization rollback on failure. +- Removed the Renderer-controlled logout gateway after independent review found + that it could leak the Main-held bearer token. Logout now accepts only an + access-token field and always targets the configured trusted gateway. +- Produced the requested local Windows x64 installer from this native-login + worktree: `release\\Makelore-2.0.0-win-x64.exe`. It is intentionally + unsigned and unpublished; no workstation release credential was included. +- The package includes a validated local OpenMAIC learner-player artifact built + in a separate disposable worktree at commit + `58d1ddc2644f4c8631d62eb0e5423365af95ef66`. Its HTML checksum is + `748d6d7c74d9d0ba444e0c051a50010f7070e7d6e46442b1e60f1e2be80fd020`. +- GitHub repeatedly timed out while fetching the pinned uv `0.10.0` archive. + The local package therefore uses an already installed, version-verified + Windows `uv 0.10.9`; the resulting runtime was checked in the package + verifier and this is not a formal release substitute. + +## Verification + +- `pnpm exec vitest run --reporter=dot` — passed: 183 files, 2,177 tests. +- Focused login/session suite — passed: 5 files, 95 tests. +- `pnpm run typecheck` — passed. +- `pnpm run lint:check` — passed with 7 pre-existing warnings and no errors. +- `pnpm run build:vite` — passed (existing chunk-size and mixed-import notices + only). +- `node ./node_modules/@playwright/test/cli.js test app-smoke.spec.ts --grep + "can skip setup and open the native login surface" --reporter=line` — passed: + 1 desktop Electron smoke test. +- Independent final review — PASS after the logout-target security fix; + reviewer traced Renderer-to-Works login/session/logout flows and verified the + attacker-controlled `authBase` regression. +- `git diff --check` — passed. +- `pnpm run package:stage:win-x64` with + `MAKELORE_LEARNING_PLAYER_ARTIFACT` set to the isolated artifact — passed; + Makelore accepted and copied the verified player resource. +- `node scripts/run-electron-builder.mjs --win --publish never` — passed and + produced `release\\Makelore-2.0.0-win-x64.exe`. +- `pnpm run verify:artifact:win -- --allow-dirty --installer + release\\Makelore-2.0.0-win-x64.exe` — passed. It verified Electron 43.4.0, + Node 24.18.1, OpenCode 1.18.9, Python, uv 0.10.9, npm 11.6.2, the bundled + player, and required native modules. +- Installer SHA-256: + `EFA94BAED02A89A2BFCD8BB74B1B914C481419FBD10302D4606EDC5183ED67B7` + (302,496,674 bytes / 288.48 MiB). Authenticode status: `NotSigned`. + +## Follow-ups + +- Before release, perform a staging/production smoke test using real password + and SMS login: captcha load/refresh, SMS send/cooldown, login, `/me`, app + restart/session refresh, logout, and all configured legal/recovery links. +- Verify and then migrate Main refresh/logout traffic to a Works Square proxy + only after the OneFeel lifecycle contract is confirmed. Do not alter the + current direct Main-owned path speculatively. +- Retire the legacy Works Square `/api/auth/desktop/*` broker only in a + separately planned compatibility window for older desktop clients. +- A signed or published release must rebuild using the release-controlled + pinned uv input and a retained/versioned OpenMAIC player artifact, then pass + the normal signing, provenance, and production smoke-test gates. + +## Promotion Candidates + +- No canonical project-document promotion is needed. The current product + boundary is reflected in README; the full Works Square refresh/logout proxy + decision remains pending protocol evidence. diff --git a/README.md b/README.md index 3655835..0e41195 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览和下载 Works 课程,也可以从需求、材料与多媒体选项发起后台单课生成;下载后的 frozen 课程包由内置 OpenMAIC production Stage 播放。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 @@ -94,7 +94,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`;请求先经 Main-owned IPC,再由兼容 Host API 路由处理。只有真正需要 URL 的资源和流会把 loopback 地址暴露给 Renderer。 - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成;所有 stream、watcher、poller、loopback server 与子进程必须登记到模块活动和任务租约,不允许页面自行创建无托管后台任务。 -- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;未打包开发版只在内存持有凭据,Main 重启后需要重新登录,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 +- Works Square 原生密码与短信登录均沿 Renderer → Host API → Electron Main → Works Square 链路完成。登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复登录,连续 7 天未使用才清除会话并要求重新登录。刷新凭据始终只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;未打包开发版只在内存持有凭据,Main 重启后需要重新登录,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 现有的短效公开 access-token 会话快照与持久化保持不变(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 - AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理,Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index 404631e..c3574a8 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -1,6 +1,4 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import { createCipheriv } from 'node:crypto'; -import { shell } from 'electron'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; @@ -27,22 +25,23 @@ import { logger } from '../../utils/logger'; import type { WorksSquareTokenPayload } from '../../services/works-square-session'; import { normalizeModuleAccess } from '../../../shared/module-access'; -type AuthClientInput = { - authBase?: unknown; - clientId?: unknown; - clientSecret?: unknown; -}; - -type PasswordLoginInput = AuthClientInput & { +type PasswordLoginInput = { username?: unknown; password?: unknown; +}; + +type MobileLoginInput = { + phone?: unknown; code?: unknown; - randomStr?: unknown; - scope?: unknown; +}; + +type MobileCodeInput = { + phone?: unknown; + imageRandomStr?: unknown; + imageCode?: unknown; }; type LogoutInput = { - authBase?: unknown; accessToken?: unknown; }; @@ -58,20 +57,10 @@ type SessionRefreshInput = { forceRefresh?: unknown; }; -type DesktopAuthStartPayload = { - request_id?: unknown; - device_secret?: unknown; - authorize_url?: unknown; - poll_interval_seconds?: unknown; -}; - -type DesktopAuthTokenPayload = { - status?: unknown; - token?: unknown; -}; - -const DESKTOP_AUTH_TIMEOUT_MS = 10 * 60 * 1000; const MAX_AUTH_ERROR_LENGTH = 180; +const MAX_CAPTCHA_IMAGE_BYTES = 1024 * 1024; +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { @@ -80,10 +69,6 @@ function readRequiredString(value: unknown, field: string): string { return value.trim(); } -function readOptionalString(value: unknown, fallback: string): string { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; -} - function readOptionalTrimmedString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } @@ -107,8 +92,8 @@ function withoutRefreshToken(payload: unknown): unknown { return publicPayload; } -function normalizeAuthBase(value: unknown = NIANCODE_AUTH_CONFIG.gatewayAuthUrl): string { - const authBase = readOptionalString(value, NIANCODE_AUTH_CONFIG.gatewayAuthUrl).replace(/\/+$/, ''); +function normalizeAuthBase(): string { + const authBase = NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, ''); if (!/^https?:\/\//i.test(authBase)) { throw new Error('authBase must start with http:// or https://'); } @@ -127,34 +112,6 @@ function createWorksUrl(pathname: string): URL { return new URL(`${normalizeWorksBase()}${pathname}`); } -function createBasicAuthHeader(clientId: string, clientSecret: string): string { - return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; -} - -function createTokenRequestBody(params: Record): URLSearchParams { - const body = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - body.set(key, value); - } - return body; -} - -function encryptPasswordForTokenEndpoint(password: string, encodeKey: string): string { - const key = Buffer.from(encodeKey, 'utf8'); - if (![16, 24, 32].includes(key.length)) { - throw new Error('Auth password encode key must be 16, 24, or 32 bytes'); - } - const cipher = createCipheriv(`aes-${key.length * 8}-cfb`, key, key); - return Buffer.concat([ - cipher.update(password, 'utf8'), - cipher.final(), - ]).toString('base64'); -} - -function shouldEncryptPasswordForClient(clientId: string): boolean { - return clientId !== 'customPC'; -} - async function readResponsePayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; @@ -180,7 +137,7 @@ function getErrorMessage(payload: unknown, fallback: string): string { if (payload && typeof payload === 'object') { const record = payload as Record; - for (const field of ['msg', 'message', 'error_description', 'error']) { + for (const field of ['detail', 'msg', 'message', 'error_description', 'error']) { const value = record[field]; if (typeof value === 'string' && value.trim()) { return compact(value); @@ -193,113 +150,101 @@ function getErrorMessage(payload: unknown, fallback: string): string { return fallback; } -function readRequiredPayloadString( - payload: Record, - field: keyof DesktopAuthStartPayload, -): string { - return readRequiredString(payload[field], field); -} - -function readPollIntervalMs(value: unknown): number { - const seconds = typeof value === 'number' - ? value - : (typeof value === 'string' ? Number(value) : 2); - if (!Number.isFinite(seconds) || seconds < 0) return 2_000; - return Math.floor(seconds * 1000); -} - -function delay(ms: number): Promise { - if (ms <= 0) return Promise.resolve(); - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function requestToken( - authBase: string, - clientId: string, - clientSecret: string, - body: URLSearchParams, -): Promise<{ ok: boolean; status: number; payload: unknown }> { - const response = await proxyAwareFetch(`${authBase}/oauth2/token`, { - method: 'POST', - headers: { - Authorization: createBasicAuthHeader(clientId, clientSecret), - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body, - }); - - return { - ok: response.ok, - status: response.status, - payload: await readResponsePayload(response), - }; -} - -async function pollDesktopAuthToken( - requestId: string, - deviceSecret: string, - pollIntervalMs: number, -): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < DESKTOP_AUTH_TIMEOUT_MS) { - const tokenUrl = createWorksUrl('/api/auth/desktop/token'); - tokenUrl.searchParams.set('request_id', requestId); - tokenUrl.searchParams.set('device_secret', deviceSecret); - - const response = await proxyAwareFetch(tokenUrl.toString(), { method: 'GET' }); - const payload = await readResponsePayload(response) as DesktopAuthTokenPayload; - - if (!response.ok) { - throw new Error(getErrorMessage(payload, '登录授权失败,请稍后重试。')); - } - - if (payload.status === 'approved' && payload.token) { - return payload.token; - } - - await delay(pollIntervalMs); +function readExactJsonObject( + value: unknown, + allowedFields: readonly string[], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Request body must be a JSON object'); } - - throw new Error('登录超时,请重新尝试。'); + const record = value as Record; + const unexpectedField = Object.keys(record).find((field) => !allowedFields.includes(field)); + if (unexpectedField) { + throw new Error(`Unexpected field: ${unexpectedField}`); + } + return record; } -async function handleBrowserAuthorization( - res: ServerResponse, - ctx: HostApiContext, -): Promise { - if (!await ensureRuntimeReadyForLogin(res, ctx)) return; - const response = await proxyAwareFetch(createWorksUrl('/api/auth/desktop/start').toString(), { - method: 'POST', - }); - const payload = await readResponsePayload(response); +function isFailureEnvelope(payload: unknown): boolean { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; + const record = payload as Record; + if (record.success === false || record.data === false) return true; + if (typeof record.code === 'number') return record.code !== 0; + if (typeof record.code === 'string' && record.code.trim()) return record.code.trim() !== '0'; + return false; +} - if (!response.ok) { - sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, { - success: false, - error: getErrorMessage(payload, '登录服务暂时不可用,请稍后重试。'), - }); +async function fetchWorks( + pathname: string, + init: RequestInit, +): Promise<{ response: Response; payload: unknown } | null> { + try { + const response = await proxyAwareFetch(createWorksUrl(pathname).toString(), init); + return { response, payload: await readResponsePayload(response) }; + } catch { + return null; + } +} + +async function readBoundedResponseBytes(response: Response, maxBytes: number): Promise { + if (!response.body) return Buffer.alloc(0); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes); +} + +function sendUpstreamFailure( + res: ServerResponse, + response: Response | null, + payload: unknown, + serviceFailureMessage: string, +): void { + const isClientError = response !== null && ( + response.ok || (response.status >= 400 && response.status < 500) + ); + sendJson(res, response?.ok ? 400 : (isClientError ? (response?.status ?? 400) : 502), { + success: false, + error: isClientError + ? getErrorMessage(payload, '请求未通过,请检查后重试。') + : serviceFailureMessage, + }); +} + +async function commitLoginPayload( + res: ServerResponse, + payload: unknown, +): Promise { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + sendJson(res, 502, { success: false, error: '登录服务返回了无效响应,请稍后重试。' }); return; } - - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new Error('Desktop authorization start returned an invalid payload'); + try { + const session = await commitWorksSquareSessionFromTokenPayload( + payload as WorksSquareTokenPayload, + ); + sendJson(res, 200, { + success: true, + token: withoutRefreshToken(payload), + session, + }); + } catch { + sendJson(res, 503, { success: false, error: '登录状态暂时无法保存,请稍后重试。' }); } - - const record = payload as Record; - const requestId = readRequiredPayloadString(record, 'request_id'); - const deviceSecret = readRequiredPayloadString(record, 'device_secret'); - const authorizeUrl = readRequiredPayloadString(record, 'authorize_url'); - const pollIntervalMs = readPollIntervalMs(record.poll_interval_seconds); - - await shell.openExternal(authorizeUrl); - const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs); - let session = null; - if (token && typeof token === 'object' && !Array.isArray(token)) { - session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload); - } - sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session }); } async function handlePasswordLogin( @@ -308,60 +253,204 @@ async function handlePasswordLogin( ctx: HostApiContext, ): Promise { if (!await ensureRuntimeReadyForLogin(res, ctx)) return; - const body = await parseJsonBody(req); - const authBase = normalizeAuthBase(body.authBase); - const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId); - const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret); + const body = readExactJsonObject( + await parseJsonBody(req), + ['username', 'password'], + ); const username = readRequiredString(body.username, 'username'); const password = readRequiredString(body.password, 'password'); - const scope = readOptionalString(body.scope, NIANCODE_AUTH_CONFIG.scope); - const code = readOptionalTrimmedString(body.code); - const randomStr = readOptionalTrimmedString(body.randomStr); - const tokenPassword = shouldEncryptPasswordForClient(clientId) - ? encryptPasswordForTokenEndpoint(password, NIANCODE_AUTH_CONFIG.passwordEncodeKey) - : password; - const tokenRequestParams: Record = { - grant_type: 'password', - username, - password: tokenPassword, - scope, - }; + const result = await fetchWorks('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); - if (code && randomStr) { - tokenRequestParams.code = code; - tokenRequestParams.randomStr = randomStr; + if (!result || !result.response.ok || isFailureEnvelope(result.payload)) { + sendUpstreamFailure( + res, + result?.response ?? null, + result?.payload, + '登录服务暂时不可用,请稍后重试。', + ); + return; + } + await commitLoginPayload(res, result.payload); +} + +async function handleMobileLogin( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, +): Promise { + if (!await ensureRuntimeReadyForLogin(res, ctx)) return; + const body = readExactJsonObject( + await parseJsonBody(req), + ['phone', 'code'], + ); + const phone = readRequiredString(body.phone, 'phone'); + const code = readRequiredString(body.code, 'code'); + const result = await fetchWorks('/api/auth/mobile-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, code }), + }); + + if (!result || !result.response.ok || isFailureEnvelope(result.payload)) { + sendUpstreamFailure( + res, + result?.response ?? null, + result?.payload, + '登录服务暂时不可用,请稍后重试。', + ); + return; + } + await commitLoginPayload(res, result.payload); +} + +async function handleMobileCode(req: IncomingMessage, res: ServerResponse): Promise { + const body = readExactJsonObject( + await parseJsonBody(req), + ['phone', 'imageRandomStr', 'imageCode'], + ); + const phone = readRequiredString(body.phone, 'phone'); + const imageRandomStr = readRequiredString(body.imageRandomStr, 'imageRandomStr'); + const imageCode = readRequiredString(body.imageCode, 'imageCode'); + if (!UUID_PATTERN.test(imageRandomStr)) { + throw new Error('imageRandomStr must be a UUID'); + } + const result = await fetchWorks('/api/auth/mobile-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, imageRandomStr, imageCode }), + }); + + if ( + !result + || !result.response.ok + || !result.payload + || typeof result.payload !== 'object' + || Array.isArray(result.payload) + || isFailureEnvelope(result.payload) + ) { + sendUpstreamFailure( + res, + result?.response ?? null, + result?.payload, + '短信验证码服务暂时不可用,请稍后重试。', + ); + return; + } + sendJson(res, 200, { success: true }); +} + +async function handleMobileImageCode(url: URL, res: ServerResponse): Promise { + res.setHeader('Cache-Control', 'no-store'); + const randomStr = readRequiredString(url.searchParams.get('randomStr'), 'randomStr'); + if (!UUID_PATTERN.test(randomStr)) { + throw new Error('randomStr must be a UUID'); + } + if ( + url.searchParams.getAll('randomStr').length !== 1 + || [...url.searchParams.keys()].some((key) => key !== 'randomStr') + ) { + throw new Error('Unexpected query parameter'); } - const tokenResult = await requestToken( - authBase, - clientId, - clientSecret, - createTokenRequestBody(tokenRequestParams), - ); - - if (!tokenResult.ok) { - sendJson(res, tokenResult.status === 401 ? 401 : 502, { + let response: Response; + try { + const upstreamUrl = createWorksUrl('/api/auth/mobile-image-code'); + upstreamUrl.searchParams.set('randomStr', randomStr); + response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' }); + } catch { + sendJson(res, 502, { success: false, - error: getErrorMessage(tokenResult.payload, `Login failed (${tokenResult.status})`), + error: '图形验证码服务暂时不可用,请稍后重试。', }); return; } - let session = null; - if ( - tokenResult.payload - && typeof tokenResult.payload === 'object' - && !Array.isArray(tokenResult.payload) - ) { - session = await commitWorksSquareSessionFromTokenPayload( - tokenResult.payload as WorksSquareTokenPayload, - ); + if (!response.ok) { + const payload = await readResponsePayload(response); + sendUpstreamFailure(res, response, payload, '图形验证码服务暂时不可用,请稍后重试。'); + return; + } + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + const contentLength = Number(response.headers.get('content-length')); + if ( + contentType !== 'image/png' + || (Number.isFinite(contentLength) && contentLength > MAX_CAPTCHA_IMAGE_BYTES) + ) { + sendJson(res, 502, { + success: false, + error: '图形验证码服务返回了无效图片,请稍后重试。', + }); + return; + } + const bytes = await readBoundedResponseBytes(response, MAX_CAPTCHA_IMAGE_BYTES); + if ( + !bytes + || bytes.length < PNG_MAGIC.length + || !bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC) + ) { + sendJson(res, 502, { + success: false, + error: '图形验证码服务返回了无效图片,请稍后重试。', + }); + return; } - sendJson(res, 200, { success: true, - token: withoutRefreshToken(tokenResult.payload), - session, + image: { + mimeType: 'image/png', + dataBase64: bytes.toString('base64'), + }, + }); +} + +function projectPublicUrl(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const worksBase = new URL(`${normalizeWorksBase()}/`); + const projected = new URL(value.trim(), worksBase); + if (projected.username || projected.password) return null; + if (projected.origin !== worksBase.origin && projected.protocol !== 'https:') return null; + if (projected.origin === worksBase.origin && !['http:', 'https:'].includes(projected.protocol)) { + return null; + } + return projected.toString(); + } catch { + return null; + } +} + +async function handlePublicConfig(res: ServerResponse): Promise { + const result = await fetchWorks('/api/app-config', { method: 'GET' }); + if (!result || !result.response.ok) { + sendUpstreamFailure( + res, + result?.response ?? null, + result?.payload, + '登录配置服务暂时不可用,请稍后重试。', + ); + return; + } + const config = result.payload && typeof result.payload === 'object' && !Array.isArray(result.payload) + ? result.payload as Record + : {}; + const legal = config.legal && typeof config.legal === 'object' && !Array.isArray(config.legal) + ? config.legal as Record + : {}; + const auth = config.auth && typeof config.auth === 'object' && !Array.isArray(config.auth) + ? config.auth as Record + : {}; + res.setHeader('Cache-Control', 'no-store'); + sendJson(res, 200, { + success: true, + links: { + termsUrl: projectPublicUrl(legal.terms_url), + privacyUrl: projectPublicUrl(legal.privacy_url), + forgotPasswordUrl: projectPublicUrl(auth.forgot_password_url), + }, }); } @@ -559,8 +648,11 @@ async function handleLogout( res: ServerResponse, ctx: HostApiContext, ): Promise { - const body = await parseJsonBody(req); - const authBase = normalizeAuthBase(body.authBase); + const body = readExactJsonObject( + await parseJsonBody(req), + ['accessToken'], + ); + const authBase = normalizeAuthBase(); const rendererAccessToken = readOptionalTrimmedString(body.accessToken); const accessToken = getWorksSquareSessionSnapshot()?.accessToken ?? readRequiredString(rendererAccessToken, 'accessToken'); @@ -630,13 +722,28 @@ export async function handleAuthRoutes( } try { - if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') { - await handleBrowserAuthorization(res, ctx); + if (url.pathname === '/api/auth/login' && req.method === 'POST') { + await handlePasswordLogin(req, res, ctx); return true; } - if (url.pathname === '/api/auth/login' && req.method === 'POST') { - await handlePasswordLogin(req, res, ctx); + if (url.pathname === '/api/auth/mobile-login' && req.method === 'POST') { + await handleMobileLogin(req, res, ctx); + return true; + } + + if (url.pathname === '/api/auth/mobile-code' && req.method === 'POST') { + await handleMobileCode(req, res); + return true; + } + + if (url.pathname === '/api/auth/mobile-image-code' && req.method === 'GET') { + await handleMobileImageCode(url, res); + return true; + } + + if (url.pathname === '/api/auth/public-config' && req.method === 'GET') { + await handlePublicConfig(res); return true; } diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index a8db76d..a45ec4b 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,14 +1,74 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ExternalLink, Loader2, LogIn } from 'lucide-react'; +import { Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { hostApiFetch } from '@/lib/host-api'; import { useAuthStore } from '@/stores/auth'; import { useProviderStore } from '@/stores/providers'; import logoSvg from '@/assets/logo.svg'; const LOGIN_SERVICE_ERROR_MESSAGE = '登录服务暂时不可用,请稍后重试。'; const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。'; +const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。'; +const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。'; +const PHONE_PATTERN = /^1\d{10}$/; + +type LoginMode = 'password' | 'mobile'; + +type PublicLinks = { + termsUrl: string | null; + privacyUrl: string | null; + forgotPasswordUrl: string | null; +}; + +type PublicConfigResponse = { + success?: unknown; + links?: unknown; +}; + +type CaptchaResponse = { + success?: unknown; + image?: { + mimeType?: unknown; + dataBase64?: unknown; + }; +}; + +type SmsResponse = { + success?: unknown; + error?: unknown; +}; + +const EMPTY_LINKS: PublicLinks = { + termsUrl: null, + privacyUrl: null, + forgotPasswordUrl: null, +}; + +function getSafeExternalUrl(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; + } catch { + return null; + } +} + +function projectPublicLinks(response: PublicConfigResponse): PublicLinks { + if (response.success !== true || !response.links || typeof response.links !== 'object') { + return EMPTY_LINKS; + } + const links = response.links as Record; + return { + termsUrl: getSafeExternalUrl(links.termsUrl), + privacyUrl: getSafeExternalUrl(links.privacyUrl), + forgotPasswordUrl: getSafeExternalUrl(links.forgotPasswordUrl), + }; +} function getLoginErrorMessage(error: string | null): string | null { const message = error?.trim(); @@ -34,31 +94,124 @@ function getLoginErrorMessage(error: string | null): string | null { export function Login() { const navigate = useNavigate(); - const loginWithBrowser = useAuthStore((state) => state.loginWithBrowser); + const loginWithPassword = useAuthStore((state) => state.loginWithPassword); + const loginWithMobile = useAuthStore((state) => state.loginWithMobile); const loading = useAuthStore((state) => state.loading); const error = useAuthStore((state) => state.error); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const logout = useAuthStore((state) => state.logout); const importUserModelConfig = useProviderStore((state) => state.importUserModelConfig); + const [mode, setMode] = useState('password'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [phone, setPhone] = useState(''); + const [imageCode, setImageCode] = useState(''); + const [smsCode, setSmsCode] = useState(''); + const [agreed, setAgreed] = useState(false); + const [links, setLinks] = useState(EMPTY_LINKS); + const [captchaRandomStr, setCaptchaRandomStr] = useState(null); + const [captchaImage, setCaptchaImage] = useState(null); + const [captchaLoading, setCaptchaLoading] = useState(false); + const [sendingCode, setSendingCode] = useState(false); + const [cooldown, setCooldown] = useState(0); + const [cooldownActive, setCooldownActive] = useState(false); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false); + const captchaRequestRef = useRef(0); + const captchaStartedRef = useRef(false); const displayError = getLoginErrorMessage(submitError || error); + useEffect(() => { + let active = true; + void hostApiFetch('/api/auth/public-config', { cache: 'no-store' }) + .then((response) => { + if (active) setLinks(projectPublicLinks(response)); + }) + .catch(() => { + if (active) setLinks(EMPTY_LINKS); + }); + return () => { + active = false; + }; + }, []); + + const loadCaptcha = useCallback(async () => { + captchaStartedRef.current = true; + const requestId = ++captchaRequestRef.current; + const randomStr = crypto.randomUUID(); + setCaptchaLoading(true); + setCaptchaRandomStr(null); + setCaptchaImage(null); + setImageCode(''); + setSubmitError(null); + try { + const response = await hostApiFetch( + `/api/auth/mobile-image-code?randomStr=${encodeURIComponent(randomStr)}`, + { cache: 'no-store' }, + ); + if (requestId !== captchaRequestRef.current) return; + const mimeType = response.image?.mimeType; + const dataBase64 = response.image?.dataBase64; + if ( + response.success !== true + || typeof mimeType !== 'string' + || !/^image\/[a-z0-9.+-]+$/i.test(mimeType) + || typeof dataBase64 !== 'string' + || !dataBase64 + ) { + throw new Error(CAPTCHA_ERROR_MESSAGE); + } + setCaptchaRandomStr(randomStr); + setCaptchaImage(`data:${mimeType};base64,${dataBase64}`); + } catch { + if (requestId === captchaRequestRef.current) { + setSubmitError(CAPTCHA_ERROR_MESSAGE); + } + } finally { + if (requestId === captchaRequestRef.current) setCaptchaLoading(false); + } + }, []); + + useEffect(() => { + if (mode !== 'mobile' || cooldownActive || captchaImage || captchaLoading || captchaStartedRef.current) return; + void loadCaptcha(); + }, [captchaImage, captchaLoading, cooldownActive, loadCaptcha, mode]); + + useEffect(() => { + if (!cooldownActive) return; + const startedAt = Date.now(); + const ticker = window.setInterval(() => { + const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000); + setCooldown(Math.max(0, 60 - elapsedSeconds)); + }, 1_000); + const expiry = window.setTimeout(() => { + setCooldownActive(false); + setCooldown(0); + void loadCaptcha(); + }, 60_000); + return () => { + window.clearInterval(ticker); + window.clearTimeout(expiry); + }; + }, [cooldownActive, loadCaptcha]); + + useEffect(() => () => { + captchaRequestRef.current += 1; + }, []); + useEffect(() => { if (isAuthenticated() && !submitting) { navigate('/module-select', { replace: true }); } }, [isAuthenticated, navigate, submitting]); - const handleBrowserLogin = async () => { + const finishLogin = async (login: () => Promise) => { setSubmitError(null); setSubmitting(true); try { - await loginWithBrowser(); + await login(); const accessToken = useAuthStore.getState().accessToken; - if (!accessToken) { - throw new Error('Makelore sign-in did not return an access token'); - } + if (!accessToken) throw new Error('Login did not return an access token'); try { await importUserModelConfig(accessToken); } catch (syncError) { @@ -73,6 +226,75 @@ export function Login() { } }; + const handlePasswordSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!agreed || !username.trim() || !password) return; + void finishLogin(() => loginWithPassword({ username: username.trim(), password })); + }; + + const handleMobileSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!agreed || !PHONE_PATTERN.test(phone) || !smsCode.trim()) return; + void finishLogin(() => loginWithMobile({ phone, code: smsCode.trim() })); + }; + + const handleModeChange = (nextMode: LoginMode) => { + if (nextMode === mode) return; + setMode(nextMode); + if (nextMode === 'mobile' && !cooldownActive) { + void loadCaptcha(); + } + }; + + const handleSendCode = async () => { + if ( + sendingCode + || cooldownActive + || !PHONE_PATTERN.test(phone) + || !captchaRandomStr + || !captchaImage + || !imageCode.trim() + ) return; + + const challenge = captchaRandomStr; + setSendingCode(true); + setSubmitError(null); + try { + const response = await hostApiFetch('/api/auth/mobile-code', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ + phone, + imageRandomStr: challenge, + imageCode: imageCode.trim(), + }), + }); + if (response.success !== true) { + throw new Error(SMS_ERROR_MESSAGE); + } + captchaRequestRef.current += 1; + setCaptchaRandomStr(null); + setCaptchaImage(null); + setImageCode(''); + setCooldown(60); + setCooldownActive(true); + } catch (sendError) { + const message = sendError instanceof Error ? sendError.message : SMS_ERROR_MESSAGE; + await loadCaptcha(); + setSubmitError(message); + } finally { + setSendingCode(false); + } + }; + + const busy = loading || submitting; + const passwordReady = agreed && Boolean(username.trim()) && Boolean(password) && !busy; + const mobileReady = agreed && PHONE_PATTERN.test(phone) && Boolean(smsCode.trim()) && !busy; + const sendReady = PHONE_PATTERN.test(phone) + && Boolean(captchaRandomStr && captchaImage && imageCode.trim()) + && !sendingCode + && !cooldownActive; + return (
@@ -84,31 +306,100 @@ export function Login() {

登录 Makelore 账户

+
+ + +
-

- 请在浏览器中完成登录,并授权此桌面应用访问。 -

- {displayError && (
{displayError}
)} - + {mode === 'password' ? ( +
+
+ + setUsername(event.target.value)} /> +
+
+
+ + {links.forgotPasswordUrl && ( + 忘记密码? + )} +
+ setPassword(event.target.value)} /> +
+ +
+ ) : ( +
+
+ + setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} /> +
+
+ +
+ setImageCode(event.target.value)} /> + +
+
+
+ +
+ setSmsCode(event.target.value)} /> + +
+
+ +
+ )} -
- - 你的密码会留在安全的登录页面中,不会输入到此应用。 -
+
diff --git a/src/stores/auth.ts b/src/stores/auth.ts index b95a1eb..007a750 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,4 +1,4 @@ -import { create } from 'zustand'; +import { create, type StoreApi } from 'zustand'; import { persist } from 'zustand/middleware'; import { hostApiFetch } from '@/lib/host-api'; import { @@ -64,6 +64,16 @@ type RefreshSessionOptions = { forceRefresh?: boolean; }; +type PasswordLoginInput = { + username: string; + password: string; +}; + +type MobileLoginInput = { + phone: string; + code: string; +}; + type AuthState = { initialized: boolean; loading: boolean; @@ -80,7 +90,8 @@ type AuthState = { user: AuthUser | null; moduleAccess: ModuleAccess; init: () => Promise; - loginWithBrowser: () => Promise; + loginWithPassword: (input: PasswordLoginInput) => Promise; + loginWithMobile: (input: MobileLoginInput) => Promise; refreshSession: (options?: RefreshSessionOptions) => Promise; getValidAccessToken: () => Promise; markActivity: () => Promise; @@ -242,6 +253,50 @@ async function readCurrentModuleAccess(fallback: ModuleAccess): Promise['setState'], +): Promise { + advanceAuthSessionEpoch(); + const operationEpoch = authSessionEpoch; + set({ loading: true, error: null }); + try { + const response = await hostApiFetch(path, { + method: 'POST', + body: JSON.stringify(input), + }); + const session = parseMainSession(response.session); + if (!response.success || !response.token || !session) { + throw new Error(response.error || 'Login failed'); + } + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + + const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS }); + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + + set({ + initialized: true, + loading: false, + error: null, + authBase: trimTrailingSlash(DEFAULT_AUTH_BASE), + clientId: DEFAULT_CLIENT_ID, + ...sessionFieldsFromMain(session), + user: createUserFromToken(response.token), + moduleAccess, + }); + } catch (error) { + if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + const terminal = isTerminalAuthError(error); + const message = terminal + ? '登录已过期,请重新授权。' + : (error instanceof Error ? error.message : String(error)); + advanceAuthSessionEpoch(); + set({ loading: false, error: message, ...getClearedSession() }); + throw new Error(message, { cause: error }); + } +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -371,44 +426,9 @@ export const useAuthStore = create()( set({ initialized: true, loading: false, error: null, moduleAccess }); }, - loginWithBrowser: async () => { - advanceAuthSessionEpoch(); - const operationEpoch = authSessionEpoch; - set({ loading: true, error: null }); - try { - const response = await hostApiFetch('/api/auth/browser/start', { - method: 'POST', - }); - const session = parseMainSession(response.session); - if (!response.success || !response.token || !session) { - throw new Error(response.error || 'Browser authorization failed'); - } - if (!isCurrentAuthSessionEpoch(operationEpoch)) return; + loginWithPassword: (input) => loginViaHost('/api/auth/login', input, set), - const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS }); - if (!isCurrentAuthSessionEpoch(operationEpoch)) return; - - set({ - initialized: true, - loading: false, - error: null, - authBase: trimTrailingSlash(DEFAULT_AUTH_BASE), - clientId: DEFAULT_CLIENT_ID, - ...sessionFieldsFromMain(session), - user: createUserFromToken(response.token), - moduleAccess, - }); - } catch (error) { - if (!isCurrentAuthSessionEpoch(operationEpoch)) return; - const terminal = isTerminalAuthError(error); - const message = terminal - ? '登录已过期,请重新授权。' - : (error instanceof Error ? error.message : String(error)); - advanceAuthSessionEpoch(); - set({ loading: false, error: message, ...getClearedSession() }); - throw new Error(message, { cause: error }); - } - }, + loginWithMobile: (input) => loginViaHost('/api/auth/mobile-login', input, set), refreshSession: async (options = {}) => { const state = get(); diff --git a/tests/e2e/app-smoke.spec.ts b/tests/e2e/app-smoke.spec.ts index 93825fc..ebb8c34 100644 --- a/tests/e2e/app-smoke.spec.ts +++ b/tests/e2e/app-smoke.spec.ts @@ -22,7 +22,7 @@ test.describe('Makelore Electron smoke flows', () => { await expect(page.getByTestId('setup-skip-button')).toBeVisible(); }); - test('can skip setup and open the public module chooser', async ({ page }) => { + test('can skip setup and open the native login surface', async ({ page }) => { await expect(page.getByTestId('setup-page')).toBeVisible(); await page.getByTestId('setup-skip-button').click(); @@ -30,7 +30,12 @@ test.describe('Makelore Electron smoke flows', () => { await expect(page.getByTestId('main-layout')).toHaveCount(0); await page.getByTestId('ai-module-option-programming').click(); - await expect(page.getByRole('button', { name: '在浏览器中继续' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '密码登录', exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: '密码登录', exact: true })) + .toHaveAttribute('aria-selected', 'true'); + await expect(page.getByLabel('用户名', { exact: true })).toBeVisible(); + await expect(page.getByLabel('密码', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '在浏览器中继续', exact: true })).toHaveCount(0); }); test('persists skipped setup across relaunch for the same isolated profile', async ({ electronApp, launchElectronApp }) => { diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 448459a..44493a1 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -1,6 +1,5 @@ import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'http'; -import { shell } from 'electron'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleAuthRoutes } from '@electron/api/routes/auth'; import { @@ -173,7 +172,7 @@ describe('auth host api routes', () => { expect(getWorksSquareSessionSnapshot()).toBeNull(); }); - it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => { + it('proxies password login through Works and commits a redacted Main session', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ access_token: 'access-token', @@ -182,7 +181,6 @@ describe('auth host api routes', () => { expires_in: 43200, username: 'zhangsan', user_id: '1', - client_id: 'app', }), { status: 200 }), ); vi.stubGlobal('fetch', fetchMock); @@ -192,9 +190,6 @@ describe('auth host api routes', () => { createRequest('POST', { username: 'zhangsan', password: 'passw0rd', - code: 'a7k9', - randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd', - scope: 'server', }), response.res, new URL('http://127.0.0.1:13210/api/auth/login'), @@ -211,7 +206,6 @@ describe('auth host api routes', () => { expires_in: 43200, username: 'zhangsan', user_id: '1', - client_id: 'app', }, session: { accessToken: 'access-token', @@ -224,16 +218,19 @@ describe('auth host api routes', () => { expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://biz.nianxx.cn/auth/oauth2/token'); + expect(url).toBe('https://square.nianxx.cn/api/auth/login'); expect(init.method).toBe('POST'); - expect(init.headers).toEqual({ - Authorization: `Basic ${Buffer.from('app:app').toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(JSON.parse(String(init.body))).toEqual({ + username: 'zhangsan', + password: 'passw0rd', }); - expect(String(init.body)).toBe( - 'grant_type=password&username=zhangsan&password=ILsdQ7Wp2P8%3D&scope=server&code=a7k9&randomStr=333e6825-760c-4c1a-8b56-eb9539b43dbd', - ); - expect(String(init.body)).not.toContain('passw0rd'); + expect(JSON.stringify(response.json())).not.toContain('refresh-token'); + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + accessToken: 'access-token', + canRefresh: true, + }); + expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken'); }); it('allows explicit reauthorization to replace an unreadable persisted session', async () => { @@ -297,57 +294,8 @@ describe('auth host api routes', () => { expect(JSON.stringify(response.json())).not.toContain('app:app'); }); - it('replaces an upstream HTML gateway error with a concise browser-login message', async () => { - const fetchMock = vi.fn().mockResolvedValueOnce( - new Response( - '502 Bad Gatewayupstream details', - { status: 502, headers: { 'content-type': 'text/html' } }, - ), - ); - vi.stubGlobal('fetch', fetchMock); - const response = createResponse(); - - await handleAuthRoutes( - createRequest('POST'), - response.res, - new URL('http://127.0.0.1:13210/api/auth/browser/start'), - {} as never, - ); - - expect(response.statusCode).toBe(502); - expect(response.json()).toEqual({ - success: false, - error: '登录服务暂时不可用,请稍后重试。', - }); - expect(JSON.stringify(response.json())).not.toContain(''); - }); - - it('opens Works Square browser authorization and returns the approved desktop token', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ - request_id: 'desktop-request-id', - device_secret: 'desktop-device-secret', - authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id', - poll_interval_seconds: 0, - }), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ status: 'pending' }), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ - status: 'approved', - token: { - access_token: 'desktop-access-token', - refresh_token: 'desktop-refresh-token', - token_type: 'Bearer', - expires_in: 43200, - username: 'student', - user_id: '42', - }, - }), { status: 200 }), - ); + it('has no browser-start route', async () => { + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const response = createResponse(); @@ -359,74 +307,265 @@ describe('auth host api routes', () => { ); expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - success: true, - token: { - access_token: 'desktop-access-token', - token_type: 'Bearer', - expires_in: 43200, - username: 'student', - user_id: '42', - }, - session: { - accessToken: 'desktop-access-token', - tokenType: 'Bearer', - expiresAt: expect.any(Number), - lastActiveAt: expect.any(Number), - canRefresh: true, - }, - }); - expect(shell.openExternal).toHaveBeenCalledWith( - 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id', - ); - expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ - 'https://square.nianxx.cn/api/auth/desktop/start', - 'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret', - 'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret', - ]); + expect(response.statusCode).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('stores the approved browser token bundle in the Main Works Square session cache', async () => { - vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z')); - const fetchMock = vi.fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ - request_id: 'desktop-request-id', - device_secret: 'desktop-device-secret', - authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id', - poll_interval_seconds: 0, - }), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ - status: 'approved', - token: { - access_token: 'desktop-access-token', - refresh_token: 'desktop-refresh-token', - token_type: 'Bearer', - expires_in: 43200, - }, - }), { status: 200 }), + it.each(['authBase', 'clientId', 'clientSecret', 'scope', 'passwordEncodeKey'])( + 'rejects renderer-provided auth control field %s', + async (field) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + username: 'zhangsan', + password: 'passw0rd', + [field]: 'renderer-controlled', + }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/login'), + {} as never, ); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + success: false, + error: `Unexpected field: ${field}`, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it('maps Works 5xx responses to a safe login service failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( + JSON.stringify({ detail: 'internal host and credential details' }), + { status: 503 }, + ))); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/login'), + {} as never, + ); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + success: false, + error: '登录服务暂时不可用,请稍后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('internal host'); + }); + + it('maps Works network failures to a safe login service failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue( + new Error('connect ECONNREFUSED 10.0.0.8 with password=secret'), + )); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/login'), + {} as never, + ); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + success: false, + error: '登录服务暂时不可用,请稍后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('10.0.0.8'); + }); + + it('proxies mobile login and commits the returned token bundle', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + access_token: 'mobile-access-token', + refresh_token: 'mobile-refresh-token', + token_type: 'Bearer', + expires_in: 43200, + username: '13800000000', + }), { status: 200 }), + ); vi.stubGlobal('fetch', fetchMock); const response = createResponse(); await handleAuthRoutes( - createRequest('POST'), + createRequest('POST', { phone: '13800000000', code: '123456' }), response.res, - new URL('http://127.0.0.1:13210/api/auth/browser/start'), + new URL('http://127.0.0.1:13210/api/auth/mobile-login'), {} as never, ); expect(response.statusCode).toBe(200); - expect(getWorksSquareSessionSnapshot()).toMatchObject({ - accessToken: 'desktop-access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 43_200_000, - canRefresh: true, + expect(response.json()).toMatchObject({ + success: true, + token: { access_token: 'mobile-access-token' }, + session: { accessToken: 'mobile-access-token', canRefresh: true }, }); - expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken'); + expect(JSON.stringify(response.json())).not.toContain('mobile-refresh-token'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-login'); + expect(JSON.parse(String(init.body))).toEqual({ phone: '13800000000', code: '123456' }); + }); + + it('returns only success for a successful SMS send envelope', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ code: 0, msg: 'sent', data: true }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + phone: '13800000000', + imageRandomStr: '550e8400-e29b-41d4-a716-446655440000', + imageCode: '15', + }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/mobile-code'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-code'); + expect(JSON.parse(String(init.body))).toEqual({ + phone: '13800000000', + imageRandomStr: '550e8400-e29b-41d4-a716-446655440000', + imageCode: '15', + }); + expect(JSON.stringify(response.json())).not.toContain('sent'); + }); + + it('maps a failed SMS success envelope to an actionable 400', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ code: 1, msg: '图形验证码不合法', data: false }), { status: 200 }), + )); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + phone: '13800000000', + imageRandomStr: '550e8400-e29b-41d4-a716-446655440000', + imageCode: 'wrong', + }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/mobile-code'), + {} as never, + ); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ success: false, error: '图形验证码不合法' }); + }); + + it('validates captcha UUID before contacting Works', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=not-a-uuid'), + {} as never, + ); + + expect(response.statusCode).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('projects a bounded PNG captcha as no-store base64 JSON', async () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('captcha'), + ]); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(png, { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }))); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: true, + image: { mimeType: 'image/png', dataBase64: png.toString('base64') }, + }); + expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + }); + + it.each([ + ['wrong MIME', Buffer.from('not png'), 'image/jpeg'], + ['wrong magic', Buffer.from('not png'), 'image/png'], + ['too large', Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(1024 * 1024), + ]), 'image/png'], + ])('rejects captcha images with %s', async (_label, bytes, contentType) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes, { + status: 200, + headers: { 'Content-Type': contentType }, + }))); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'), + {} as never, + ); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ + success: false, + error: '图形验证码服务返回了无效图片,请稍后重试。', + }); + }); + + it('projects only safe public login links', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + downloads: { windows_url: 'https://secret.example/client.exe' }, + legal: { + terms_url: '/legal/terms', + privacy_url: 'http://unsafe.example/privacy', + }, + auth: { + forgot_password_url: 'https://accounts.example/forgot', + wechat_login_url: 'https://secret.example/wechat', + }, + }), { status: 200 }))); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1:13210/api/auth/public-config'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: true, + links: { + termsUrl: 'https://square.nianxx.cn/legal/terms', + privacyUrl: null, + forgotPasswordUrl: 'https://accounts.example/forgot', + }, + }); + expect(JSON.stringify(response.json())).not.toContain('secret.example'); + expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); }); it('accepts renderer session sync after app restart', async () => { @@ -776,6 +915,40 @@ describe('auth host api routes', () => { }); }); + it('rejects a renderer-controlled logout auth base without sending the Main token', async () => { + storeWorksSquareSession({ + accessToken: 'main-secret-access-token', + refreshToken: 'main-secret-refresh-token', + expiresAt: Date.now() + 3600_000, + lastActiveAt: Date.now(), + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const stop = vi.fn(); + const response = createResponse(); + + await handleAuthRoutes( + createRequest('POST', { + accessToken: 'renderer-stale-access-token', + authBase: 'https://attacker.example/collect', + }), + response.res, + new URL('http://127.0.0.1:13210/api/auth/logout'), + { opencodeManager: { stop } } as never, + ); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + success: false, + error: 'Unexpected field: authBase', + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + expect(getWorksSquareSessionSnapshot()).toMatchObject({ + accessToken: 'main-secret-access-token', + }); + }); + it('blocks a new login while cleanup of the previous runtime still fails', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ success: true }), { status: 200 }), diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index 8c14ddd..0e2e681 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -43,7 +43,7 @@ describe('auth store', () => { vi.useRealTimers(); }); - it('starts browser authorization through the host api and stores the session', async () => { + it('logs in with a password through the host api and stores the hydrated session', async () => { hostApiFetchMock .mockResolvedValueOnce({ success: true, @@ -77,12 +77,21 @@ describe('auth store', () => { }, }); - await useAuthStore.getState().loginWithBrowser(); - - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', { - method: 'POST', + await useAuthStore.getState().loginWithPassword({ + username: 'zhangsan', + password: 'secret', }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username: 'zhangsan', password: 'secret' }), + }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me'); + expect(hostApiFetchMock).not.toHaveBeenCalledWith( + '/api/auth/browser/start', + expect.anything(), + ); + const state = useAuthStore.getState(); expect(state.isAuthenticated()).toBe(true); expect(state.accessToken).toBe('access-token'); @@ -107,6 +116,91 @@ describe('auth store', () => { ); }); + it('logs in with a mobile code using only the phone and code payload', async () => { + hostApiFetchMock + .mockResolvedValueOnce({ + success: true, + token: { + access_token: 'mobile-access-token', + token_type: 'Bearer', + username: '13800138000', + user_id: '2', + }, + session: { + accessToken: 'mobile-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }) + .mockResolvedValueOnce({ + success: true, + moduleAccess: { learning: false }, + }); + + await useAuthStore.getState().loginWithMobile({ + phone: '13800138000', + code: '123456', + }); + + expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/mobile-login', { + method: 'POST', + body: JSON.stringify({ phone: '13800138000', code: '123456' }), + }); + expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me'); + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + loading: false, + accessToken: 'mobile-access-token', + user: { username: '13800138000', userId: '2' }, + moduleAccess: { + programming: true, + design: true, + learning: false, + robot: true, + }, + }); + }); + + it('keeps a new session with safe defaults when module hydration is temporarily unavailable', async () => { + hostApiFetchMock + .mockResolvedValueOnce({ + success: true, + token: { + access_token: 'access-token', + token_type: 'Bearer', + username: 'zhangsan', + }, + session: { + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + }, + }) + .mockRejectedValueOnce(new Error('Host API unavailable')); + + await useAuthStore.getState().loginWithPassword({ + username: 'zhangsan', + password: 'secret', + }); + + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + loading: false, + error: null, + accessToken: 'access-token', + moduleAccess: { + programming: true, + design: true, + learning: true, + robot: true, + }, + }); + }); + it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => { hostApiFetchMock .mockResolvedValueOnce({ @@ -226,7 +320,10 @@ describe('auth store', () => { details: { status: 401 }, })); - await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow( + await expect(useAuthStore.getState().loginWithPassword({ + username: 'zhangsan', + password: 'secret', + })).rejects.toThrow( '登录已过期,请重新授权。', ); @@ -244,23 +341,47 @@ describe('auth store', () => { }); }); - it('surfaces browser authorization failures and does not keep a partial session', async () => { + it('surfaces password login failures and does not keep a partial session', async () => { hostApiFetchMock.mockResolvedValueOnce({ success: false, - error: 'Authorization timed out', + error: 'Invalid credentials', }); - await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow( - 'Authorization timed out', + await expect(useAuthStore.getState().loginWithPassword({ + username: 'zhangsan', + password: 'wrong', + })).rejects.toThrow( + 'Invalid credentials', ); const state = useAuthStore.getState(); expect(state.loading).toBe(false); - expect(state.error).toBe('Authorization timed out'); + expect(state.error).toBe('Invalid credentials'); expect(state.accessToken).toBeNull(); expect(state.isAuthenticated()).toBe(false); }); + it('rejects an invalid mobile login snapshot without retaining token data', async () => { + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + token: { access_token: 'partial-token', username: '13800138000' }, + session: { accessToken: 'partial-token' }, + }); + + await expect(useAuthStore.getState().loginWithMobile({ + phone: '13800138000', + code: '123456', + })).rejects.toThrow('Login failed'); + + expect(hostApiFetchMock).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState()).toMatchObject({ + loading: false, + error: 'Login failed', + accessToken: null, + user: null, + }); + }); + it('clears a persisted session when the configured SSO gateway changes', async () => { useAuthStore.setState({ initialized: false, @@ -476,13 +597,16 @@ describe('auth store', () => { }); }); - it('does not let delayed browser login revive a Main-terminal session', async () => { + it('does not let a delayed password login revive a Main-terminal session', async () => { let resolveLogin!: (value: unknown) => void; hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLogin = resolve; })); - const login = useAuthStore.getState().loginWithBrowser(); + const login = useAuthStore.getState().loginWithPassword({ + username: 'zhangsan', + password: 'secret', + }); useAuthStore.getState().applyMainSession(null); resolveLogin({ success: true, diff --git a/tests/unit/login-page.test.tsx b/tests/unit/login-page.test.tsx index 3237b19..201c531 100644 --- a/tests/unit/login-page.test.tsx +++ b/tests/unit/login-page.test.tsx @@ -1,13 +1,9 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Login } from '@/pages/Login'; import { useAuthStore } from '@/stores/auth'; -import App from '@/App'; -import { useSettingsStore } from '@/stores/settings'; -import { useOpencodeStore } from '@/stores/opencode'; -import { useProjectConfigStore } from '@/stores/project-config'; -import { createProjectConfig } from '../../shared/project-config'; +import { useProviderStore } from '@/stores/providers'; const hostApiFetchMock = vi.hoisted(() => vi.fn()); @@ -15,6 +11,11 @@ vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); +const loginWithPassword = vi.fn(); +const loginWithMobile = vi.fn(); +const logout = vi.fn(); +const importUserModelConfig = vi.fn(); + function resetAuthStore() { useAuthStore.setState({ initialized: true, @@ -29,487 +30,318 @@ function resetAuthStore() { canRefresh: false, legacyRefreshToken: null, user: null, + loginWithPassword, + loginWithMobile, + logout, }); } +function renderLogin() { + return render( + + + } /> + Module Selection} /> + + , + ); +} + +function switchToMobile() { + fireEvent.click(screen.getByRole('tab', { name: '验证码登录' })); +} + +function resolveCaptcha(randomStr = 'captcha-id') { + return { + success: true, + image: { mimeType: 'image/png', dataBase64: `image-${randomStr}` }, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + describe('Login page', () => { beforeEach(() => { - window.electron.imageWorkspaceLocalDevelopment = false; window.localStorage.clear(); - hostApiFetchMock.mockReset(); + vi.clearAllMocks(); + vi.useRealTimers(); resetAuthStore(); - useSettingsStore.getState().resetSettings(); - useOpencodeStore.setState({ projects: [], activeProject: null }); - useProjectConfigStore.setState({ configsByProjectId: {}, knowledgeByProjectId: {}, loadingProjectId: null, errorsByProjectId: {} }); - vi.unstubAllGlobals(); - }); - - it('keeps the login route reachable from the app router', async () => { - vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true); - - render( - - - , - ); - - expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument(); - }); - - it('uses Chinese copy and keeps upstream HTML errors concise', () => { - useAuthStore.setState({ - error: '502 Bad Gatewayupstream details', + useProviderStore.setState({ importUserModelConfig }); + importUserModelConfig.mockResolvedValue(undefined); + logout.mockResolvedValue(undefined); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/auth/public-config') { + return { + success: true, + links: { + termsUrl: 'https://works.example/terms', + privacyUrl: 'https://works.example/privacy', + forgotPasswordUrl: 'https://works.example/forgot', + }, + }; + } + if (path.startsWith('/api/auth/mobile-image-code?')) return resolveCaptcha(); + return { success: true }; }); - - render( - - - , - ); - - expect(screen.getByText('登录 Makelore 账户')).toBeInTheDocument(); - expect(screen.getByText('请在浏览器中完成登录,并授权此桌面应用访问。')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument(); - expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。'); - expect(screen.queryByText(//i)).not.toBeInTheDocument(); + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001'); }); - it('redirects protected app routes to login when setup is complete but the user is signed out', async () => { - useSettingsStore.setState({ setupComplete: true }); - - render( - - - , - ); - - expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument(); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); }); - it('opens the module chooser at the default root route', async () => { - vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true); + it('shows native Chinese tabs with password login as the default and no browser authorization surface', async () => { + renderLogin(); - render( - - - , - ); - - expect(await screen.findByTestId('ai-module-selection-page')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument(); + await screen.findByRole('link', { name: '用户协议' }); + expect(screen.getByRole('tab', { name: '密码登录' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: '验证码登录' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByLabelText('用户名')).toHaveAttribute('autocomplete', 'username'); + expect(screen.getByLabelText('密码')).toHaveAttribute('autocomplete', 'current-password'); + expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument(); }); - it('redirects an open protected route when the current auth session is invalidated', async () => { - useSettingsStore.setState({ setupComplete: true }); - useAuthStore.setState({ - initialized: true, - loading: false, - error: null, - authBase: 'https://biz.nianxx.cn/auth/', - clientId: 'app', - accessToken: 'access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 60_000, - lastActiveAt: Date.now(), - canRefresh: true, - legacyRefreshToken: null, - user: { - username: 'brother7', - userId: '1', - tenantId: null, - deptId: null, - authorities: [], + it('renders only safe projected links and degrades unavailable or unsafe links to plain text', async () => { + hostApiFetchMock.mockResolvedValueOnce({ + success: true, + links: { + termsUrl: null, + privacyUrl: 'javascript:alert(1)', + forgotPasswordUrl: null, }, }); - hostApiFetchMock.mockResolvedValue({ success: true }); + renderLogin(); - render( - - - , - ); - - await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/auth/session/sync', - expect.any(Object), - )); - - act(() => { - useAuthStore.setState({ - accessToken: null, - tokenType: null, - expiresAt: null, - lastActiveAt: null, - canRefresh: false, - legacyRefreshToken: null, - user: null, - }); - }); - - expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument(); + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/public-config', { cache: 'no-store' })); + expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('用户协议'); + expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('隐私政策'); + expect(screen.queryByText('忘记密码?')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); - it('does not record synthetic DOM events as user activity', async () => { - useSettingsStore.setState({ setupComplete: true }); - const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000; - useAuthStore.setState({ - initialized: true, - loading: false, - error: null, - authBase: 'https://biz.nianxx.cn/auth/', - clientId: 'app', - accessToken: 'access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 3600_000, - lastActiveAt, - canRefresh: true, - legacyRefreshToken: null, - user: { - username: 'brother7', - userId: '1', - tenantId: null, - deptId: null, - authorities: [], - }, - }); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/auth/session/activity') { - return { - success: true, - session: { - accessToken: 'access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 3600_000, - lastActiveAt: Date.now(), - canRefresh: true, - }, - }; - } - if (path === '/api/auth/session/sync') { - return { - success: true, - session: { - accessToken: 'access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 3600_000, - lastActiveAt, - canRefresh: true, - }, - }; - } - return { success: true }; - }); + it('opens projected agreement and recovery links with safe external-link attributes', async () => { + renderLogin(); - render( - - - , - ); - - await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/auth/session/sync', - expect.any(Object), - )); - expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false); - - fireEvent.keyDown(window, { key: 'a' }); - - expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false); + for (const name of ['用户协议', '隐私政策', '忘记密码?']) { + const link = await screen.findByRole('link', { name }); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + } }); - it('opens only the local image workspace anonymously when the explicit development mode is active', async () => { - useSettingsStore.setState({ setupComplete: true }); - window.electron.imageWorkspaceLocalDevelopment = true; - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/works/image-workspace') { - return { - success: true, - status: 200, - data: { - capabilities: { - conversation: true, - generation: true, - image: true, - video: true, - }, - workspaces: [], - }, - }; - } - return { success: true }; + it('gates password login on agreement and submits the exact credentials before syncing models', async () => { + loginWithPassword.mockImplementation(async () => { + useAuthStore.setState({ accessToken: 'password-access-token' }); }); + renderLogin(); - const { unmount } = render( - - - , - ); - - expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument(); - expect(screen.queryByText('本地开发')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '在浏览器中继续' })).not.toBeInTheDocument(); - unmount(); - - render( - - - , - ); - expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument(); - }); - - it('opens the project conversation from its direct route for an initialized active project', async () => { - vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true); - const project = { - id: 'project-default-chat', - path: '/tmp/project-default-chat', - name: 'default-chat', - createdAt: '2026-07-12T00:00:00.000Z', - updatedAt: '2026-07-12T00:00:00.000Z', - lastOpenedAt: '2026-07-12T00:00:00.000Z', - }; - const config = { ...createProjectConfig(), initialized: true }; - useOpencodeStore.setState({ projects: [project], activeProject: project }); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/opencode/projects') return { projects: [project], activeProject: project }; - if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] }; - if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 }; - if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] }; - if (path === '/api/provider-accounts') return []; - if (path === '/api/provider-accounts/key-info') return []; - if (path === '/api/provider-vendors') return []; - if (path === '/api/provider-accounts/default') return { accountId: null }; - return { success: true }; - }); - - render( - - - , - ); - - expect(await screen.findByTestId('chat-operation-page')).toBeInTheDocument(); - }); - - it('keeps the module chooser at the default route without an active project', async () => { - vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true); - - render( - - - , - ); - - expect(await screen.findByTestId('ai-module-selection-page')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument(); - }); - - it('keeps an uninitialized active project on project configuration from its direct route', async () => { - vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true); - const project = { - id: 'project-uninitialized', - path: '/tmp/project-uninitialized', - name: 'uninitialized', - createdAt: '2026-07-12T00:00:00.000Z', - updatedAt: '2026-07-12T00:00:00.000Z', - lastOpenedAt: '2026-07-12T00:00:00.000Z', - }; - const config = { ...createProjectConfig(), initialized: false }; - useOpencodeStore.setState({ projects: [project], activeProject: project }); - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/opencode/projects') return { projects: [project], activeProject: project }; - if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] }; - if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 }; - if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] }; - if (path === '/api/provider-accounts') return []; - if (path === '/api/provider-accounts/key-info') return []; - if (path === '/api/provider-vendors') return []; - if (path === '/api/provider-accounts/default') return { accountId: null }; - return { success: true }; - }); - - render( - - - , - ); - - expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument(); - expect(screen.queryByTestId('chat-operation-page')).not.toBeInTheDocument(); - }); - - it('starts browser authorization and enters the app after success', async () => { - hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/auth/browser/start') { - return { - success: true, - token: { - access_token: 'access-token', - token_type: 'Bearer', - expires_in: 43200, - username: 'zhangsan', - user_id: '1', - }, - session: { - accessToken: 'access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 43_200_000, - lastActiveAt: Date.now(), - canRefresh: true, - }, - }; - } - if (path === '/api/provider-accounts/import-user-model-config') { - return { - success: true, - account: { - id: 'niancode-user-models', - vendorId: 'custom', - label: 'Makelore Models', - authMode: 'api_key', - model: 'gpt-4.1-mini', - enabled: true, - isDefault: true, - createdAt: '2026-07-07T00:00:00.000Z', - updatedAt: '2026-07-07T00:00:00.000Z', - }, - importedModels: ['gpt-4.1-mini'], - }; - } - if (path === '/api/provider-accounts') return []; - if (path === '/api/provider-accounts/key-info') return []; - if (path === '/api/provider-vendors') return []; - if (path === '/api/provider-accounts/default') return { accountId: null }; - return { success: true }; - }); - - render( - - - } /> - Module Selection} /> - - , - ); - - expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Captcha')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' })); + fireEvent.change(screen.getByLabelText('用户名'), { target: { value: ' zhangsan ' } }); + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } }); + const submit = screen.getByRole('button', { name: '登录' }); + expect(submit).toBeDisabled(); + fireEvent.click(screen.getByRole('checkbox')); + expect(submit).toBeEnabled(); + fireEvent.click(submit); await screen.findByText('Module Selection'); - await waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', { - method: 'POST', - }); - }); + expect(loginWithPassword).toHaveBeenCalledWith({ username: 'zhangsan', password: 'secret-password' }); + expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token'); }); - it('syncs the current Works Square user model config after browser login', async () => { + it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => { + loginWithMobile.mockImplementation(async () => { + useAuthStore.setState({ accessToken: 'mobile-access-token' }); + }); + renderLogin(); + switchToMobile(); + + await screen.findByAltText('图形验证码'); + expect(screen.getByLabelText('短信验证码')).toHaveAttribute('autocomplete', 'one-time-code'); + fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '20123456789' } }); + fireEvent.change(screen.getByLabelText('短信验证码'), { target: { value: '123456' } }); + fireEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('button', { name: '登录' })).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + await screen.findByText('Module Selection'); + expect(loginWithMobile).toHaveBeenCalledWith({ phone: '13800138000', code: '123456' }); + expect(importUserModelConfig).toHaveBeenCalledWith('mobile-access-token'); + }); + + it('fetches an uncached UUID captcha and sends the exact image challenge without consuming an SMS code response', async () => { hostApiFetchMock.mockImplementation(async (path: string) => { - if (path === '/api/auth/browser/start') { - return { - success: true, - token: { - access_token: 'fresh-access-token', - token_type: 'Bearer', - expires_in: 43200, - username: 'zhangsan', - user_id: '1', - }, - session: { - accessToken: 'fresh-access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 43_200_000, - lastActiveAt: Date.now(), - canRefresh: true, - }, - }; - } - if (path === '/api/provider-accounts/import-user-model-config') { - return { - success: true, - account: { - id: 'niancode-user-models', - vendorId: 'custom', - label: 'Makelore Models', - authMode: 'api_key', - model: 'gpt-4.1-mini', - fallbackModels: ['claude-3-5-haiku'], - enabled: true, - isDefault: true, - createdAt: '2026-07-07T00:00:00.000Z', - updatedAt: '2026-07-07T00:00:00.000Z', - }, - importedModels: ['gpt-4.1-mini', 'claude-3-5-haiku'], - }; - } - if (path === '/api/provider-accounts/default') { - return { accountId: 'niancode-user-models' }; - } - return []; + if (path === '/api/auth/public-config') return { success: true, links: {} }; + if (path.includes('/mobile-image-code?')) return resolveCaptcha(); + if (path === '/api/auth/mobile-code') return { success: true, code: 'server-mock-code-must-not-be-used' }; + return { success: true }; }); + renderLogin(); + switchToMobile(); - render( - - - } /> - Module Selection} /> - - , + await screen.findByAltText('图形验证码'); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000001', + { cache: 'no-store' }, ); + fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } }); + fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: ' AbCd ' } }); + fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); - fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' })); - - await waitFor(() => { - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', { - method: 'POST', - body: JSON.stringify({ - accessToken: 'fresh-access-token', - runtimeRefresh: 'apply', - }), - }); - }); + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/mobile-code', { + method: 'POST', + cache: 'no-store', + body: JSON.stringify({ + phone: '13800138000', + imageRandomStr: '00000000-0000-4000-8000-000000000001', + imageCode: 'AbCd', + }), + })); + expect(screen.getByLabelText('短信验证码')).toHaveValue(''); + expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled(); + expect(screen.queryByAltText('图形验证码')).not.toBeInTheDocument(); }); - it('keeps the user on login when model config sync fails after browser login', async () => { - hostApiFetchMock - .mockResolvedValueOnce({ - success: true, - token: { - access_token: 'fresh-access-token', - token_type: 'Bearer', - expires_in: 43200, - username: 'zhangsan', - user_id: '1', - }, - session: { - accessToken: 'fresh-access-token', - tokenType: 'Bearer', - expiresAt: Date.now() + 43_200_000, - lastActiveAt: Date.now(), - canRefresh: true, - }, - }) - .mockRejectedValueOnce(new Error('sync failed')) - .mockResolvedValueOnce({ success: true }); - - render( - - - } /> - Module Selection} /> - - , - ); - - fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' })); - - expect(await screen.findByText('登录失败,请稍后重试。')).toBeInTheDocument(); - expect(screen.queryByText('Module Selection')).not.toBeInTheDocument(); - expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/logout', { - method: 'POST', - body: JSON.stringify({ accessToken: 'fresh-access-token' }), + it('rejects a stale captcha response after manual refresh', async () => { + const first = createDeferred>(); + const second = createDeferred>(); + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002'); + hostApiFetchMock.mockImplementation((path: string) => { + if (path === '/api/auth/public-config') return Promise.resolve({ success: true, links: {} }); + if (path.includes('000000000001')) return first.promise; + if (path.includes('000000000002')) return second.promise; + return Promise.resolve({ success: true }); }); + renderLogin(); + switchToMobile(); + + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(expect.stringContaining('000000000001'), { cache: 'no-store' })); + fireEvent.click(screen.getByRole('button', { name: '刷新图形验证码' })); + await act(async () => second.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'new-image' } })); + expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image'); + + await act(async () => first.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'stale-image' } })); + expect(screen.getByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image'); + }); + + it('loads a fresh UUID captcha each time the user switches into SMS login outside cooldown', async () => { + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002'); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/auth/public-config') return { success: true, links: {} }; + if (path.includes('000000000001')) { + return { success: true, image: { mimeType: 'image/png', dataBase64: 'first-image' } }; + } + if (path.includes('000000000002')) { + return { success: true, image: { mimeType: 'image/png', dataBase64: 'second-image' } }; + } + return { success: true }; + }); + renderLogin(); + + switchToMobile(); + expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,first-image'); + fireEvent.click(screen.getByRole('tab', { name: '密码登录' })); + switchToMobile(); + + await waitFor(() => expect(screen.getByAltText('图形验证码')).toHaveAttribute( + 'src', + 'data:image/png;base64,second-image', + )); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002', + { cache: 'no-store' }, + ); + }); + + it('immediately replaces the image challenge when SMS sending fails and presents a safe error', async () => { + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002'); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/auth/public-config') return { success: true, links: {} }; + if (path.includes('/mobile-image-code?')) return resolveCaptcha(path); + if (path === '/api/auth/mobile-code') return { success: false, error: '502 Bad Gateway secret' }; + return { success: true }; + }); + renderLogin(); + switchToMobile(); + await screen.findByAltText('图形验证码'); + + fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } }); + fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } }); + fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); + + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith( + expect.stringContaining('000000000002'), + { cache: 'no-store' }, + )); + expect(screen.getByRole('alert')).toHaveTextContent('验证码发送失败,请稍后重试。'); + expect(screen.getByLabelText('图形验证码')).toHaveValue(''); + }); + + it('starts a 60-second cooldown after SMS send and fetches a fresh challenge when it expires', async () => { + vi.useFakeTimers(); + vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') + .mockReturnValueOnce('00000000-0000-4000-8000-000000000002'); + renderLogin(); + switchToMobile(); + await act(async () => Promise.resolve()); + + fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } }); + fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } }); + fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); + await act(async () => Promise.resolve()); + expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002', + { cache: 'no-store' }, + ); + expect(screen.getByAltText('图形验证码')).toBeInTheDocument(); + }); + + it('logs out and stays on login when post-login model synchronization fails', async () => { + loginWithPassword.mockImplementation(async () => { + useAuthStore.setState({ accessToken: 'fresh-access-token' }); + }); + importUserModelConfig.mockRejectedValue(new Error('sync failed with token fresh-access-token')); + logout.mockImplementation(async () => { + useAuthStore.setState({ accessToken: null }); + }); + renderLogin(); + fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } }); + fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } }); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请稍后重试。'); + expect(screen.queryByText('fresh-access-token')).not.toBeInTheDocument(); + expect(screen.queryByText('Module Selection')).not.toBeInTheDocument(); + expect(logout).toHaveBeenCalledOnce(); + }); + + it('keeps upstream HTML errors concise without exposing their contents', async () => { + useAuthStore.setState({ error: '502 Bad Gatewayupstream secret' }); + renderLogin(); + + await screen.findByRole('link', { name: '用户协议' }); + expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。'); + expect(screen.queryByText(/upstream secret/i)).not.toBeInTheDocument(); }); });