diff --git a/frontend/app/cypress/support/component.tsx b/frontend/app/cypress/support/component.tsx
index baf1919527..eb2a527ce8 100644
--- a/frontend/app/cypress/support/component.tsx
+++ b/frontend/app/cypress/support/component.tsx
@@ -25,6 +25,8 @@ import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
import "./commands";
import "../../src/app/styles/index.css";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "../../src/shared/api/rest/client";
// Alternatively you can use CommonJS syntax:
// require('./commands')
@@ -54,17 +56,19 @@ Cypress.Commands.add("mount", (component, options = {}) => {
const wrapped = (
-
-
- {component}
-
-
+
+
+
+ {component}
+
+
+
);
diff --git a/frontend/app/src/app/app.tsx b/frontend/app/src/app/app.tsx
index 97264ad547..2ae7ac8844 100644
--- a/frontend/app/src/app/app.tsx
+++ b/frontend/app/src/app/app.tsx
@@ -1,22 +1,23 @@
+import { ApolloProvider } from "@apollo/client";
+import { addCollection } from "@iconify-icon/react";
+import mdiIcons from "@iconify-json/mdi/icons.json";
+import { QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "jotai";
import { ErrorBoundary } from "react-error-boundary";
import { RouterProvider } from "react-router-dom";
import { Slide, ToastContainer } from "react-toastify";
+import { TanStackQueryDevtools } from "@/app/devtools";
import { router } from "@/app/router";
import { AuthProvider } from "@/entities/authentication/ui/useAuth";
+import { ConfigProvider } from "@/entities/config/config-provider";
import graphqlClient from "@/shared/api/graphql/graphqlClientApollo";
+import { queryClient } from "@/shared/api/rest/client";
import ErrorFallback from "@/shared/components/errors/error-fallback";
import { store } from "@/shared/stores";
-import { ApolloProvider } from "@apollo/client";
-import { addCollection } from "@iconify-icon/react";
-import mdiIcons from "@iconify-json/mdi/icons.json";
-import { QueryClientProvider } from "@tanstack/react-query";
import "@/app/styles/index.css";
import "react-toastify/dist/ReactToastify.css";
-import { TanStackQueryDevtools } from "@/app/devtools";
-import { queryClient } from "@/shared/api/rest/client";
addCollection(mdiIcons);
@@ -27,15 +28,17 @@ export function App() {
-
-
+
+
+
+
diff --git a/frontend/app/src/app/root.tsx b/frontend/app/src/app/root.tsx
deleted file mode 100644
index d81ace0245..0000000000
--- a/frontend/app/src/app/root.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { CONFIG } from "@/config/config";
-import { Config, configState } from "@/config/config.atom";
-import { fetchUrl } from "@/shared/api/rest/fetch";
-import LoadingScreen from "@/shared/components/loading-screen";
-import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert";
-import { useSetAtom } from "jotai";
-import { ReactNode, useEffect, useState } from "react";
-import { toast } from "react-toastify";
-
-export const Root = ({ children }: { children?: ReactNode }) => {
- const setConfig = useSetAtom(configState);
- const [isLoadingConfig, setIsLoadingConfig] = useState(true);
-
- const fetchConfig = async () => {
- try {
- return fetchUrl(CONFIG.CONFIG_URL);
- } catch (err) {
- toast(
-
- );
- console.error("Error while fetching the config: ", err);
- return undefined;
- }
- };
-
- const setConfigInState = async () => {
- try {
- const config: Config = await fetchConfig();
-
- setConfig(config);
- setIsLoadingConfig(false);
- } catch (error: any) {
- setIsLoadingConfig(false);
-
- if (error?.message?.includes("Received status code 401")) {
- return;
- }
-
- toast(
-
- );
- console.error("Error while fetching the config: ", error);
- }
- };
-
- useEffect(() => {
- setConfigInState();
- }, []);
-
- if (isLoadingConfig) {
- return (
-
-
-
- );
- }
-
- return children;
-};
diff --git a/frontend/app/src/app/router.tsx b/frontend/app/src/app/router.tsx
index 75f25c048c..0b5be31970 100644
--- a/frontend/app/src/app/router.tsx
+++ b/frontend/app/src/app/router.tsx
@@ -1,4 +1,3 @@
-import { Root } from "@/app/root";
import { NODE_OBJECT, PROPOSED_CHANGES_OBJECT } from "@/config/constants";
import { RequireAuth } from "@/entities/authentication/ui/useAuth";
import { constructPathForIpam } from "@/entities/ipam/common/utils";
@@ -21,9 +20,7 @@ export const router = createBrowserRouter([
objectToSearchString: queryString.stringify,
}}
>
-
-
-
+
),
children: [
diff --git a/frontend/app/src/config/config.atom.ts b/frontend/app/src/config/config.atom.ts
deleted file mode 100644
index 0e39e7c5a3..0000000000
--- a/frontend/app/src/config/config.atom.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { atom } from "jotai";
-
-export type RemoteConfig = {
- api_server_dsn: string;
- enable: boolean;
- frontend_dsn: string;
- git_agent_dsn: string;
-};
-
-export type AnalyticsConfig = {
- address: string;
- api_key: string;
- enable: boolean;
-};
-export type LoggingConfig = {
- remote: RemoteConfig;
-};
-export type MainConfig = {
- default_branch: string;
- internal_address: string;
- allow_anonymous_access: boolean;
-};
-
-export type Provider = {
- name: string;
- display_label: string;
- icon: string;
- protocol: string;
- readonly authorize_path: string;
- readonly token_path: string;
-};
-
-export type Config = {
- analytics: AnalyticsConfig;
- logging: LoggingConfig;
- main: MainConfig;
- experimental_features: { [key: string]: boolean };
- sso: {
- enabled: boolean;
- providers: Array;
- };
-};
-
-export const configState = atom(undefined);
diff --git a/frontend/app/src/config/config.ts b/frontend/app/src/config/config.ts
index 4a48cd7cbe..d3d22ebb64 100644
--- a/frontend/app/src/config/config.ts
+++ b/frontend/app/src/config/config.ts
@@ -27,7 +27,6 @@ export const CONFIG = {
branch
? `${INFRAHUB_API_SERVER_URL}/api/schema?branch=${branch}`
: `${INFRAHUB_API_SERVER_URL}/api/schema`,
- CONFIG_URL: `${INFRAHUB_API_SERVER_URL}/api/config`,
SEARCH_URL: (query: string, limit: number = 3) =>
`${INFRAHUB_API_SERVER_URL}/api/search/docs?query=${query}&limit=${limit}`,
INFO_URL: `${INFRAHUB_API_SERVER_URL}/api/info`,
@@ -50,6 +49,4 @@ export const CONFIG = {
FILES_CONTENT_URL: (repositoryId: string, location: string) =>
`${INFRAHUB_API_SERVER_URL}/api/file/${repositoryId}/${encodeURIComponent(location)}`,
STORAGE_DETAILS_URL: (id: string) => `${INFRAHUB_API_SERVER_URL}/api/storage/object/${id}`,
- MENU_URL: (branch?: string) =>
- `${INFRAHUB_API_SERVER_URL}/api/menu${branch ? `?branch=${branch}` : ""}`,
};
diff --git a/frontend/app/src/entities/authentication/ui/login-sso-buttons.tsx b/frontend/app/src/entities/authentication/ui/login-sso-buttons.tsx
index df31ca3477..e1c4cfa2c5 100644
--- a/frontend/app/src/entities/authentication/ui/login-sso-buttons.tsx
+++ b/frontend/app/src/entities/authentication/ui/login-sso-buttons.tsx
@@ -1,12 +1,12 @@
import { INFRAHUB_API_SERVER_URL } from "@/config/config";
-import { Provider } from "@/config/config.atom";
+import { SSOProvider } from "@/entities/config/types";
import { classNames } from "@/shared/utils/common";
import { Icon } from "@iconify-icon/react";
import { useLocation } from "react-router-dom";
export interface LoginWithSSOButtonsProps {
className?: string;
- providers: Array;
+ providers: Array;
}
export const LoginWithSSOButtons = ({ className, providers }: LoginWithSSOButtonsProps) => {
@@ -30,7 +30,7 @@ export const LoginWithSSOButtons = ({ className, providers }: LoginWithSSOButton
export const ProviderButton = ({
provider,
redirectTo = "/",
-}: { provider: Provider; redirectTo?: string }) => {
+}: { provider: SSOProvider; redirectTo?: string }) => {
return (
{
- const config = useAtomValue(configState);
+ const { data: config } = useConfig();
const [displaySSO, setDisplaySSO] = useState(true);
- if (config && config.sso.enabled && config.sso.providers.length > 0) {
+ if (config && config.sso.enabled && config.sso.providers && config.sso.providers.length > 0) {
return displaySSO ? (
<>
diff --git a/frontend/app/src/entities/authentication/ui/useAuth.tsx b/frontend/app/src/entities/authentication/ui/useAuth.tsx
index de342bff61..3c9198d9ce 100644
--- a/frontend/app/src/entities/authentication/ui/useAuth.tsx
+++ b/frontend/app/src/entities/authentication/ui/useAuth.tsx
@@ -1,14 +1,13 @@
import { CONFIG } from "@/config/config";
-import { configState } from "@/config/config.atom";
import { REFRESH_TOKEN_KEY } from "@/config/constants";
import { ACCESS_TOKEN_KEY } from "@/config/localStorage";
+import { useConfig } from "@/entities/config/get-config.query";
import graphqlClient from "@/shared/api/graphql/graphqlClientApollo";
import { fetchUrl } from "@/shared/api/rest/fetch";
import { components } from "@/shared/api/rest/types.generated";
import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert";
import { parseJwt } from "@/shared/utils/common";
import { ObservableQuery } from "@apollo/client";
-import { useAtom } from "jotai/index";
import { ReactElement, ReactNode, createContext, useContext, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { toast } from "react-toastify";
@@ -173,11 +172,11 @@ export function useAuth() {
}
export function RequireAuth({ children }: { children: ReactElement }) {
- const [config] = useAtom(configState);
+ const { data: config } = useConfig();
const { isAuthenticated } = useAuth();
const location = useLocation();
- if (isAuthenticated || config?.main?.allow_anonymous_access) return children;
+ if (isAuthenticated || config.main.allow_anonymous_access) return children;
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
diff --git a/frontend/app/src/entities/config/config-provider.tsx b/frontend/app/src/entities/config/config-provider.tsx
new file mode 100644
index 0000000000..b665289e29
--- /dev/null
+++ b/frontend/app/src/entities/config/config-provider.tsx
@@ -0,0 +1,19 @@
+import { getConfigQueryOptions } from "@/entities/config/get-config.query";
+import ErrorScreen from "@/shared/components/errors/error-screen";
+import { InfrahubLoading } from "@/shared/components/loading/infrahub-loading";
+import { useQuery } from "@tanstack/react-query";
+import React from "react";
+
+export const ConfigProvider = ({ children }: { children: React.ReactNode }) => {
+ const { isPending, error } = useQuery(getConfigQueryOptions());
+
+ if (isPending) {
+ return Loading config...;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return children;
+};
diff --git a/frontend/app/src/entities/config/get-config.query.ts b/frontend/app/src/entities/config/get-config.query.ts
new file mode 100644
index 0000000000..a2559864e6
--- /dev/null
+++ b/frontend/app/src/entities/config/get-config.query.ts
@@ -0,0 +1,13 @@
+import { getConfig } from "@/entities/config/get-config";
+import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
+
+export const getConfigQueryOptions = () => {
+ return queryOptions({
+ queryKey: ["config"],
+ queryFn: getConfig,
+ });
+};
+
+export const useConfig = () => {
+ return useSuspenseQuery(getConfigQueryOptions());
+};
diff --git a/frontend/app/src/entities/config/get-config.ts b/frontend/app/src/entities/config/get-config.ts
new file mode 100644
index 0000000000..42c799a3de
--- /dev/null
+++ b/frontend/app/src/entities/config/get-config.ts
@@ -0,0 +1,12 @@
+import { ConfigAPI } from "@/entities/config/types";
+import { apiClient } from "@/shared/api/rest/client";
+
+export type GetConfig = () => Promise;
+
+export const getConfig: GetConfig = async () => {
+ const { data, error } = await apiClient.GET("/api/config");
+
+ if (error) throw error;
+
+ return data;
+};
diff --git a/frontend/app/src/entities/config/types.ts b/frontend/app/src/entities/config/types.ts
new file mode 100644
index 0000000000..ffaceb408e
--- /dev/null
+++ b/frontend/app/src/entities/config/types.ts
@@ -0,0 +1,5 @@
+import { components } from "@/shared/api/rest/types.generated";
+
+export type ConfigAPI = components["schemas"]["ConfigAPI"];
+
+export type SSOProvider = components["schemas"]["SSOProviderInfo"];
diff --git a/frontend/app/src/entities/permission/utils.ts b/frontend/app/src/entities/permission/utils.ts
index 9a9a1dba16..c73a68437c 100644
--- a/frontend/app/src/entities/permission/utils.ts
+++ b/frontend/app/src/entities/permission/utils.ts
@@ -1,4 +1,4 @@
-import { configState } from "@/config/config.atom";
+import { getConfigQueryOptions } from "@/entities/config/get-config.query";
import {
Permission,
PermissionAction,
@@ -6,7 +6,7 @@ import {
PermissionDecision,
PermissionDecisionData,
} from "@/entities/permission/types";
-import { store } from "@/shared/stores";
+import { queryClient } from "@/shared/api/rest/client";
import { warnUnexpectedType } from "@/shared/utils/common";
const getMessage = (action: string, decision?: PermissionDecisionData): string => {
@@ -31,7 +31,7 @@ const getMessage = (action: string, decision?: PermissionDecisionData): string =
export function getPermission(permission?: Array<{ node: PermissionData }>): Permission {
if (!Array.isArray(permission)) return PERMISSION_ALLOW_ALL;
- const config = store.get(configState);
+ const config = queryClient.getQueryData(getConfigQueryOptions().queryKey);
const createPermissionAction = (action: PermissionAction): PermissionDecision => {
if (action === "view" && config?.main.allow_anonymous_access) return { isAllowed: true };
diff --git a/frontend/app/src/pages/auth-callback.tsx b/frontend/app/src/pages/auth-callback.tsx
index 0d75e3f6cd..67b352bf2c 100644
--- a/frontend/app/src/pages/auth-callback.tsx
+++ b/frontend/app/src/pages/auth-callback.tsx
@@ -1,15 +1,14 @@
import { INFRAHUB_API_SERVER_URL } from "@/config/config";
-import { configState } from "@/config/config.atom";
import { useAuth } from "@/entities/authentication/ui/useAuth";
+import { useConfig } from "@/entities/config/get-config.query";
import { fetchUrl } from "@/shared/api/rest/fetch";
import LoadingScreen from "@/shared/components/loading-screen";
-import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { Navigate, useParams, useSearchParams } from "react-router-dom";
function AuthCallback() {
const { protocol, provider } = useParams();
- const config = useAtomValue(configState);
+ const { data: config } = useConfig();
const [searchParams] = useSearchParams();
const { isAuthenticated, setToken } = useAuth();
const [redirectTo, setRedirectTo] = useState("/");
@@ -21,7 +20,7 @@ function AuthCallback() {
useEffect(() => {
if (!config || !config.sso.enabled) return;
- const currentAuthProvider = config.sso.providers.find(
+ const currentAuthProvider = config.sso.providers?.find(
(p) => p.protocol === protocol && p.name === provider
);
if (!currentAuthProvider) return;
diff --git a/frontend/app/src/shared/components/loading/infrahub-loading.tsx b/frontend/app/src/shared/components/loading/infrahub-loading.tsx
new file mode 100644
index 0000000000..9a9d147ca5
--- /dev/null
+++ b/frontend/app/src/shared/components/loading/infrahub-loading.tsx
@@ -0,0 +1,11 @@
+import infrahubLogo from "@/assets/infrahub-logo.svg";
+import React from "react";
+
+export const InfrahubLoading = ({ children }: { children?: React.ReactNode }) => {
+ return (
+
+
+
{children}
+
+ );
+};
diff --git a/frontend/app/src/shared/hooks/usePagination.ts b/frontend/app/src/shared/hooks/usePagination.ts
index 50b5fc86a5..00876af1ac 100644
--- a/frontend/app/src/shared/hooks/usePagination.ts
+++ b/frontend/app/src/shared/hooks/usePagination.ts
@@ -1,6 +1,5 @@
-import { configState } from "@/config/config.atom";
import { QSP } from "@/config/qsp";
-import { useAtom } from "jotai";
+import { useConfig } from "@/entities/config/get-config.query";
import { StringParam, useQueryParam } from "use-query-params";
type tPagination = {
@@ -34,7 +33,7 @@ const getVerifiedOffset = (offset: number, config: any) => {
};
const usePagination = (): [tPagination, Function] => {
- const [config] = useAtom(configState);
+ const { data: config } = useConfig();
const [paginationInQueryString, setPaginationInQueryString] = useQueryParam(
QSP.PAGINATION,
diff --git a/frontend/app/tests/integrations/screens/artifact-diff.cy.tsx b/frontend/app/tests/integrations/screens/artifact-diff.cy.tsx
index b968f2047a..c929a1a722 100644
--- a/frontend/app/tests/integrations/screens/artifact-diff.cy.tsx
+++ b/frontend/app/tests/integrations/screens/artifact-diff.cy.tsx
@@ -80,9 +80,13 @@ describe("Artifact Diff", () => {
cy.viewport(1920, 1080);
cy.fixture("storage-old").as("storageOld");
cy.fixture("storage-new").as("storageNew");
+ cy.fixture("config").as("config");
});
beforeEach(function () {
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
cy.fixture("artifacts").then(function (json) {
cy.intercept("GET", "/api/diff/artifacts*", json).as("artifacts");
});
diff --git a/frontend/app/tests/integrations/screens/conversations.cy.tsx b/frontend/app/tests/integrations/screens/conversations.cy.tsx
index dae52f772d..bef6921745 100644
--- a/frontend/app/tests/integrations/screens/conversations.cy.tsx
+++ b/frontend/app/tests/integrations/screens/conversations.cy.tsx
@@ -51,6 +51,9 @@ const ConversationsProvider = () => {
describe("List screen", () => {
it("should display a conversation with comments", () => {
cy.viewport(1920, 1080);
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
// Mount the view with the default route and the mocked data
cy.mount(
diff --git a/frontend/app/tests/integrations/screens/object-details-relationships.cy.tsx b/frontend/app/tests/integrations/screens/object-details-relationships.cy.tsx
index ed6cdee4f0..fb23af9811 100644
--- a/frontend/app/tests/integrations/screens/object-details-relationships.cy.tsx
+++ b/frontend/app/tests/integrations/screens/object-details-relationships.cy.tsx
@@ -142,6 +142,9 @@ const ObjectDetailsProvider = () => {
describe("List screen", () => {
it("should fetch items and render list", () => {
cy.viewport(1920, 1080);
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
// Mount the view with the default route and the mocked data
cy.mount(
diff --git a/frontend/app/tests/integrations/screens/object-details.cy.tsx b/frontend/app/tests/integrations/screens/object-details.cy.tsx
index c7ca94aea7..2305ce15cd 100644
--- a/frontend/app/tests/integrations/screens/object-details.cy.tsx
+++ b/frontend/app/tests/integrations/screens/object-details.cy.tsx
@@ -63,6 +63,9 @@ const ObjectDetailsProvider = () => {
describe("List screen", () => {
it("should fetch object details and render a list of details", () => {
cy.viewport(1920, 1080);
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
// Mount the view with the default route and the mocked data
cy.mount(
diff --git a/frontend/app/tests/integrations/screens/object-fields.cy.tsx b/frontend/app/tests/integrations/screens/object-fields.cy.tsx
index cb6bf6aca6..7aaabdba25 100644
--- a/frontend/app/tests/integrations/screens/object-fields.cy.tsx
+++ b/frontend/app/tests/integrations/screens/object-fields.cy.tsx
@@ -184,6 +184,10 @@ describe("Object list", () => {
const token = encodeJwt(data);
localStorage.setItem(ACCESS_TOKEN_KEY, token);
+
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
});
it("should open the add panel, submit without filling the text field and display a required message", function () {
diff --git a/frontend/app/tests/integrations/screens/object-items-deletion.cy.tsx b/frontend/app/tests/integrations/screens/object-items-deletion.cy.tsx
index 2fd0c6abc9..51fcfceaaa 100644
--- a/frontend/app/tests/integrations/screens/object-items-deletion.cy.tsx
+++ b/frontend/app/tests/integrations/screens/object-items-deletion.cy.tsx
@@ -3,13 +3,11 @@
import { gql } from "@apollo/client";
import { MockedProvider } from "@apollo/client/testing";
import { Route, Routes } from "react-router-dom";
-import { configState } from "../../../src/config/config.atom";
import { ACCESS_TOKEN_KEY } from "../../../src/config/localStorage";
import { AuthProvider } from "../../../src/entities/authentication/ui/useAuth";
import { schemaState } from "../../../src/entities/schema/stores/schema.atom";
import { ObjectItemsPage } from "../../../src/pages/objects/object-items";
import { mockedToken } from "../../fixtures/auth";
-import { configMocks } from "../../mocks/data/config";
import {
graphqlQueriesMocksData,
graphqlQueriesMocksDataDeleted,
@@ -62,12 +60,7 @@ const AuthenticatedObjectItems = () => (
// Provide the initial value for jotai
const ObjectItemsProvider = () => {
return (
-
+
);
@@ -76,6 +69,9 @@ const ObjectItemsProvider = () => {
describe("List screen", () => {
beforeEach(function () {
cy.fixture("device-items-delete").as("delete");
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
localStorage.setItem(ACCESS_TOKEN_KEY, mockedToken);
});
diff --git a/frontend/app/tests/integrations/screens/object-items.cy.tsx b/frontend/app/tests/integrations/screens/object-items.cy.tsx
index fd9b064d1f..175184a779 100644
--- a/frontend/app/tests/integrations/screens/object-items.cy.tsx
+++ b/frontend/app/tests/integrations/screens/object-items.cy.tsx
@@ -57,6 +57,12 @@ const ObjectItemsProvider = () => {
};
describe("List screen", () => {
+ beforeEach(() => {
+ cy.fixture("config").then(function (json) {
+ cy.intercept("GET", "/api/config", json).as("config");
+ });
+ });
+
it("should fetch items and render list", () => {
cy.viewport(1920, 1080);