From 6973783aa3d0bf58249c91267c314cb778e5560d Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 12 Aug 2026 10:19:28 +0800 Subject: [PATCH] fix(web): surface actionable login diagnostics --- apps/web/src/app/App.tsx | 38 ++--------- apps/web/src/components/LoginScreen.tsx | 7 +- apps/web/src/lib/api.test.mjs | 27 ++++++++ apps/web/src/lib/api.ts | 31 ++++++++- apps/web/src/lib/login-error.test.mjs | 38 +++++++++++ apps/web/src/lib/login-error.ts | 88 +++++++++++++++++++++---- packages/shared/src/i18n/en-US.ts | 6 +- packages/shared/src/i18n/zh-CN.ts | 6 +- 8 files changed, 192 insertions(+), 49 deletions(-) diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 314453e7..354cf205 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -15,7 +15,7 @@ import { getCachedDesktopSession, saveDesktopApiBaseUrl, } from "@/lib/api"; -import { classifyLoginError, type LoginProblem } from "@/lib/login-error"; +import { classifyLoginError, getLoginProblemMessageKey } from "@/lib/login-error"; import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes"; import { isBrowserOffline } from "@/lib/network-status"; import type { AuthSession } from "@edgeever/shared"; @@ -160,38 +160,12 @@ const AuthenticatedWorkspace = () => { : sessionQuery.error ? classifyLoginError(sessionQuery.error, "session") : null; - const problemMessage = (value: LoginProblem) => { - switch (value.kind) { - case "invalidInstanceUrl": - return t("login.desktopInstanceUrlInvalid"); - case "instanceUnreachable": - return t("login.instanceUnreachable"); - case "instanceApiNotFound": - return t("login.instanceApiNotFound"); - case "invalidCredentials": - return t("login.invalidCredentials"); - case "sessionExpired": - return t("login.sessionExpired"); - case "loginRateLimited": - return t("login.loginRateLimited"); - case "authNotConfigured": - return t("login.authNotConfigured"); - case "databaseNotReady": - return t("login.databaseNotReady"); - case "passwordHashInvalid": - return t("login.passwordHashInvalid"); - case "instanceServerError": - return t("login.instanceServerError", { status: value.status }); - case "requestRejected": - return t("login.requestRejected", { status: value.status }); - case "invalidResponse": - return t("login.invalidInstanceResponse"); - case "unexpected": - return t("login.unexpectedError"); - } - }; const loginError = problem - ? { message: problemMessage(problem), diagnosticCode: problem.diagnosticCode } + ? { + message: t(getLoginProblemMessageKey(problem), "status" in problem ? { status: problem.status } : undefined), + diagnosticCode: problem.diagnosticCode, + rayId: problem.rayId, + } : null; if (desktopBridge?.isAvailable && !desktopScopeReady) { diff --git a/apps/web/src/components/LoginScreen.tsx b/apps/web/src/components/LoginScreen.tsx index 7373d8f1..777d5abe 100644 --- a/apps/web/src/components/LoginScreen.tsx +++ b/apps/web/src/components/LoginScreen.tsx @@ -6,7 +6,7 @@ import { GitHubRepositoryLink } from "@/components/GitHubRepositoryLink"; import { Input } from "@/components/ui/input"; interface LoginScreenProps { - error: { message: string; diagnosticCode: string } | null; + error: { message: string; diagnosticCode: string; rayId?: string } | null; instanceUrl?: string; isSubmitting: boolean; onSubmit: (payload: { instanceUrl?: string; username: string; password: string }) => void; @@ -71,6 +71,11 @@ export const LoginScreen = ({ error, instanceUrl: initialInstanceUrl, isSubmitti

{t("login.diagnosticCode", { code: error.diagnosticCode })}

+ {error.rayId ? ( +

+ {t("login.cloudflareRayId", { id: error.rayId })} +

+ ) : null} ) : null} diff --git a/apps/web/src/lib/api.test.mjs b/apps/web/src/lib/api.test.mjs index 2d29c693..0e12e3e0 100644 --- a/apps/web/src/lib/api.test.mjs +++ b/apps/web/src/lib/api.test.mjs @@ -62,6 +62,7 @@ afterAll(() => { }); const { + ApiRequestError, DESKTOP_API_BASE_URL_STORAGE_KEY, api, cacheDesktopSession, @@ -315,4 +316,30 @@ describe("desktop instance setup", () => { authorization: "Bearer replacement-session-token", }); }); + + test("preserves Cloudflare response diagnostics for login failures", async () => { + globalThis.fetch = async () => new Response("challenge", { + status: 403, + headers: { + "CF-Mitigated": "challenge", + "CF-Ray": "abc123-SJC", + "Content-Type": "text/html", + }, + }); + + try { + await api.login({ username: "admin", password: "secret" }); + throw new Error("Expected login to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ApiRequestError); + expect(error).toMatchObject({ + status: 403, + responseDiagnostics: { + cloudflareMitigated: true, + isEdgeEverApiError: false, + rayId: "abc123-SJC", + }, + }); + } + }); }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 69fd06d9..ad1dd463 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -258,16 +258,30 @@ export class ApiRequestError extends Error { status: number; code?: string; details?: unknown; + responseDiagnostics?: ApiResponseDiagnostics; - constructor(message: string, status: number, code?: string, details?: unknown) { + constructor( + message: string, + status: number, + code?: string, + details?: unknown, + responseDiagnostics?: ApiResponseDiagnostics, + ) { super(message); this.name = "ApiRequestError"; this.status = status; this.code = code; this.details = details; + this.responseDiagnostics = responseDiagnostics; } } +export type ApiResponseDiagnostics = { + cloudflareMitigated: boolean; + isEdgeEverApiError: boolean; + rayId?: string; +}; + let desktopSessionRejected = false; const isDesktopAuthenticationRequest = (path: string) => @@ -299,13 +313,20 @@ const request = async (path: string, init?: RequestInit): Promise => { if (!response.ok) { const body = await response.json().catch(() => null); + const rayId = response.headers.get("cf-ray")?.trim(); const error = body && typeof body === "object" && "error" in body ? (body as { error?: { code?: string; message?: string; details?: unknown } }).error : undefined; + const isEdgeEverApiError = Boolean(error && typeof error === "object"); const message = body && typeof body === "object" && "error" in body ? error?.message : response.statusText; + const responseDiagnostics: ApiResponseDiagnostics = { + cloudflareMitigated: response.headers.get("cf-mitigated") === "challenge", + isEdgeEverApiError, + ...(rayId ? { rayId } : {}), + }; if (response.status === 401) { if (isDesktop) { @@ -319,7 +340,13 @@ const request = async (path: string, init?: RequestInit): Promise => { } } - throw new ApiRequestError(message || "Request failed", response.status, error?.code, error?.details); + throw new ApiRequestError( + message || "Request failed", + response.status, + error?.code, + error?.details, + responseDiagnostics, + ); } const body = await response.json() as T; diff --git a/apps/web/src/lib/login-error.test.mjs b/apps/web/src/lib/login-error.test.mjs index c85ec3af..81de10e8 100644 --- a/apps/web/src/lib/login-error.test.mjs +++ b/apps/web/src/lib/login-error.test.mjs @@ -33,6 +33,44 @@ describe("login error classification", () => { }); }); + test("identifies Cloudflare challenges and preserves the Ray ID", () => { + const challenge = new ApiRequestError("Forbidden", 403, undefined, undefined, { + cloudflareMitigated: true, + isEdgeEverApiError: false, + rayId: "abc123-SJC", + }); + + expect(classifyLoginError(challenge, "login")).toEqual({ + kind: "cloudflareChallenge", + diagnosticCode: "cloudflare_challenge", + rayId: "abc123-SJC", + }); + }); + + test("distinguishes an edge security block from an EdgeEver forbidden response", () => { + const blocked = new ApiRequestError("Forbidden", 403, undefined, undefined, { + cloudflareMitigated: false, + isEdgeEverApiError: false, + rayId: "def456-NRT", + }); + const forbidden = new ApiRequestError("Forbidden", 403, "forbidden", undefined, { + cloudflareMitigated: false, + isEdgeEverApiError: true, + rayId: "ghi789-LAX", + }); + + expect(classifyLoginError(blocked, "login")).toEqual({ + kind: "securityPolicyBlocked", + diagnosticCode: "security_policy_blocked", + rayId: "def456-NRT", + }); + expect(classifyLoginError(forbidden, "login")).toEqual({ + kind: "requestForbidden", + diagnosticCode: "forbidden", + rayId: "ghi789-LAX", + }); + }); + test("identifies responses that are not EdgeEver JSON", () => { expect(classifyLoginError(new SyntaxError("Unexpected token"), "login")).toEqual({ kind: "invalidResponse", diff --git a/apps/web/src/lib/login-error.ts b/apps/web/src/lib/login-error.ts index dc5e8c51..26f5c5e9 100644 --- a/apps/web/src/lib/login-error.ts +++ b/apps/web/src/lib/login-error.ts @@ -2,7 +2,12 @@ import { ApiRequestError, DesktopInstanceUrlError } from "@/lib/api"; export type LoginErrorPhase = "session" | "login"; -export type LoginProblem = +type LoginProblemDetails = { + diagnosticCode: string; + rayId?: string; +}; + +export type LoginProblem = LoginProblemDetails & ( | { kind: "invalidInstanceUrl"; diagnosticCode: "instance_url_invalid" } | { kind: "instanceUnreachable"; diagnosticCode: "network_unreachable" } | { kind: "instanceApiNotFound"; diagnosticCode: "edgeever_api_not_found" } @@ -12,10 +17,58 @@ export type LoginProblem = | { kind: "authNotConfigured"; diagnosticCode: "auth_not_configured" } | { kind: "databaseNotReady"; diagnosticCode: "database_not_ready" } | { kind: "passwordHashInvalid"; diagnosticCode: "password_hash_invalid" } + | { kind: "cloudflareChallenge"; diagnosticCode: "cloudflare_challenge" } + | { kind: "securityPolicyBlocked"; diagnosticCode: "security_policy_blocked" } + | { kind: "requestForbidden"; diagnosticCode: string } | { kind: "instanceServerError"; diagnosticCode: string; status: number } | { kind: "requestRejected"; diagnosticCode: string; status: number } | { kind: "invalidResponse"; diagnosticCode: "invalid_instance_response" } - | { kind: "unexpected"; diagnosticCode: "unexpected_login_error" }; + | { kind: "unexpected"; diagnosticCode: "unexpected_login_error" } +); + +export type LoginProblemMessageKey = + | "login.desktopInstanceUrlInvalid" + | "login.instanceUnreachable" + | "login.instanceApiNotFound" + | "login.invalidCredentials" + | "login.sessionExpired" + | "login.loginRateLimited" + | "login.authNotConfigured" + | "login.databaseNotReady" + | "login.passwordHashInvalid" + | "login.cloudflareChallenge" + | "login.securityPolicyBlocked" + | "login.requestForbidden" + | "login.instanceServerError" + | "login.requestRejected" + | "login.invalidInstanceResponse" + | "login.unexpectedError"; + +const withRayId = (problem: T, error: ApiRequestError): T => { + const rayId = error.responseDiagnostics?.rayId; + return rayId ? { ...problem, rayId } : problem; +}; + +export const getLoginProblemMessageKey = (problem: LoginProblem): LoginProblemMessageKey => { + switch (problem.kind) { + case "invalidInstanceUrl": return "login.desktopInstanceUrlInvalid"; + case "instanceUnreachable": return "login.instanceUnreachable"; + case "instanceApiNotFound": return "login.instanceApiNotFound"; + case "invalidCredentials": return "login.invalidCredentials"; + case "sessionExpired": return "login.sessionExpired"; + case "loginRateLimited": return "login.loginRateLimited"; + case "authNotConfigured": return "login.authNotConfigured"; + case "databaseNotReady": return "login.databaseNotReady"; + case "passwordHashInvalid": return "login.passwordHashInvalid"; + case "cloudflareChallenge": return "login.cloudflareChallenge"; + case "securityPolicyBlocked": return "login.securityPolicyBlocked"; + case "requestForbidden": return "login.requestForbidden"; + case "instanceServerError": return "login.instanceServerError"; + case "requestRejected": return "login.requestRejected"; + case "invalidResponse": return "login.invalidInstanceResponse"; + case "unexpected": return "login.unexpectedError"; + } +}; export const classifyLoginError = (error: unknown, phase: LoginErrorPhase): LoginProblem => { if (error instanceof DesktopInstanceUrlError) { @@ -23,34 +76,45 @@ export const classifyLoginError = (error: unknown, phase: LoginErrorPhase): Logi } if (error instanceof ApiRequestError) { + if (error.responseDiagnostics?.cloudflareMitigated) { + return withRayId({ kind: "cloudflareChallenge", diagnosticCode: "cloudflare_challenge" }, error); + } if (error.code === "auth_not_configured") { - return { kind: "authNotConfigured", diagnosticCode: "auth_not_configured" }; + return withRayId({ kind: "authNotConfigured", diagnosticCode: "auth_not_configured" }, error); } if (error.code === "database_not_ready") { - return { kind: "databaseNotReady", diagnosticCode: "database_not_ready" }; + return withRayId({ kind: "databaseNotReady", diagnosticCode: "database_not_ready" }, error); } if (error.code === "password_hash_invalid") { - return { kind: "passwordHashInvalid", diagnosticCode: "password_hash_invalid" }; + return withRayId({ kind: "passwordHashInvalid", diagnosticCode: "password_hash_invalid" }, error); } if (error.code === "login_rate_limited" || error.status === 429) { - return { kind: "loginRateLimited", diagnosticCode: "login_rate_limited" }; + return withRayId({ kind: "loginRateLimited", diagnosticCode: "login_rate_limited" }, error); } if (error.code === "unauthorized" || error.status === 401) { - return phase === "login" + return withRayId(phase === "login" ? { kind: "invalidCredentials", diagnosticCode: "invalid_credentials" } - : { kind: "sessionExpired", diagnosticCode: "session_expired" }; + : { kind: "sessionExpired", diagnosticCode: "session_expired" }, error); } if (error.status === 404) { - return { kind: "instanceApiNotFound", diagnosticCode: "edgeever_api_not_found" }; + return withRayId({ kind: "instanceApiNotFound", diagnosticCode: "edgeever_api_not_found" }, error); + } + if (error.status === 403) { + return withRayId( + error.responseDiagnostics?.isEdgeEverApiError + ? { kind: "requestForbidden", diagnosticCode: error.code || "http_403" } + : { kind: "securityPolicyBlocked", diagnosticCode: "security_policy_blocked" }, + error, + ); } if (error.status >= 500) { - return { kind: "instanceServerError", diagnosticCode: `http_${error.status}`, status: error.status }; + return withRayId({ kind: "instanceServerError", diagnosticCode: `http_${error.status}`, status: error.status }, error); } - return { + return withRayId({ kind: "requestRejected", diagnosticCode: error.code || `http_${error.status}`, status: error.status, - }; + }, error); } if (error instanceof TypeError) { diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 8baf6878..611393c7 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -56,11 +56,15 @@ export const enUS = { invalidCredentials: "The username or password is incorrect. Check both fields and try again.", sessionExpired: "Your login has expired. Sign in again.", loginRateLimited: "Too many attempts. Try again later.", + cloudflareChallenge: "A Cloudflare browser challenge intercepted the login API and cannot run in this page. Ask the instance administrator to adjust the WAF Challenge rule for login.", + securityPolicyBlocked: "A security policy blocked the request before it reached EdgeEver. Ask the instance administrator to check Cloudflare Security Events, Access, WAF, or reverse-proxy rules.", + requestForbidden: "The instance rejected the login request (HTTP 403). Ask the instance administrator to check its access policy.", instanceServerError: "The instance is temporarily unavailable. Try again later.", - requestRejected: "Sign-in failed. Check the form and try again.", + requestRejected: "The instance rejected the login request (HTTP {{status}}). Contact the instance administrator.", invalidInstanceResponse: "This is not a valid EdgeEver instance. Check the instance address.", unexpectedError: "Sign-in failed. Try again.", diagnosticCode: "Error code: {{code}}", + cloudflareRayId: "CF-Ray: {{id}}", desktopScopeUnavailable: "The desktop local data scope could not be switched. The workspace is paused to protect account data.", desktopScopeRetry: "Retry", passwordHashInvalid: "This account is not configured correctly. Ask the administrator to reset its password.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 49d429c0..661105d3 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -56,11 +56,15 @@ export const zhCN = { invalidCredentials: "账号或密码错误,请检查后重试。", sessionExpired: "登录已过期,请重新登录。", loginRateLimited: "尝试次数太多,请稍后再试。", + cloudflareChallenge: "Cloudflare 人机验证拦截了登录接口,当前页面无法完成该验证。请实例管理员调整针对登录接口的 WAF Challenge 规则。", + securityPolicyBlocked: "登录请求在到达 EdgeEver 前被安全策略拦截。请实例管理员检查 Cloudflare Security Events、Access、WAF 或反向代理规则。", + requestForbidden: "实例拒绝了登录请求(HTTP 403)。请实例管理员检查访问策略。", instanceServerError: "实例暂时出错,请稍后再试。", - requestRejected: "登录请求未成功,请检查填写内容后重试。", + requestRejected: "实例拒绝了登录请求(HTTP {{status}})。请联系实例管理员。", invalidInstanceResponse: "这个地址不是有效的 EdgeEver 实例,请检查实例地址。", unexpectedError: "登录失败,请重试。", diagnosticCode: "错误代码:{{code}}", + cloudflareRayId: "CF-Ray:{{id}}", desktopScopeUnavailable: "桌面本地数据空间暂时无法切换。为保护账户数据,工作区已暂停加载。", desktopScopeRetry: "重试", passwordHashInvalid: "账号配置异常,请联系管理员重置密码。",