fix(web): surface actionable login diagnostics

This commit is contained in:
tianma-if committed 2026-08-12 10:19:28 +08:00
1 parent 64fb6f5464
commit 6973783aa3
8 files changed
+192 -49

No files matched your search

+6 -32
View File
@@ -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) {
+6 -1
View File
@@ -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
<p className="mt-1 font-mono text-[11px] text-rose-500">
{t("login.diagnosticCode", { code: error.diagnosticCode })}
</p>
{error.rayId ? (
<p className="mt-0.5 break-all font-mono text-[11px] text-rose-500">
{t("login.cloudflareRayId", { id: error.rayId })}
</p>
) : null}
</div>
) : null}
+27
View File
@@ -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("<html>challenge</html>", {
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",
},
});
}
});
});
+29 -2
View File
@@ -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 <T>(path: string, init?: RequestInit): Promise<T> => {
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 <T>(path: string, init?: RequestInit): Promise<T> => {
}
}
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;
+38
View File
@@ -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",
+76 -12
View File
@@ -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 = <T extends LoginProblem>(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) {
+5 -1
View File
@@ -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.",
+5 -1
View File
@@ -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: "账号配置异常,请联系管理员重置密码。",