From 5216a4847c888ae540e5e4989375046f753d10d3 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 30 Apr 2026 01:07:32 +0800 Subject: [PATCH] feat(login-ui): dedupe submissions and surface guard error codes Collapse rapid double-submits into a single in-flight request across admin-web, ops-web and the desktop client so users cannot fire parallel login attempts and trip the new in-progress guard. Map the new login_rate_limited / login_locked / login_in_progress / login_guard_unavailable error codes to localized messages in every login surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/lib/errors.ts | 5 +++ apps/admin-web/src/stores/auth.ts | 27 +++++++++--- .../renderer/composables/useDesktopSession.ts | 33 ++++++++++++-- apps/ops-web/src/stores/auth.ts | 43 +++++++++++++------ apps/ops-web/src/views/LoginView.vue | 27 +++++++++--- 5 files changed, 104 insertions(+), 31 deletions(-) diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 5978f1b..97beb01 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -2,10 +2,15 @@ import { ApiClientError } from "@geo/http-client"; const errorMessageMap: Record = { invalid_credentials: "邮箱或密码错误", + login_rate_limited: "登录请求过于频繁", + login_locked: "登录已被临时保护", + login_in_progress: "登录请求正在处理中", + login_guard_unavailable: "登录保护暂时不可用", no_tenant: "当前账号未关联租户", not_authenticated: "请先登录", missing_bearer_token: "请先登录", invalid_access_token: "登录状态已失效", + user_disabled: "用户被停用,请联系客服", invalid_refresh_token: "刷新令牌已失效", refresh_session_expired: "登录已过期,请重新登录", refresh_token_mismatch: "刷新令牌校验失败,请重新登录", diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index af9bd71..0667ab7 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -137,15 +137,27 @@ export const useAuthStore = defineStore("auth", () => { initialized.value = true; } + let loginPromise: Promise | null = null; + async function login(payload: LoginRequest): Promise { - const response = await authApi.login(payload); - setStoredTokens({ - accessToken: response.access_token, - refreshToken: response.refresh_token, - expiresAt: response.expires_at, + if (loginPromise) { + return loginPromise; + } + + const loginPayload = { ...payload }; + loginPromise = authApi.login(loginPayload).then((response) => { + setStoredTokens({ + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiresAt: response.expires_at, + }); + setStoredUser(response.user); + return response.user; + }).finally(() => { + loginPromise = null; }); - setStoredUser(response.user); - return response.user; + + return loginPromise; } async function logout(): Promise { @@ -164,6 +176,7 @@ export const useAuthStore = defineStore("auth", () => { const value = membership.value; return Boolean( value?.blocked || + value?.blocked_reason === "user_disabled" || value?.status === "expired" || value?.blocked_reason === "trial_plan_expired" || value?.blocked_reason === "subscription_required" || diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index c6818ec..c3f292d 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -1,6 +1,6 @@ import { computed, readonly, shallowRef } from "vue"; -import { createApiClient } from "@geo/http-client"; +import { ApiClientError, createApiClient } from "@geo/http-client"; import type { AuthTokens, DesktopClientInfo, @@ -38,6 +38,7 @@ const session = shallowRef(readStoredSession()); const apiBaseURL = shallowRef(readStoredApiBaseURL()); const pending = shallowRef(false); const error = shallowRef(null); +let loginPromise: Promise | null = null; function readStoredSession(): DesktopSession | null { if (typeof window === "undefined") { @@ -336,7 +337,22 @@ async function syncRuntimeSessionToMain(next: DesktopSession | null = session.va await window.desktopBridge.app.syncRuntimeSession(buildRuntimeSessionPayload(next)); } -async function login(payload: LoginRequest): Promise { +function formatLoginError(err: unknown): string { + if (err instanceof ApiClientError) { + const messages: Record = { + invalid_credentials: "邮箱或密码错误", + login_rate_limited: "登录请求过于频繁", + login_locked: "登录已被临时保护", + login_in_progress: "登录请求正在处理中", + login_guard_unavailable: "登录保护暂时不可用", + }; + const message = messages[err.message] ?? err.message.replaceAll("_", " "); + return err.detail ? `${message},${err.detail}` : message; + } + return err instanceof Error ? err.message : "登录失败,请稍后重试"; +} + +async function performLogin(payload: LoginRequest): Promise { pending.value = true; error.value = null; @@ -368,7 +384,7 @@ async function login(payload: LoginRequest): Promise { desktopClient: registered.client, }); } catch (err) { - error.value = err instanceof Error ? err.message : "登录失败,请稍后重试"; + error.value = formatLoginError(err); persistSession(null); throw err; } finally { @@ -376,6 +392,17 @@ async function login(payload: LoginRequest): Promise { } } +async function login(payload: LoginRequest): Promise { + if (loginPromise) { + return loginPromise; + } + + loginPromise = performLogin({ ...payload }).finally(() => { + loginPromise = null; + }); + return loginPromise; +} + async function enterPreview(): Promise { error.value = null; persistSession({ diff --git a/apps/ops-web/src/stores/auth.ts b/apps/ops-web/src/stores/auth.ts index 9a929af..9785dcd 100644 --- a/apps/ops-web/src/stores/auth.ts +++ b/apps/ops-web/src/stores/auth.ts @@ -33,21 +33,36 @@ export const useAuthStore = defineStore("ops-auth", () => { initialized.value = true; } + let loginPromise: Promise | null = null; + async function login(username: string, password: string, remember: boolean): Promise { - const result = await http.post("/auth/login", { username, password }); - accessToken.value = result.access_token; - expiresAt.value = result.expires_at; - operator.value = result.operator; - persistent.value = remember; - writeStoredSession( - { - accessToken: result.access_token, - expiresAt: result.expires_at, - operator: result.operator, - }, - remember, - ); - writeLoginPreference({ remember, lastUsername: username }); + if (loginPromise) { + return loginPromise; + } + + const payload = { username, password, remember }; + loginPromise = http.post("/auth/login", { + username: payload.username, + password: payload.password, + }).then((result) => { + accessToken.value = result.access_token; + expiresAt.value = result.expires_at; + operator.value = result.operator; + persistent.value = payload.remember; + writeStoredSession( + { + accessToken: result.access_token, + expiresAt: result.expires_at, + operator: result.operator, + }, + payload.remember, + ); + writeLoginPreference({ remember: payload.remember, lastUsername: payload.username }); + }).finally(() => { + loginPromise = null; + }); + + return loginPromise; } async function refreshSelf(): Promise { diff --git a/apps/ops-web/src/views/LoginView.vue b/apps/ops-web/src/views/LoginView.vue index 53c79c9..9f59552 100644 --- a/apps/ops-web/src/views/LoginView.vue +++ b/apps/ops-web/src/views/LoginView.vue @@ -106,6 +106,25 @@ onMounted(() => { } }); +function formatLoginError(error: unknown): string { + if (error instanceof OpsApiError) { + const messages: Record = { + invalid_credentials: "账号或密码错误", + account_disabled: "账号已停用", + login_rate_limited: "登录请求过于频繁", + login_locked: "登录已被临时保护", + login_in_progress: "登录请求正在处理中", + login_guard_unavailable: "登录保护暂时不可用", + }; + const message = messages[error.message] ?? error.message; + return error.detail ? `${message},${error.detail}` : message; + } + if (error instanceof Error) { + return error.message; + } + return "登录失败,请稍后重试"; +} + async function handleSubmit(): Promise { if (submitting.value) { return; @@ -124,13 +143,7 @@ async function handleSubmit(): Promise { const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users"; await router.replace(redirect); } catch (error) { - if (error instanceof OpsApiError) { - errorMessage.value = error.detail || error.message; - } else if (error instanceof Error) { - errorMessage.value = error.message; - } else { - errorMessage.value = "登录失败,请稍后重试"; - } + errorMessage.value = formatLoginError(error); } finally { submitting.value = false; }