fix(web): surface actionable login diagnostics
This commit is contained in:
1 parent
64fb6f5464
commit
6973783aa3
8 files changed
+192
-49
No files matched your search
@@ -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,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}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "账号配置异常,请联系管理员重置密码。",
|
||||
|
||||
Reference in new issue
Block a user