Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0642f7cf87 | |||
| df6e15c122 | |||
| 94384cf75e | |||
| 555004e9fd |
@@ -2,7 +2,7 @@ import { Button } from "components/Button/Button";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
type ErrorResponse,
|
||||
isRouteErrorResponse,
|
||||
@@ -27,6 +27,10 @@ export const GlobalErrorBoundaryInner: FC<GlobalErrorBoundaryInnerProps> = ({
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("[GlobalErrorBoundary] Uncaught error:", error);
|
||||
}, [error]);
|
||||
|
||||
const coderVersion = metadata["build-info"].value?.version;
|
||||
const isRenderableError =
|
||||
error instanceof Error || isRouteErrorResponse(error);
|
||||
|
||||
@@ -28,6 +28,7 @@ export type AuthContextValue = {
|
||||
isSignedIn: boolean;
|
||||
isSigningIn: boolean;
|
||||
isUpdatingProfile: boolean;
|
||||
isError: boolean;
|
||||
user: User | undefined;
|
||||
permissions: Permissions | undefined;
|
||||
signInError: unknown;
|
||||
@@ -41,12 +42,26 @@ export const AuthContext = createContext<AuthContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Don't retry 401s — the user is genuinely not authenticated.
|
||||
const shouldRetryAuth = (failureCount: number, error: unknown): boolean => {
|
||||
if (isApiError(error) && error.response.status === 401) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
};
|
||||
const authRetryDelay = (attempt: number) =>
|
||||
Math.min(1000 * 2 ** attempt, 10000);
|
||||
|
||||
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const userMetadataState = metadata.user;
|
||||
|
||||
const meOptions = me(userMetadataState);
|
||||
const userQuery = useQuery(meOptions);
|
||||
const userQuery = useQuery({
|
||||
...meOptions,
|
||||
retry: shouldRetryAuth,
|
||||
retryDelay: authRetryDelay,
|
||||
});
|
||||
const hasFirstUserQuery = useQuery(hasFirstUser(userMetadataState));
|
||||
|
||||
const permissionsQuery = useQuery({
|
||||
@@ -55,6 +70,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
metadata.permissions,
|
||||
),
|
||||
enabled: userQuery.data !== undefined,
|
||||
retry: shouldRetryAuth,
|
||||
retryDelay: authRetryDelay,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -84,6 +101,18 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined;
|
||||
const isSigningIn = loginMutation.isPending;
|
||||
const isUpdatingProfile = updateProfileMutation.isPending;
|
||||
// Non-401 errors from the user query (e.g. network timeout, 500,
|
||||
// 502) represent a transient failure, not a sign-out. Exposing
|
||||
// this lets RequireAuth show a recoverable error screen instead
|
||||
// of crashing through to the error boundary.
|
||||
const isError =
|
||||
(userQuery.isError && !isSignedOut) ||
|
||||
(userQuery.isSuccess &&
|
||||
permissionsQuery.isError &&
|
||||
!(
|
||||
isApiError(permissionsQuery.error) &&
|
||||
permissionsQuery.error.response.status === 401
|
||||
));
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
logoutMutation.mutate();
|
||||
@@ -118,6 +147,7 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
isSignedIn,
|
||||
isSigningIn,
|
||||
isUpdatingProfile,
|
||||
isError,
|
||||
signOut,
|
||||
signIn,
|
||||
updateProfile,
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
renderWithAuth,
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { renderHook, screen } from "@testing-library/react";
|
||||
import { render, renderHook, screen } from "@testing-library/react";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { AuthContext, type AuthContextValue } from "./AuthProvider";
|
||||
import { RequireAuth } from "./RequireAuth";
|
||||
|
||||
describe("RequireAuth", () => {
|
||||
it("redirects to /login if user is not authenticated", async () => {
|
||||
@@ -31,6 +33,50 @@ describe("RequireAuth", () => {
|
||||
|
||||
await screen.findByText("Login");
|
||||
});
|
||||
|
||||
it("shows a recoverable error screen on non-401 API errors", () => {
|
||||
// Directly mock the auth context with isError=true to simulate
|
||||
// a non-401 failure (e.g. 500, network timeout) without relying
|
||||
// on real query retry timing.
|
||||
const authValue: AuthContextValue = {
|
||||
user: undefined,
|
||||
isLoading: false,
|
||||
isSignedOut: false,
|
||||
isSigningOut: false,
|
||||
isConfiguringTheFirstUser: false,
|
||||
isSignedIn: false,
|
||||
isSigningIn: false,
|
||||
isUpdatingProfile: false,
|
||||
isError: true,
|
||||
permissions: undefined,
|
||||
signInError: undefined,
|
||||
updateProfileError: undefined,
|
||||
signOut: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
};
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContext.Provider value={authValue}>
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/" element={<h1>Dashboard</h1>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</AuthContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Should show the connection error screen, not the dashboard
|
||||
// or the global error boundary.
|
||||
expect(screen.getByText("Unable to connect")).toBeDefined();
|
||||
expect(screen.getByTestId("retry-button")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createAuthWrapper = (override: Partial<AuthContextValue>) => {
|
||||
@@ -43,9 +89,8 @@ const createAuthWrapper = (override: Partial<AuthContextValue>) => {
|
||||
isSignedIn: false,
|
||||
isSigningIn: false,
|
||||
isUpdatingProfile: false,
|
||||
isError: false,
|
||||
permissions: undefined,
|
||||
authMethods: undefined,
|
||||
organizationIds: undefined,
|
||||
signInError: undefined,
|
||||
updateProfileError: undefined,
|
||||
signOut: vi.fn(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { API } from "api/api";
|
||||
import { isApiError } from "api/errors";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { ProxyProvider as ProductionProxyProvider } from "contexts/ProxyContext";
|
||||
import { DashboardProvider as ProductionDashboardProvider } from "modules/dashboard/DashboardProvider";
|
||||
@@ -26,7 +28,7 @@ export const RequireAuth: FC<RequireAuthProps> = ({
|
||||
ProxyProvider = ProductionProxyProvider,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
|
||||
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading, isError } =
|
||||
useAuthContext();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +73,10 @@ export const RequireAuth: FC<RequireAuthProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ConnectionErrorScreen />;
|
||||
}
|
||||
|
||||
// Authenticated pages have access to some contexts for knowing enabled experiments
|
||||
// and where to route workspace connections.
|
||||
return (
|
||||
@@ -81,3 +87,37 @@ export const RequireAuth: FC<RequireAuthProps> = ({
|
||||
</DashboardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-screen error shown when the user API call fails with a non-401
|
||||
* error (network timeout, 500, 502, etc.). Gives the user a simple
|
||||
* retry action instead of crashing to the global error boundary.
|
||||
*/
|
||||
const ConnectionErrorScreen: FC = () => {
|
||||
return (
|
||||
<div className="bg-surface-primary text-center w-full h-full flex justify-center items-center absolute inset-0">
|
||||
<main className="flex gap-6 w-full max-w-prose p-4 flex-col flex-nowrap">
|
||||
<div className="flex gap-2 flex-col items-center">
|
||||
<CoderIcon className="w-11 h-11" />
|
||||
<div className="text-content-primary flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold m-0">Unable to connect</h1>
|
||||
<p className="leading-6 m-0 text-content-secondary text-sm">
|
||||
We're having trouble reaching the server. This may be a
|
||||
temporary network issue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-nowrap justify-center gap-2">
|
||||
<Button
|
||||
className="min-w-32"
|
||||
onClick={() => window.location.reload()}
|
||||
data-testid="retry-button"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user