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) <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,15 @@ import { ApiClientError } from "@geo/http-client";
|
|||||||
|
|
||||||
const errorMessageMap: Record<string, string> = {
|
const errorMessageMap: Record<string, string> = {
|
||||||
invalid_credentials: "邮箱或密码错误",
|
invalid_credentials: "邮箱或密码错误",
|
||||||
|
login_rate_limited: "登录请求过于频繁",
|
||||||
|
login_locked: "登录已被临时保护",
|
||||||
|
login_in_progress: "登录请求正在处理中",
|
||||||
|
login_guard_unavailable: "登录保护暂时不可用",
|
||||||
no_tenant: "当前账号未关联租户",
|
no_tenant: "当前账号未关联租户",
|
||||||
not_authenticated: "请先登录",
|
not_authenticated: "请先登录",
|
||||||
missing_bearer_token: "请先登录",
|
missing_bearer_token: "请先登录",
|
||||||
invalid_access_token: "登录状态已失效",
|
invalid_access_token: "登录状态已失效",
|
||||||
|
user_disabled: "用户被停用,请联系客服",
|
||||||
invalid_refresh_token: "刷新令牌已失效",
|
invalid_refresh_token: "刷新令牌已失效",
|
||||||
refresh_session_expired: "登录已过期,请重新登录",
|
refresh_session_expired: "登录已过期,请重新登录",
|
||||||
refresh_token_mismatch: "刷新令牌校验失败,请重新登录",
|
refresh_token_mismatch: "刷新令牌校验失败,请重新登录",
|
||||||
|
|||||||
@@ -137,15 +137,27 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
initialized.value = true;
|
initialized.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loginPromise: Promise<UserInfo> | null = null;
|
||||||
|
|
||||||
async function login(payload: LoginRequest): Promise<UserInfo> {
|
async function login(payload: LoginRequest): Promise<UserInfo> {
|
||||||
const response = await authApi.login(payload);
|
if (loginPromise) {
|
||||||
setStoredTokens({
|
return loginPromise;
|
||||||
accessToken: response.access_token,
|
}
|
||||||
refreshToken: response.refresh_token,
|
|
||||||
expiresAt: response.expires_at,
|
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<void> {
|
async function logout(): Promise<void> {
|
||||||
@@ -164,6 +176,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
const value = membership.value;
|
const value = membership.value;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
value?.blocked ||
|
value?.blocked ||
|
||||||
|
value?.blocked_reason === "user_disabled" ||
|
||||||
value?.status === "expired" ||
|
value?.status === "expired" ||
|
||||||
value?.blocked_reason === "trial_plan_expired" ||
|
value?.blocked_reason === "trial_plan_expired" ||
|
||||||
value?.blocked_reason === "subscription_required" ||
|
value?.blocked_reason === "subscription_required" ||
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, readonly, shallowRef } from "vue";
|
import { computed, readonly, shallowRef } from "vue";
|
||||||
|
|
||||||
import { createApiClient } from "@geo/http-client";
|
import { ApiClientError, createApiClient } from "@geo/http-client";
|
||||||
import type {
|
import type {
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
DesktopClientInfo,
|
DesktopClientInfo,
|
||||||
@@ -38,6 +38,7 @@ const session = shallowRef<DesktopSession | null>(readStoredSession());
|
|||||||
const apiBaseURL = shallowRef(readStoredApiBaseURL());
|
const apiBaseURL = shallowRef(readStoredApiBaseURL());
|
||||||
const pending = shallowRef(false);
|
const pending = shallowRef(false);
|
||||||
const error = shallowRef<string | null>(null);
|
const error = shallowRef<string | null>(null);
|
||||||
|
let loginPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function readStoredSession(): DesktopSession | null {
|
function readStoredSession(): DesktopSession | null {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -336,7 +337,22 @@ async function syncRuntimeSessionToMain(next: DesktopSession | null = session.va
|
|||||||
await window.desktopBridge.app.syncRuntimeSession(buildRuntimeSessionPayload(next));
|
await window.desktopBridge.app.syncRuntimeSession(buildRuntimeSessionPayload(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(payload: LoginRequest): Promise<void> {
|
function formatLoginError(err: unknown): string {
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
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<void> {
|
||||||
pending.value = true;
|
pending.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
@@ -368,7 +384,7 @@ async function login(payload: LoginRequest): Promise<void> {
|
|||||||
desktopClient: registered.client,
|
desktopClient: registered.client,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "登录失败,请稍后重试";
|
error.value = formatLoginError(err);
|
||||||
persistSession(null);
|
persistSession(null);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -376,6 +392,17 @@ async function login(payload: LoginRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function login(payload: LoginRequest): Promise<void> {
|
||||||
|
if (loginPromise) {
|
||||||
|
return loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginPromise = performLogin({ ...payload }).finally(() => {
|
||||||
|
loginPromise = null;
|
||||||
|
});
|
||||||
|
return loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function enterPreview(): Promise<void> {
|
async function enterPreview(): Promise<void> {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
persistSession({
|
persistSession({
|
||||||
|
|||||||
@@ -33,21 +33,36 @@ export const useAuthStore = defineStore("ops-auth", () => {
|
|||||||
initialized.value = true;
|
initialized.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let loginPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
async function login(username: string, password: string, remember: boolean): Promise<void> {
|
async function login(username: string, password: string, remember: boolean): Promise<void> {
|
||||||
const result = await http.post<LoginResponse>("/auth/login", { username, password });
|
if (loginPromise) {
|
||||||
accessToken.value = result.access_token;
|
return loginPromise;
|
||||||
expiresAt.value = result.expires_at;
|
}
|
||||||
operator.value = result.operator;
|
|
||||||
persistent.value = remember;
|
const payload = { username, password, remember };
|
||||||
writeStoredSession(
|
loginPromise = http.post<LoginResponse>("/auth/login", {
|
||||||
{
|
username: payload.username,
|
||||||
accessToken: result.access_token,
|
password: payload.password,
|
||||||
expiresAt: result.expires_at,
|
}).then((result) => {
|
||||||
operator: result.operator,
|
accessToken.value = result.access_token;
|
||||||
},
|
expiresAt.value = result.expires_at;
|
||||||
remember,
|
operator.value = result.operator;
|
||||||
);
|
persistent.value = payload.remember;
|
||||||
writeLoginPreference({ remember, lastUsername: username });
|
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<void> {
|
async function refreshSelf(): Promise<void> {
|
||||||
|
|||||||
@@ -106,6 +106,25 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatLoginError(error: unknown): string {
|
||||||
|
if (error instanceof OpsApiError) {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
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<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
if (submitting.value) {
|
if (submitting.value) {
|
||||||
return;
|
return;
|
||||||
@@ -124,13 +143,7 @@ async function handleSubmit(): Promise<void> {
|
|||||||
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users";
|
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users";
|
||||||
await router.replace(redirect);
|
await router.replace(redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OpsApiError) {
|
errorMessage.value = formatLoginError(error);
|
||||||
errorMessage.value = error.detail || error.message;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage.value = error.message;
|
|
||||||
} else {
|
|
||||||
errorMessage.value = "登录失败,请稍后重试";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user