fix: little fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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";
|
||||
|
||||
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 (
|
||||
<AuthContext
|
||||
value={{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,56 @@ import createClient from "openapi-fetch";
|
||||
import { apiBaseUrl } from "./config";
|
||||
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>({
|
||||
baseUrl: apiBaseUrl,
|
||||
credentials: "include",
|
||||
fetch: authenticatedFetch,
|
||||
});
|
||||
|
||||
Vendored
+375
@@ -136,6 +136,21 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
ProblemDetailsDto: {
|
||||
/** @example 401 */
|
||||
status: number;
|
||||
/** @example UNAUTHORIZED */
|
||||
code: string;
|
||||
/**
|
||||
* @example [
|
||||
* {
|
||||
* "pointer": "#/email",
|
||||
* "detail": "must be a valid email address"
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
errors?: string[];
|
||||
};
|
||||
CredentialsDto: {
|
||||
/** @example user@example.com */
|
||||
email: string;
|
||||
@@ -218,6 +233,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -241,6 +292,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -260,6 +347,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -277,6 +400,42 @@ export interface operations {
|
||||
};
|
||||
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: {
|
||||
@@ -296,6 +455,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -315,6 +510,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -338,6 +569,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -355,6 +622,42 @@ export interface operations {
|
||||
};
|
||||
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: {
|
||||
@@ -374,6 +677,42 @@ export interface operations {
|
||||
"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: {
|
||||
@@ -395,6 +734,42 @@ export interface operations {
|
||||
"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,2 +1,2 @@
|
||||
export { apiClient } from "./client";
|
||||
export { apiClient, configureAuthClient } from "./client";
|
||||
export { apiBaseUrl } from "./config";
|
||||
|
||||
Reference in New Issue
Block a user