fix: little fixes

This commit is contained in:
2026-08-13 23:02:25 +03:00
parent 9156f29707
commit 2e32d226d3
5 changed files with 496 additions and 2 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react";
import { apiClient } from "../../../shared/api"; import { apiClient, configureAuthClient } from "../../../shared/api";
import type { paths } from "../../../shared/api/generated/schema"; import type { paths } from "../../../shared/api/generated/schema";
type User = type User =
@@ -97,6 +97,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
} }
useEffect(() => {
configureAuthClient({
accessToken: () => accessTokenRef.current,
refresh,
clear: () => {
setUser(null);
setAccessToken(null);
accessTokenRef.current = null;
queryClient.clear();
},
});
return () => configureAuthClient(null);
});
return ( return (
<AuthContext <AuthContext
value={{ value={{
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => {
afterEach(() => {
configureAuthClient(null);
vi.unstubAllGlobals();
});
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
token = "fresh";
return true;
});
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }),
)
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }));
vi.stubGlobal("fetch", fetch);
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
expect(data).toEqual([]);
expect(refresh).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][0].headers.get("Authorization")).toBe(
"Bearer expired",
);
expect(fetch.mock.calls[1][0].headers.get("Authorization")).toBe(
"Bearer fresh",
);
});
it("clears the session when refresh cannot restore it", async () => {
const clear = vi.fn();
const fetch = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }),
);
vi.stubGlobal("fetch", fetch);
configureAuthClient({
accessToken: () => "expired",
refresh: async () => false,
clear,
});
await apiClient.GET("/api/v1/brokerage-accounts");
expect(clear).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledOnce();
});
});
+49
View File
@@ -3,7 +3,56 @@ import createClient from "openapi-fetch";
import { apiBaseUrl } from "./config"; import { apiBaseUrl } from "./config";
import type { paths } from "./generated/schema"; import type { paths } from "./generated/schema";
type AuthHandlers = {
accessToken: () => string | null;
refresh: () => Promise<boolean>;
clear: () => void;
};
let auth: AuthHandlers | null = null;
export function configureAuthClient(handlers: AuthHandlers | null) {
auth = handlers;
}
async function authenticatedFetch(request: Request): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const token = auth?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
return new Request(source, { headers });
};
const authorized =
token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
if (
response.status !== 401 ||
isAuthRequest ||
!auth ||
(
await response
.clone()
.json()
.catch(() => null)
)?.code !== "UNAUTHORIZED"
)
return response;
if (!(await auth.refresh())) {
auth.clear();
return response;
}
const refreshedToken = auth.accessToken();
return refreshedToken
? fetch(withToken(retryRequest, refreshedToken))
: response;
}
export const apiClient = createClient<paths>({ export const apiClient = createClient<paths>({
baseUrl: apiBaseUrl, baseUrl: apiBaseUrl,
credentials: "include", credentials: "include",
fetch: authenticatedFetch,
}); });
+375
View File
@@ -136,6 +136,21 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
ProblemDetailsDto: {
/** @example 401 */
status: number;
/** @example UNAUTHORIZED */
code: string;
/**
* @example [
* {
* "pointer": "#/email",
* "detail": "must be a valid email address"
* }
* ]
*/
errors?: string[];
};
CredentialsDto: { CredentialsDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string; email: string;
@@ -218,6 +233,42 @@ export interface operations {
"application/json": components["schemas"]["AuthenticationDto"]; "application/json": components["schemas"]["AuthenticationDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
AuthController_login: { AuthController_login: {
@@ -241,6 +292,42 @@ export interface operations {
"application/json": components["schemas"]["AuthenticationDto"]; "application/json": components["schemas"]["AuthenticationDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
AuthController_refresh: { AuthController_refresh: {
@@ -260,6 +347,42 @@ export interface operations {
"application/json": components["schemas"]["AuthenticationDto"]; "application/json": components["schemas"]["AuthenticationDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
AuthController_logout: { AuthController_logout: {
@@ -277,6 +400,42 @@ export interface operations {
}; };
content?: never; content?: never;
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
AuthController_me: { AuthController_me: {
@@ -296,6 +455,42 @@ export interface operations {
"application/json": components["schemas"]["UserDto"]; "application/json": components["schemas"]["UserDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
TbankConnectionController_status: { TbankConnectionController_status: {
@@ -315,6 +510,42 @@ export interface operations {
"application/json": components["schemas"]["TbankConnectionStatusDto"]; "application/json": components["schemas"]["TbankConnectionStatusDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
TbankConnectionController_put: { TbankConnectionController_put: {
@@ -338,6 +569,42 @@ export interface operations {
"application/json": components["schemas"]["TbankConnectionStatusDto"]; "application/json": components["schemas"]["TbankConnectionStatusDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
TbankConnectionController_delete: { TbankConnectionController_delete: {
@@ -355,6 +622,42 @@ export interface operations {
}; };
content?: never; content?: never;
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
BrokerageAccountsController_list: { BrokerageAccountsController_list: {
@@ -374,6 +677,42 @@ export interface operations {
"application/json": components["schemas"]["BrokerageAccountDto"][]; "application/json": components["schemas"]["BrokerageAccountDto"][];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
PortfolioController_get: { PortfolioController_get: {
@@ -395,6 +734,42 @@ export interface operations {
"application/json": components["schemas"]["PortfolioDto"]; "application/json": components["schemas"]["PortfolioDto"];
}; };
}; };
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
}; };
}; };
} }
+1 -1
View File
@@ -1,2 +1,2 @@
export { apiClient } from "./client"; export { apiClient, configureAuthClient } from "./client";
export { apiBaseUrl } from "./config"; export { apiBaseUrl } from "./config";