Compare commits
1
Commits
v1.2.1
...
d4d5edecec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d5edecec |
@@ -40,6 +40,11 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
|
|||||||
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
|
||||||
Не добавляй собственный refresh/retry loop в caller.
|
Не добавляй собственный refresh/retry loop в caller.
|
||||||
|
|
||||||
|
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
|
||||||
|
(например, деплой backend) не доказывают конец session: она сохраняется,
|
||||||
|
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
|
||||||
|
запускает refresh. Retry/backoff внутри refresh не добавляется.
|
||||||
|
|
||||||
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
|
||||||
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
|
||||||
token stores не добавляются.
|
token stores не добавляются.
|
||||||
|
|||||||
@@ -190,6 +190,30 @@ describe("AppRoutes", () => {
|
|||||||
expect(document.title).toBe("Карты — WealthLedger");
|
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 () => {
|
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
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 { type FC, type PropsWithChildren, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Navigate, useLocation } from "react-router";
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
@@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback";
|
|||||||
|
|
||||||
const Protected: FC<PropsWithChildren> = (props) => {
|
const Protected: FC<PropsWithChildren> = (props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
const { ready, signedOut, user } = useAuth();
|
const { ready, retry, signedOut, unavailable, user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
const [wasAuthenticated, setWasAuthenticated] = useState(false);
|
||||||
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
if (user && !wasAuthenticated) setWasAuthenticated(true);
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
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
|
const loginState = signedOut && wasAuthenticated
|
||||||
? undefined
|
? undefined
|
||||||
: { returnTo: `${location.pathname}${location.search}` };
|
: { returnTo: `${location.pathname}${location.search}` };
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ type AuthContextValue = {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
register: (email: string, password: string) => Promise<void>;
|
register: (email: string, password: string) => Promise<void>;
|
||||||
|
retry: () => void;
|
||||||
signedOut: boolean;
|
signedOut: boolean;
|
||||||
|
/**
|
||||||
|
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
|
||||||
|
*/
|
||||||
|
unavailable: boolean;
|
||||||
user: undefined | User;
|
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 () => {
|
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.error()));
|
server.use(http.post("*/api/v1/auth/refresh", () =>
|
||||||
|
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
|
||||||
|
));
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||||
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
|
||||||
@@ -103,12 +105,54 @@ describe("AuthProvider", () => {
|
|||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
|
|
||||||
expect(result.current.user).toBeUndefined();
|
expect(result.current.user).toBeUndefined();
|
||||||
|
expect(result.current.unavailable).toBe(false);
|
||||||
expect(await requestAuthorization()).toBeNull();
|
expect(await requestAuthorization()).toBeNull();
|
||||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).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;
|
let refreshCalls = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/refresh", () => {
|
http.post("*/api/v1/auth/refresh", () => {
|
||||||
@@ -137,10 +181,11 @@ describe("AuthProvider", () => {
|
|||||||
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
const response = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
expect(response.response.status).toBe(401);
|
expect(response.response.status).toBe(401);
|
||||||
await waitFor(() => expect(result.current.user).toBeUndefined());
|
// An unreachable server does not prove the session ended; the next 401 refreshes again.
|
||||||
expect(await requestAuthorization()).toBeNull();
|
await waitFor(() => expect(result.current.unavailable).toBe(true));
|
||||||
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
|
expect(result.current.user?.id).toBe("user-1");
|
||||||
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
|
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 () => {
|
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";
|
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
|
type Authentication
|
||||||
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ function useAuthProviderVm() {
|
|||||||
const accessTokenRef = useRef<string | undefined>(undefined);
|
const accessTokenRef = useRef<string | undefined>(undefined);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [signedOut, setSignedOut] = useState(false);
|
const [signedOut, setSignedOut] = useState(false);
|
||||||
|
const [unavailable, setUnavailable] = useState(false);
|
||||||
|
const unavailableRef = useRef(false);
|
||||||
|
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const userRef = useRef<undefined | User>(undefined);
|
const userRef = useRef<undefined | User>(undefined);
|
||||||
@@ -49,12 +54,18 @@ function useAuthProviderVm() {
|
|||||||
return next;
|
return next;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markUnavailable = useCallback((isUnavailable: boolean) => {
|
||||||
|
unavailableRef.current = isUnavailable;
|
||||||
|
setUnavailable(isUnavailable);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyAuthentication = useCallback(
|
const applyAuthentication = useCallback(
|
||||||
(authentication: Authentication) => {
|
(authentication: Authentication) => {
|
||||||
const changedIdentity
|
const changedIdentity
|
||||||
= userRef.current && userRef.current.id !== authentication.user.id;
|
= userRef.current && userRef.current.id !== authentication.user.id;
|
||||||
|
|
||||||
setSignedOut(false);
|
setSignedOut(false);
|
||||||
|
markUnavailable(false);
|
||||||
accessTokenRef.current = authentication.accessToken;
|
accessTokenRef.current = authentication.accessToken;
|
||||||
|
|
||||||
if (changedIdentity) {
|
if (changedIdentity) {
|
||||||
@@ -67,16 +78,17 @@ function useAuthProviderVm() {
|
|||||||
userRef.current = authentication.user;
|
userRef.current = authentication.user;
|
||||||
setUser(authentication.user);
|
setUser(authentication.user);
|
||||||
},
|
},
|
||||||
[queryClient],
|
[markUnavailable, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
const clearSession = useCallback(() => {
|
||||||
|
markUnavailable(false);
|
||||||
generationRef.current += 1;
|
generationRef.current += 1;
|
||||||
userRef.current = undefined;
|
userRef.current = undefined;
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
accessTokenRef.current = undefined;
|
accessTokenRef.current = undefined;
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
}, [queryClient]);
|
}, [markUnavailable, queryClient]);
|
||||||
|
|
||||||
const refresh = useCallback((): Promise<boolean> => {
|
const refresh = useCallback((): Promise<boolean> => {
|
||||||
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
||||||
@@ -86,11 +98,12 @@ function useAuthProviderVm() {
|
|||||||
if (started !== generationRef.current) return false;
|
if (started !== generationRef.current) return false;
|
||||||
|
|
||||||
try {
|
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 (started !== generationRef.current) return false;
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
clearSession();
|
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
|
||||||
|
else clearSession();
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -100,7 +113,7 @@ function useAuthProviderVm() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
if (started === generationRef.current) clearSession();
|
if (started === generationRef.current) markUnavailable(true);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -112,7 +125,7 @@ function useAuthProviderVm() {
|
|||||||
refreshPromiseRef.current = pending;
|
refreshPromiseRef.current = pending;
|
||||||
|
|
||||||
return pending;
|
return pending;
|
||||||
}, [applyAuthentication, clearSession, serialize]);
|
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh().finally(() => setReady(true));
|
void refresh().finally(() => setReady(true));
|
||||||
@@ -146,6 +159,7 @@ function useAuthProviderVm() {
|
|||||||
clear: clearSession,
|
clear: clearSession,
|
||||||
generation: () => generationRef.current,
|
generation: () => generationRef.current,
|
||||||
refresh,
|
refresh,
|
||||||
|
unavailable: () => unavailableRef.current,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
useEffect(() => () => configureAuthClient(), []);
|
useEffect(() => () => configureAuthClient(), []);
|
||||||
@@ -157,7 +171,9 @@ function useAuthProviderVm() {
|
|||||||
ready,
|
ready,
|
||||||
register: (email: string, password: string) =>
|
register: (email: string, password: string) =>
|
||||||
authenticate("/api/v1/auth/register", email, password),
|
authenticate("/api/v1/auth/register", email, password),
|
||||||
|
retry: () => void refresh(),
|
||||||
signedOut,
|
signedOut,
|
||||||
|
unavailable,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ describe("apiClient authentication", () => {
|
|||||||
expect(clear).toHaveBeenCalledOnce();
|
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 () => {
|
it("does not attach Bearer auth to authentication endpoints", async () => {
|
||||||
let authorization: null | string
|
let authorization: null | string
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type AuthHandlers = {
|
|||||||
clear: () => void;
|
clear: () => void;
|
||||||
generation?: () => number;
|
generation?: () => number;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
|
/**
|
||||||
|
True after a refresh failed transiently (network/5xx): the session is not proven gone.
|
||||||
|
*/
|
||||||
|
unavailable?: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReadAttempt = {
|
type ReadAttempt = {
|
||||||
@@ -100,7 +104,8 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
|||||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||||
|
|
||||||
if (!(await sessionState.handlers.refresh())) {
|
if (!(await sessionState.handlers.refresh())) {
|
||||||
if (isCurrent()) sessionState.handlers.clear();
|
if (isCurrent() && !sessionState.handlers.unavailable?.())
|
||||||
|
sessionState.handlers.clear();
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const ru = {
|
|||||||
passwordConfirmation: "Подтвердите пароль",
|
passwordConfirmation: "Подтвердите пароль",
|
||||||
register: "Регистрация",
|
register: "Регистрация",
|
||||||
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
|
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
|
||||||
|
retry: "Повторить",
|
||||||
|
unavailableText: "Сервер временно недоступен. Ваш вход сохранён, попробуйте через минуту.",
|
||||||
|
unavailableTitle: "Не удалось подключиться",
|
||||||
validation: {
|
validation: {
|
||||||
invalidEmail: "Некорректный email",
|
invalidEmail: "Некорректный email",
|
||||||
invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
|
invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел",
|
||||||
|
|||||||
Reference in New Issue
Block a user