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 ( +
+ Infrahub logo + {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);