Compare commits
1
Commits
main
...
d4d5edecec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d5edecec |
@@ -40,6 +40,11 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||
Не добавляй собственный refresh/retry loop в caller.
|
||||
|
||||
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
|
||||
(например, деплой backend) не доказывают конец session: она сохраняется,
|
||||
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
|
||||
запускает refresh. Retry/backoff внутри refresh не добавляется.
|
||||
|
||||
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||
token stores не добавляются.
|
||||
|
||||
@@ -190,6 +190,30 @@ describe("AppRoutes", () => {
|
||||
expect(document.title).toBe("Карты — WealthLedger");
|
||||
});
|
||||
|
||||
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
|
||||
let isOutage = true;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
isOutage
|
||||
? new HttpResponse(null, { status: 502 })
|
||||
: HttpResponse.json({
|
||||
accessToken: "test-token",
|
||||
user: { email: "user@example.com", id: "user-1" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderRoutes("/accounts");
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
|
||||
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
|
||||
isOutage = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Button, Container } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import { type FC, type PropsWithChildren, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
|
||||
import { useAuth } from "@/features/auth";
|
||||
@@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback";
|
||||
|
||||
const Protected: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { ready, signedOut, user } = useAuth();
|
||||
const { ready, retry, signedOut, unavailable, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
||||
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
|
||||
// The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone.
|
||||
if (!user && unavailable)
|
||||
return (
|
||||
<Container py="xl" size={560}>
|
||||
<StateView
|
||||
action={<Button onClick={retry}>{t("auth.retry")}</Button>}
|
||||
error
|
||||
text={t("auth.unavailableText")}
|
||||
title={t("auth.unavailableTitle")}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
const loginState = signedOut && wasAuthenticated
|
||||
? undefined
|
||||
: { returnTo: `${location.pathname}${location.search}` };
|
||||
|
||||
@@ -7,7 +7,12 @@ type AuthContextValue = {
|
||||
logout: () => Promise<void>;
|
||||
ready: boolean;
|
||||
register: (email: string, password: string) => Promise<void>;
|
||||
retry: () => void;
|
||||
signedOut: boolean;
|
||||
/**
|
||||
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
|
||||
*/
|
||||
unavailable: boolean;
|
||||
user: undefined | User;
|
||||
};
|
||||
|
||||
|
||||
@@ -92,8 +92,10 @@ describe("AuthProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
||||
it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
));
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||
@@ -103,12 +105,54 @@ describe("AuthProvider", () => {
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.unavailable).toBe(false);
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
|
||||
it.each([
|
||||
["a transport error", () => HttpResponse.error()],
|
||||
["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })],
|
||||
["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })],
|
||||
])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => {
|
||||
server.use(http.post("*/api/v1/auth/refresh", failure));
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(["cached-query"], { value: "kept" });
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||
|
||||
expect(result.current.user).toBeUndefined();
|
||||
expect(result.current.unavailable).toBe(true);
|
||||
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" });
|
||||
});
|
||||
|
||||
it("restores the session when retry succeeds after an outage", async () => {
|
||||
let isOutage = true;
|
||||
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||
isOutage
|
||||
? new HttpResponse(null, { status: 502 })
|
||||
: HttpResponse.json({
|
||||
accessToken: "restored-token",
|
||||
user: { email: "user@example.com", id: "user-1" },
|
||||
}),
|
||||
));
|
||||
const queryClient = createTestQueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||
isOutage = false;
|
||||
act(() => result.current.retry());
|
||||
|
||||
await waitFor(() => expect(result.current.user?.id).toBe("user-1"));
|
||||
expect(result.current.unavailable).toBe(false);
|
||||
expect(await requestAuthorization()).toBe("Bearer restored-token");
|
||||
});
|
||||
|
||||
it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => {
|
||||
let refreshCalls = 0;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () => {
|
||||
@@ -137,10 +181,11 @@ describe("AuthProvider", () => {
|
||||
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(response.response.status).toBe(401);
|
||||
await waitFor(() => expect(result.current.user).toBeUndefined());
|
||||
expect(await requestAuthorization()).toBeNull();
|
||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
||||
// An unreachable server does not prove the session ended; the next 401 refreshes again.
|
||||
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||
expect(result.current.user?.id).toBe("user-1");
|
||||
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" });
|
||||
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]);
|
||||
});
|
||||
|
||||
it("clears connection status and financial cache independently on logout", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
import { type User } from "./auth-context";
|
||||
|
||||
// A 5xx or network error means the server is unreachable, not that the session ended.
|
||||
const HTTP_SERVER_ERROR = 500;
|
||||
|
||||
type Authentication
|
||||
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
@@ -32,6 +35,8 @@ function useAuthProviderVm() {
|
||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [signedOut, setSignedOut] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
const unavailableRef = useRef(false);
|
||||
|
||||
const generationRef = useRef(0);
|
||||
const userRef = useRef<undefined | User>(undefined);
|
||||
@@ -49,12 +54,18 @@ function useAuthProviderVm() {
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const markUnavailable = useCallback((isUnavailable: boolean) => {
|
||||
unavailableRef.current = isUnavailable;
|
||||
setUnavailable(isUnavailable);
|
||||
}, []);
|
||||
|
||||
const applyAuthentication = useCallback(
|
||||
(authentication: Authentication) => {
|
||||
const changedIdentity
|
||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||
|
||||
setSignedOut(false);
|
||||
markUnavailable(false);
|
||||
accessTokenRef.current = authentication.accessToken;
|
||||
|
||||
if (changedIdentity) {
|
||||
@@ -67,16 +78,17 @@ function useAuthProviderVm() {
|
||||
userRef.current = authentication.user;
|
||||
setUser(authentication.user);
|
||||
},
|
||||
[queryClient],
|
||||
[markUnavailable, queryClient],
|
||||
);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
markUnavailable(false);
|
||||
generationRef.current += 1;
|
||||
userRef.current = undefined;
|
||||
setUser(undefined);
|
||||
accessTokenRef.current = undefined;
|
||||
queryClient.clear();
|
||||
}, [queryClient]);
|
||||
}, [markUnavailable, queryClient]);
|
||||
|
||||
const refresh = useCallback((): Promise<boolean> => {
|
||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||
@@ -86,11 +98,12 @@ function useAuthProviderVm() {
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
const { data, response } = await apiClient.POST("/api/v1/auth/refresh");
|
||||
if (started !== generationRef.current) return false;
|
||||
|
||||
if (!data) {
|
||||
clearSession();
|
||||
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
|
||||
else clearSession();
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -100,7 +113,7 @@ function useAuthProviderVm() {
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
if (started === generationRef.current) clearSession();
|
||||
if (started === generationRef.current) markUnavailable(true);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -112,7 +125,7 @@ function useAuthProviderVm() {
|
||||
refreshPromiseRef.current = pending;
|
||||
|
||||
return pending;
|
||||
}, [applyAuthentication, clearSession, serialize]);
|
||||
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().finally(() => setReady(true));
|
||||
@@ -146,6 +159,7 @@ function useAuthProviderVm() {
|
||||
clear: clearSession,
|
||||
generation: () => generationRef.current,
|
||||
refresh,
|
||||
unavailable: () => unavailableRef.current,
|
||||
});
|
||||
});
|
||||
useEffect(() => () => configureAuthClient(), []);
|
||||
@@ -157,7 +171,9 @@ function useAuthProviderVm() {
|
||||
ready,
|
||||
register: (email: string, password: string) =>
|
||||
authenticate("/api/v1/auth/register", email, password),
|
||||
retry: () => void refresh(),
|
||||
signedOut,
|
||||
unavailable,
|
||||
user,
|
||||
};
|
||||
|
||||
|
||||
@@ -67,6 +67,25 @@ describe("apiClient authentication", () => {
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the session when refresh failed because the server was unreachable", async () => {
|
||||
const clear = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () =>
|
||||
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "expired",
|
||||
clear,
|
||||
refresh: () => Promise.resolve(false),
|
||||
unavailable: () => true,
|
||||
});
|
||||
|
||||
await apiClient.GET("/api/v1/brokerage-accounts");
|
||||
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||
let authorization: null | string
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ type AuthHandlers = {
|
||||
clear: () => void;
|
||||
generation?: () => number;
|
||||
refresh: () => Promise<boolean>;
|
||||
/**
|
||||
True after a refresh failed transiently (network/5xx): the session is not proven gone.
|
||||
*/
|
||||
unavailable?: () => boolean;
|
||||
};
|
||||
|
||||
type ReadAttempt = {
|
||||
@@ -100,7 +104,8 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||
|
||||
if (!(await sessionState.handlers.refresh())) {
|
||||
if (isCurrent()) sessionState.handlers.clear();
|
||||
if (isCurrent() && !sessionState.handlers.unavailable?.())
|
||||
sessionState.handlers.clear();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ const ru = {
|
||||
passwordConfirmation: "Подтвердите пароль",
|
||||
register: "Регистрация",
|
||||
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
|
||||
retry: "Повторить",
|
||||
unavailableText: "Сервер временно недоступен. Ваш вход сохранён, попробуйте через минуту.",
|
||||
unavailableTitle: "Не удалось подключиться",
|
||||
validation: {
|
||||
invalidEmail: "Некорректный email",
|
||||
invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
|
||||
|
||||
Reference in New Issue
Block a user