From 0842c0c37d0c21a761ac8b09c215ed222d58e504 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 27 Oct 2025 13:30:07 +0200 Subject: [PATCH 01/16] added basic plugin support --- .eslintrc.json | 2 +- config/paths.js | 94 +++++++++---------- config/webpack.config.js | 1 + src/App.tsx | 91 ++++++++++-------- src/index.ts | 12 +++ src/{index.tsx => main.tsx} | 7 ++ .../componentOverrideContext.tsx | 11 +++ .../genericComponentOverrideContext.tsx | 19 ++++ src/plugins/componentOverride/index.ts | 6 ++ src/plugins/componentOverride/types.ts | 9 ++ .../componentOverride/useComponentOverride.ts | 20 ++++ .../componentOverride/withOverride.tsx | 24 +++++ src/plugins/index.ts | 2 + src/plugins/types.ts | 55 +++++++++++ src/supertokens.ts | 88 +++++++++++++++++ tsconfig.json | 1 + 16 files changed, 354 insertions(+), 88 deletions(-) create mode 100644 src/index.ts rename src/{index.tsx => main.tsx} (92%) create mode 100644 src/plugins/componentOverride/componentOverrideContext.tsx create mode 100644 src/plugins/componentOverride/genericComponentOverrideContext.tsx create mode 100644 src/plugins/componentOverride/index.ts create mode 100644 src/plugins/componentOverride/types.ts create mode 100644 src/plugins/componentOverride/useComponentOverride.ts create mode 100644 src/plugins/componentOverride/withOverride.tsx create mode 100644 src/plugins/index.ts create mode 100644 src/plugins/types.ts create mode 100644 src/supertokens.ts diff --git a/.eslintrc.json b/.eslintrc.json index 9c8ca852..02d5630d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,7 +14,7 @@ "parser": "@typescript-eslint/parser", "parserOptions": { - "project": "tsconfig.json", + "project": true, "ecmaFeatures": { "jsx": true }, diff --git a/config/paths.js b/config/paths.js index 84469bcf..51588754 100644 --- a/config/paths.js +++ b/config/paths.js @@ -1,13 +1,13 @@ -'use strict'; +"use strict"; -const path = require('path'); -const fs = require('fs'); -const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); +const path = require("path"); +const fs = require("fs"); +const getPublicUrlOrPath = require("react-dev-utils/getPublicUrlOrPath"); // Make sure any symlinks in the project folder are resolved: // https://github.com/facebook/create-react-app/issues/637 const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); // We use `PUBLIC_URL` environment variable or "homepage" field to infer // "public path" at which the app is served. @@ -16,64 +16,60 @@ const resolveApp = relativePath => path.resolve(appDirectory, relativePath); // We can't use a relative path in HTML because we don't want to load something // like /todos/42/static/js/bundle.7289d.js. We have to know the root. const publicUrlOrPath = getPublicUrlOrPath( - process.env.NODE_ENV === 'development', - require(resolveApp('package.json')).homepage, - process.env.PUBLIC_URL + process.env.NODE_ENV === "development", + require(resolveApp("package.json")).homepage, + process.env.PUBLIC_URL ); -const buildPath = process.env.BUILD_PATH || 'build'; -const debugBuildPath = process.env.DEBUG_BUILD_PATH || 'debug'; +const buildPath = process.env.BUILD_PATH || "build"; +const debugBuildPath = process.env.DEBUG_BUILD_PATH || "debug"; const moduleFileExtensions = [ - 'web.mjs', - 'mjs', - 'web.js', - 'js', - 'web.ts', - 'ts', - 'web.tsx', - 'tsx', - 'json', - 'web.jsx', - 'jsx', + "web.mjs", + "mjs", + "web.js", + "js", + "web.ts", + "ts", + "web.tsx", + "tsx", + "json", + "web.jsx", + "jsx", ]; // Resolve file paths in the same order as webpack const resolveModule = (resolveFn, filePath) => { - const extension = moduleFileExtensions.find(extension => - fs.existsSync(resolveFn(`${filePath}.${extension}`)) - ); + const extension = moduleFileExtensions.find((extension) => fs.existsSync(resolveFn(`${filePath}.${extension}`))); - if (extension) { - return resolveFn(`${filePath}.${extension}`); - } + if (extension) { + return resolveFn(`${filePath}.${extension}`); + } - return resolveFn(`${filePath}.js`); + return resolveFn(`${filePath}.js`); }; // config after eject: we're in ./config/ module.exports = { - dotenv: resolveApp('.env'), - appPath: resolveApp('.'), - appBuild: resolveApp(buildPath), - appDevBuild: resolveApp(debugBuildPath), - appPublic: resolveApp('public'), - appHtml: resolveApp('public/index.html'), - appIndexJs: resolveModule(resolveApp, 'src/index'), - appPackageJson: resolveApp('package.json'), - appSrc: resolveApp('src'), - appTsConfig: resolveApp('tsconfig.json'), - appJsConfig: resolveApp('jsconfig.json'), - yarnLockFile: resolveApp('yarn.lock'), - testsSetup: resolveModule(resolveApp, 'src/setupTests'), - proxySetup: resolveApp('src/setupProxy.js'), - appNodeModules: resolveApp('node_modules'), - appWebpackCache: resolveApp('node_modules/.cache'), - appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'), - swSrc: resolveModule(resolveApp, 'src/service-worker'), - publicUrlOrPath, + dotenv: resolveApp(".env"), + appPath: resolveApp("."), + appBuild: resolveApp(buildPath), + appDevBuild: resolveApp(debugBuildPath), + appPublic: resolveApp("public"), + appHtml: resolveApp("public/index.html"), + appIndexJs: resolveModule(resolveApp, "src/main"), + appPackageJson: resolveApp("package.json"), + appSrc: resolveApp("src"), + appTsConfig: resolveApp("tsconfig.json"), + appJsConfig: resolveApp("jsconfig.json"), + yarnLockFile: resolveApp("yarn.lock"), + testsSetup: resolveModule(resolveApp, "src/setupTests"), + proxySetup: resolveApp("src/setupProxy.js"), + appNodeModules: resolveApp("node_modules"), + appWebpackCache: resolveApp("node_modules/.cache"), + appTsBuildInfoFile: resolveApp("node_modules/.cache/tsconfig.tsbuildinfo"), + swSrc: resolveModule(resolveApp, "src/service-worker"), + publicUrlOrPath, }; - - module.exports.moduleFileExtensions = moduleFileExtensions; diff --git a/config/webpack.config.js b/config/webpack.config.js index 5ced976a..8f14522b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -297,6 +297,7 @@ module.exports = function (webpackEnv) { }), // Path aliases "@api": path.resolve(paths.appSrc, "api"), + "@plugins": path.resolve(paths.appSrc, "plugins"), "@components": path.resolve(paths.appSrc, "shared/components"), "@styles": path.resolve(paths.appSrc, "shared/styles"), "@services": path.resolve(paths.appSrc, "shared/services"), diff --git a/src/App.tsx b/src/App.tsx index 78ea4d47..6f6b0ed8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ import { getDashboardAppBasePath } from "@shared/utils"; import "./images"; import TenantManagement from "@features/tenants/Page"; -import { UserManagement } from "@features/users/Page"; +import { UserManagement } from "@features/users/page"; import { ToastProvider } from "@shared/components/toast"; import { QueryProvider } from "@shared/providers/QueryProvider"; import { ROUTES } from "@shared/navigation"; @@ -33,47 +33,62 @@ import AuthWrapper from "@features/auth/components/AuthWrapper"; import SafeAreaView from "@shared/components/safeAreaView/SafeAreaView"; import ErrorBoundary from "@shared/components/errorboundary"; import { AccessDeniedModal } from "@shared/components/accessDenied"; +import { ComponentOverrideContext } from "@plugins"; +import React, { useMemo } from "react"; +import { SuperTokens } from "./supertokens"; + +const genericContext = React.createContext({}); function App() { + const contextValue = React.useContext(genericContext); + const componentOverrides = useMemo(() => { + return { + ...SuperTokens.getInstanceOrThrow().componentOverrides, + ...contextValue, + }; + }, [contextValue]); + return ( - - - - - - - - - - } - /> - } - /> - } - /> - } - /> - - - - - - - - - + + + + + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + + + + + + ); } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..8acb6e74 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import { SuperTokens } from "supertokens"; + +import Dashboard from "./App"; + +export const init = () => { + SuperTokens.init({ + apiPath: "/api", + plugins: [], + }); +}; + +export { Dashboard }; diff --git a/src/index.tsx b/src/main.tsx similarity index 92% rename from src/index.tsx rename to src/main.tsx index 07deae4d..9fee1078 100644 --- a/src/index.tsx +++ b/src/main.tsx @@ -17,7 +17,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import reportWebVitals from "./reportWebVitals"; + import "./shared/styles"; +import { SuperTokens } from "./supertokens"; + +SuperTokens.init({ + apiPath: "/api", + plugins: [], +}); const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); root.render( diff --git a/src/plugins/componentOverride/componentOverrideContext.tsx b/src/plugins/componentOverride/componentOverrideContext.tsx new file mode 100644 index 00000000..42744d43 --- /dev/null +++ b/src/plugins/componentOverride/componentOverrideContext.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +import type { ComponentOverride } from "./types"; + +export type GenericComponentOverrideMap = { + [K in keyof T]?: ComponentOverride; +}; + +type ContextType = GenericComponentOverrideMap | "IS_DEFAULT"; + +export const ComponentOverrideContext = React.createContext>("IS_DEFAULT"); diff --git a/src/plugins/componentOverride/genericComponentOverrideContext.tsx b/src/plugins/componentOverride/genericComponentOverrideContext.tsx new file mode 100644 index 00000000..aad58faa --- /dev/null +++ b/src/plugins/componentOverride/genericComponentOverrideContext.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { FC, PropsWithChildren } from "react"; + +export const createGenericComponentsOverrideContext = >(v: T = {} as T) => { + const genericContext = React.createContext(v); + + const useComponentsOverrideContext = () => { + const contextValue = React.useContext(genericContext); + + return { ...contextValue }; + }; + + const Provider: FC> = ({ children, components }) => { + return {children}; + }; + + return [useComponentsOverrideContext, Provider, genericContext.Consumer] as const; +}; diff --git a/src/plugins/componentOverride/index.ts b/src/plugins/componentOverride/index.ts new file mode 100644 index 00000000..8348b6e1 --- /dev/null +++ b/src/plugins/componentOverride/index.ts @@ -0,0 +1,6 @@ +import { withOverride } from "./withOverride"; + +export type { ComponentOverride } from "./types"; + +export { ComponentOverrideContext } from "./componentOverrideContext"; +export { withOverride }; diff --git a/src/plugins/componentOverride/types.ts b/src/plugins/componentOverride/types.ts new file mode 100644 index 00000000..bcbab7e7 --- /dev/null +++ b/src/plugins/componentOverride/types.ts @@ -0,0 +1,9 @@ +import type React from "react"; + +export type ComponentOverrideProps> = React.ComponentProps & { + DefaultComponent: TComponent; +}; + +export type ComponentOverride> = React.ComponentType< + ComponentOverrideProps +>; diff --git a/src/plugins/componentOverride/useComponentOverride.ts b/src/plugins/componentOverride/useComponentOverride.ts new file mode 100644 index 00000000..73b5d78f --- /dev/null +++ b/src/plugins/componentOverride/useComponentOverride.ts @@ -0,0 +1,20 @@ +import { useContext } from "react"; +import type React from "react"; + +import { ComponentOverrideContext } from "./componentOverrideContext"; + +import type { ComponentOverride } from "./types"; + +export const useComponentOverride = >( + overrideKey: string +): ComponentOverride | null => { + const ctx = useContext(ComponentOverrideContext); + + if (ctx === "IS_DEFAULT") { + throw new Error("Cannot use component override outside ComponentOverrideContext provider."); + } + + const OverrideComponent = ctx[overrideKey]; + + return OverrideComponent === undefined ? null : OverrideComponent; +}; diff --git a/src/plugins/componentOverride/withOverride.tsx b/src/plugins/componentOverride/withOverride.tsx new file mode 100644 index 00000000..e56abef6 --- /dev/null +++ b/src/plugins/componentOverride/withOverride.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import { useComponentOverride } from "./useComponentOverride"; + +export const withOverride = >( + overrideKey: string, + DefaultComponent: TComponent +): React.ComponentType> => { + const finalKey = overrideKey + "_Override"; + DefaultComponent.displayName = finalKey; + return (props: React.ComponentProps) => { + const OverrideComponent = useComponentOverride(finalKey); + if (OverrideComponent !== null) { + return ( + + ); + } + + return ; + }; +}; diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 00000000..79b0bfa0 --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./componentOverride"; diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 00000000..666554ff --- /dev/null +++ b/src/plugins/types.ts @@ -0,0 +1,55 @@ +import { Layout } from "@features/layout"; +import type { ComponentOverride } from "./componentOverride"; +import { Header, Sidebar } from "@features/layout/components"; +import { + Auth, + SignInContent, + SignInContentWrapper, + SignInWithApiKeyContent, + SignOutBtn, + SignUpOrResetPasswordContent, +} from "@features/auth"; +import AuthWrapper from "@features/auth/components/AuthWrapper"; +import RolesListHeader from "@features/roles-and-permissions/components/RolesListHeader"; +import RolesListTable from "@features/roles-and-permissions/components/RolesListTable"; +import RolesList from "@features/users/components/user-details/roles/RolesList"; +import RolesListItem from "@features/roles-and-permissions/components/RolesListItem"; +import RolesListFooter from "@features/roles-and-permissions/components/RolesListFooter"; +import TenantsList from "@features/tenants/components/TenantsList"; +import TenantsListHeader from "@features/tenants/components/TenantsListHeader"; +import TenantsListFooter from "@features/tenants/components/TenantsListFooter"; +import TenantsListTable from "@features/tenants/components/TenantsListTable"; +import TenantsListItem from "@features/tenants/components/TenantsListItem"; + +type SuperTokensPublicConfig = { + apiPath: string; +}; +export type PluginRouteHandler = { + path: string; // this is appended to apiBasePath + handler: () => JSX.Element; +}; + +export type SuperTokensPlugin = { + id: string; + version?: string; + init?: (config: SuperTokensPublicConfig, allPlugins: SuperTokensPublicPlugin[], sdkVersion: string) => void; + dependencies?: ( + config: SuperTokensPublicConfig, + pluginsAbove: SuperTokensPublicPlugin[], + dashboardVersion: string + ) => { status: "OK"; pluginsToAdd?: SuperTokensPlugin[] } | { status: "ERROR"; message: string }; + componentOverrides?: ComponentOverrideMap; + routeHandlers?: + | (( + config: SuperTokensPublicConfig, + allPlugins: SuperTokensPublicPlugin[] + ) => { status: "OK"; routeHandlers: PluginRouteHandler[] } | { status: "ERROR"; message: string }) + | PluginRouteHandler[]; + + config?: (config: SuperTokensPublicConfig) => SuperTokensPublicConfig | undefined; + exports?: Record; +}; + +export type SuperTokensPublicPlugin = Pick & { initialized: boolean }; + +export type ComponentOverrideMap = {}; diff --git a/src/supertokens.ts b/src/supertokens.ts new file mode 100644 index 00000000..14bbdd06 --- /dev/null +++ b/src/supertokens.ts @@ -0,0 +1,88 @@ +import { ComponentOverrideMap, PluginRouteHandler, SuperTokensPlugin, SuperTokensPublicPlugin } from "@plugins"; +type SuperTokensConfig = { + apiPath: string; + plugins: SuperTokensPlugin[]; +}; + +type SuperTokensPublicConfig = Pick; + +export class SuperTokens { + private static instance: SuperTokens | undefined; + public componentOverrides: ComponentOverrideMap; + public pluginList: SuperTokensPublicPlugin[]; + public pluginRouteHandlers: PluginRouteHandler[] = []; + + private constructor(config: SuperTokensConfig) { + const { plugins } = config; + + this.componentOverrides = {}; + + for (const plugin of plugins) { + if (plugin.componentOverrides !== undefined) { + this.componentOverrides = { + ...this.componentOverrides, + ...plugin.componentOverrides, + }; + } + } + + this.pluginList = plugins.map(getPublicPlugin); + + const publicConfig = getPublicConfig(config); + + // iterated separately so we can pass the instance plugins as reference so they always have access to the latest + for (let pluginIndex = 0; pluginIndex < this.pluginList.length; pluginIndex += 1) { + const pluginRouteHandlers = plugins[pluginIndex].routeHandlers; + if (pluginRouteHandlers) { + let handlers: PluginRouteHandler[] = []; + if (typeof pluginRouteHandlers === "function") { + const result = pluginRouteHandlers(publicConfig, this.pluginList); + if (result.status === "ERROR") { + throw new Error(result.message); + } + handlers = result.routeHandlers; + } else { + handlers = pluginRouteHandlers; + } + + this.pluginRouteHandlers.push(...handlers); + } + } + } + + public static getInstanceOrThrow(): SuperTokens { + if (!SuperTokens.instance) { + throw new Error("SuperTokens not initialized"); + } + return SuperTokens.instance; + } + + public static reset(): void { + SuperTokens.instance = undefined; + } + + public static init(config: SuperTokensConfig): SuperTokens { + if (SuperTokens.instance) { + return SuperTokens.instance; + } + + SuperTokens.instance = new SuperTokens(config); + + return SuperTokens.instance; + } +} + +function getPublicPlugin(plugin: SuperTokensPlugin): SuperTokensPublicPlugin { + return { + id: plugin.id, + initialized: plugin.init ? false : true, // since the init method is optional, we default to true + version: plugin.version, + exports: plugin.exports, + }; +} + +function getPublicConfig(config: SuperTokensConfig): SuperTokensPublicConfig { + return { + apiPath: config.apiPath, + }; +} diff --git a/tsconfig.json b/tsconfig.json index cdfd2c69..d30dee27 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "@components/*": ["shared/components/*"], "@features/*": ["features/*"], "@shared/*": ["shared/*"], + "@plugins": ["plugins"], "@styles/*": ["shared/styles/*"], "@services/*": ["shared/services/*"], "@assets/*": ["assets/*"], From 93556dbd996f531e06a2ae7a9a6a2086586c1e9b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 27 Oct 2025 14:22:02 +0200 Subject: [PATCH 02/16] make components overridable --- src/features/auth/components/Auth.tsx | 7 +- src/features/auth/components/AuthWrapper.tsx | 7 +- .../auth/components/SignInContent.tsx | 10 +- .../auth/components/SignInContentWrapper.tsx | 18 +- .../components/SignInWithApiKeyContent.tsx | 90 +- src/features/auth/components/SignOutBtn.tsx | 7 +- .../SignUpOrResetPasswordContent.tsx | 210 +-- src/features/layout/components/Layout.tsx | 8 +- .../layout/components/header/Header.tsx | 7 +- .../layout/components/sidebar/Sidebar.tsx | 7 +- .../components/RolesList.tsx | 7 +- .../components/RolesListFooter.tsx | 92 +- .../components/RolesListHeader.tsx | 81 +- .../components/RolesListItem.tsx | 7 +- .../components/RolesListTable.tsx | 122 +- .../modals/AddPermissionModal.tsx | 110 +- .../modals/CreateNewRoleModal.tsx | 130 +- .../modals/DeletePermissionModal.tsx | 84 +- .../modals/DeleteRoleModal.tsx | 129 +- .../modals/RemoveAccessModal.tsx | 78 +- .../roles-details/ManageAccessFooter.tsx | 90 +- .../roles-details/ManageAccessHeader.tsx | 7 +- .../tenants/components/TenantsList.tsx | 7 +- .../tenants/components/TenantsListFooter.tsx | 101 +- .../tenants/components/TenantsListHeader.tsx | 83 +- .../tenants/components/TenantsListItem.tsx | 7 +- .../tenants/components/TenantsListTable.tsx | 110 +- .../tenants/modals/AddNewProviderModal.tsx | 198 +-- .../tenants/modals/CreateNewTenantModal.tsx | 153 +- .../modals/DeleteProviderConfigModal.tsx | 140 +- .../tenants/modals/DeleteTenantModal.tsx | 90 +- .../modals/EditConfigurationPropertyModal.tsx | 382 ++--- .../modals/EditPluginPropertyModal.tsx | 121 +- .../modals/UneditableConfigurationModal.tsx | 46 +- .../tenants/tenant-details/LoginMethods.tsx | 363 ++--- .../tenants/tenant-details/Providers.tsx | 531 ++++--- .../tenant-details/SecondaryFactors.tsx | 407 ++--- .../tenants/tenant-details/TenantDetails.tsx | 246 +-- .../core-configuration/CoreConfigTableRow.tsx | 129 +- .../core-configuration/CoreConfiguration.tsx | 112 +- .../CoreConfigurationTable.tsx | 146 +- .../PluginPropertiesSection.tsx | 94 +- .../AdditionalConfigForms.tsx | 176 +- .../ProviderConfiguration.tsx | 1409 +++++++++-------- .../components/ClientConfigSection.tsx | 404 ++--- .../components/EmailSelect.tsx | 52 +- .../components/ProviderConfigCancelButton.tsx | 26 +- .../components/ProviderConfigInput.tsx | 74 +- .../components/ProviderConfigInputLabel.tsx | 82 +- .../components/ProviderConfigInputRow.tsx | 68 +- .../components/ProviderConfigKeyValue.tsx | 206 +-- .../components/ProviderConfigSeparator.tsx | 18 +- .../components/ProviderConfigSuffixInput.tsx | 168 +- .../components/UserInfoMapSection.tsx | 96 +- .../components/user-details/UserDetails.tsx | 2 +- .../user-details/login-methods/index.ts | 2 +- .../login-methods/loginMethods.tsx | 2 +- src/plugins/types.ts | 107 +- 58 files changed, 4010 insertions(+), 3656 deletions(-) diff --git a/src/features/auth/components/Auth.tsx b/src/features/auth/components/Auth.tsx index c2fd5783..b8896fa5 100644 --- a/src/features/auth/components/Auth.tsx +++ b/src/features/auth/components/Auth.tsx @@ -22,13 +22,12 @@ import { type ContentMode } from "./types"; import styles from "./Auth.module.scss"; import { assertNever } from "@shared/utils/assertNever"; +import { withOverride } from "@plugins"; const INITIAL_CONTENT_TO_SHOW: ContentMode = "sign-in"; export const LOGO_ICON_LIGHT = getImageUrl("ST_full_logo_light_theme.svg"); -const Auth: React.FC<{ - onSuccess: () => void; -}> = (props) => { +const Auth = withOverride("Auth", function Auth(props: { onSuccess: () => void }) { const [contentMode, setContentMode] = useState(INITIAL_CONTENT_TO_SHOW); const getContentToRender = () => { @@ -79,6 +78,6 @@ const Auth: React.FC<{ ); -}; +}); export default Auth; diff --git a/src/features/auth/components/AuthWrapper.tsx b/src/features/auth/components/AuthWrapper.tsx index ee2ec9e3..6fed8414 100644 --- a/src/features/auth/components/AuthWrapper.tsx +++ b/src/features/auth/components/AuthWrapper.tsx @@ -18,12 +18,13 @@ import { StorageKeys } from "@shared/constants"; import { localStorageHandler } from "@shared/services/storage"; import Loader from "@shared/components/loader"; import Auth from "./Auth"; +import { withOverride } from "@plugins"; interface AuthWrapperProps { children: React.ReactNode; } -export default function AuthWrapper({ children }: AuthWrapperProps): JSX.Element { +const AuthWrapper = withOverride("AuthWrapper", function AuthWrapper({ children }: AuthWrapperProps): JSX.Element { const [shouldShowAuthForm, setShouldShowAuthForm] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -55,4 +56,6 @@ export default function AuthWrapper({ children }: AuthWrapperProps): JSX.Element } return <>{children}; -} +}); + +export default AuthWrapper; diff --git a/src/features/auth/components/SignInContent.tsx b/src/features/auth/components/SignInContent.tsx index f2a4103b..eb167d88 100644 --- a/src/features/auth/components/SignInContent.tsx +++ b/src/features/auth/components/SignInContent.tsx @@ -18,6 +18,7 @@ import { ArrowRightIcon } from "@radix-ui/react-icons"; import TextField from "@shared/components/text"; import { useSignIn } from "../hooks/useSignIn"; import styles from "./SignInContent.module.scss"; +import { withOverride } from "@plugins"; interface SignInContentProps { onSuccess: () => void; @@ -25,11 +26,8 @@ interface SignInContentProps { onForgotPasswordBtnClick: () => void; } -const SignInContent: React.FC = ({ - onSuccess, - onCreateNewUserClick, - onForgotPasswordBtnClick, -}): JSX.Element => { +const SignInContent = withOverride("SignInContent", function SignInContent(props: SignInContentProps) { + const { onSuccess, onCreateNewUserClick, onForgotPasswordBtnClick } = props; const { isLoading, userTriedToSubmit, @@ -125,6 +123,6 @@ const SignInContent: React.FC = ({ ); -}; +}); export default SignInContent; diff --git a/src/features/auth/components/SignInContentWrapper.tsx b/src/features/auth/components/SignInContentWrapper.tsx index 3a6278f9..972b10dc 100644 --- a/src/features/auth/components/SignInContentWrapper.tsx +++ b/src/features/auth/components/SignInContentWrapper.tsx @@ -16,6 +16,7 @@ import { getAuthMode } from "@shared/utils"; import SignIn from "./SignInContent"; import SignInWithApiKeyContent from "./SignInWithApiKeyContent"; +import { withOverride } from "@plugins"; interface SignInContentWrapperProps { onSuccess: () => void; @@ -23,14 +24,17 @@ interface SignInContentWrapperProps { onForgotPasswordBtnClick: () => void; } -const SignInContentWrapper: React.FC = ({ ...props }: SignInContentWrapperProps) => { - const authMode = getAuthMode(); +const SignInContentWrapper = withOverride( + "SignInContentWrapper", + function SignInContentWrapper(props: SignInContentWrapperProps) { + const authMode = getAuthMode(); - if (authMode === "email-password") { - return ; - } + if (authMode === "email-password") { + return ; + } - return ; -}; + return ; + } +); export default SignInContentWrapper; diff --git a/src/features/auth/components/SignInWithApiKeyContent.tsx b/src/features/auth/components/SignInWithApiKeyContent.tsx index a0ed35c9..66028db3 100644 --- a/src/features/auth/components/SignInWithApiKeyContent.tsx +++ b/src/features/auth/components/SignInWithApiKeyContent.tsx @@ -20,54 +20,60 @@ import { useApiKeyValidation } from "../hooks/useApiKeyValidation"; import { ArrowRightIcon } from "@radix-ui/react-icons"; import styles from "./SignInWithApiKeyContent.module.scss"; +import { withOverride } from "@plugins"; interface SignInWithApiKeyContentProps { onSuccess: () => void; } -const SignInWithApiKeyContent: React.FC = ({ onSuccess }) => { - const { apiKey, apiKeyFieldError, loading, handleSubmit, handleApiKeyFieldChange } = useApiKeyValidation(onSuccess); +const SignInWithApiKeyContent = withOverride( + "SignInWithApiKeyContent", + function SignInWithApiKeyContent(props: SignInWithApiKeyContentProps) { + const { onSuccess } = props; + const { apiKey, apiKeyFieldError, loading, handleSubmit, handleApiKeyFieldChange } = + useApiKeyValidation(onSuccess); - return ( - - Enter your API Key - - Please enter the API key that you used to connect with your backend - -
- - + return ( + + Enter your API Key + + Please enter the API key that you used to connect with your backend + + + + - - - - - ); -}; + +
+ +
+ ); + } +); export default SignInWithApiKeyContent; diff --git a/src/features/auth/components/SignOutBtn.tsx b/src/features/auth/components/SignOutBtn.tsx index 6e066270..480f9e9e 100644 --- a/src/features/auth/components/SignOutBtn.tsx +++ b/src/features/auth/components/SignOutBtn.tsx @@ -17,8 +17,9 @@ import useAuthService from "@api"; import styles from "./SignOutBtn.module.scss"; import Loader from "@shared/components/loader"; +import { withOverride } from "@plugins"; -export default function SignOutBtn() { +const SignOutBtn = withOverride("SignOutBtn", function SignOutBtn() { const { logout, isLoading } = useAuthService(); return ( @@ -32,4 +33,6 @@ export default function SignOutBtn() { {isLoading && } ); -} +}); + +export default SignOutBtn; diff --git a/src/features/auth/components/SignUpOrResetPasswordContent.tsx b/src/features/auth/components/SignUpOrResetPasswordContent.tsx index 74c020a0..8e1670de 100644 --- a/src/features/auth/components/SignUpOrResetPasswordContent.tsx +++ b/src/features/auth/components/SignUpOrResetPasswordContent.tsx @@ -21,6 +21,7 @@ import styles from "./SignUpOrResetPasswordContent.module.scss"; import { ArrowLeftIcon, CopyIcon } from "@radix-ui/react-icons"; import { copyToClipboard } from "@shared/utils/copyToClipboard"; import { useToast } from "@shared/components/toast"; +import { withOverride } from "@plugins"; interface ISignUpOrResetPasswordContentProps { contentMode: Exclude; @@ -41,121 +42,122 @@ const commonHeaders = ` --header 'Content-Type: application/json' \\ `; -const SignUpOrResetPasswordContent: React.FC = ({ - contentMode, - onBack, -}: ISignUpOrResetPasswordContentProps): JSX.Element => { - const { showSuccessToast, showErrorToast } = useToast(); +const SignUpOrResetPasswordContent = withOverride( + "SignUpOrResetPasswordContent", + function SignUpOrResetPasswordContent(props: ISignUpOrResetPasswordContentProps) { + const { contentMode, onBack } = props; + const { showSuccessToast, showErrorToast } = useToast(); - useEffect(() => { - HighlightJS.registerLanguage("bash", BashHighlight); - HighlightJS.initHighlightingOnLoad(); - }); + useEffect(() => { + HighlightJS.registerLanguage("bash", BashHighlight); + HighlightJS.initHighlightingOnLoad(); + }); - const getContentForMode = (): IContentForMode => { - switch (contentMode) { - case "sign-up": - return { - title: "Sign Up", - subtitle: "Run the below command in your terminal", - endpoint: "/recipe/dashboard/user", - method: "POST", - // eslint-disable-next-line @typescript-eslint/quotes - rawData: `"email": "","password": ""`, - }; - case "forgot-password": - return { - title: "Reset your password", - subtitle: "Run the below command in your terminal", - endpoint: "/recipe/dashboard/user", - method: "PUT", - // eslint-disable-next-line @typescript-eslint/quotes - rawData: `"email": "","newPassword": ""`, - }; - default: - throw Error("No content found for the prop!"); - } - }; + const getContentForMode = (): IContentForMode => { + switch (contentMode) { + case "sign-up": + return { + title: "Sign Up", + subtitle: "Run the below command in your terminal", + endpoint: "/recipe/dashboard/user", + method: "POST", + // eslint-disable-next-line @typescript-eslint/quotes + rawData: `"email": "","password": ""`, + }; + case "forgot-password": + return { + title: "Reset your password", + subtitle: "Run the below command in your terminal", + endpoint: "/recipe/dashboard/user", + method: "PUT", + // eslint-disable-next-line @typescript-eslint/quotes + rawData: `"email": "","newPassword": ""`, + }; + default: + throw Error("No content found for the prop!"); + } + }; - const { title, subtitle, endpoint, method, rawData } = getContentForMode(); + const { title, subtitle, endpoint, method, rawData } = getContentForMode(); - const command = `curl --location --request ${method} '${ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).connectionURI - }${endpoint}' \\ + const command = `curl --location --request ${method} '${ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).connectionURI + }${endpoint}' \\ ${commonHeaders.trim()} --data-raw '{${rawData}}'`; - const highlightedCode = HighlightJS.highlight(command, { - language: "bash", - }); + const highlightedCode = HighlightJS.highlight(command, { + language: "bash", + }); - return ( -
- -

{title}

- - {subtitle} - -
- - {/* TODO: VERIFY THIS */} -
- { - e.stopPropagation(); - void copyToClipboard( - command, - () => { - showSuccessToast("Success", "Command copied to clipboard."); - }, - () => { - showErrorToast("Failed to copy command to clipboard."); - } - ); + return ( +
+ +

{title}

+ + {subtitle} + +
+ + {/* TODO: VERIFY THIS */} +
+ { + e.stopPropagation(); + void copyToClipboard( + command, + () => { + showSuccessToast("Success", "Command copied to clipboard."); + }, + () => { + showErrorToast("Failed to copy command to clipboard."); + } + ); + }} + /> +
-
- -
- {contentMode === "sign-up" ? ( - - Account exists?{" "} - - Sign In - - - ) : ( - - )} + +
+ {contentMode === "sign-up" ? ( + + Account exists?{" "} + + Sign In + + + ) : ( + + )} + - -
- ); -}; + + ); + } +); export default SignUpOrResetPasswordContent; diff --git a/src/features/layout/components/Layout.tsx b/src/features/layout/components/Layout.tsx index 8b456fae..7fe0115b 100644 --- a/src/features/layout/components/Layout.tsx +++ b/src/features/layout/components/Layout.tsx @@ -20,17 +20,19 @@ import Sidebar from "./sidebar"; import { useSidebar } from "../hooks"; import styles from "./Layout.module.scss"; +import { withOverride } from "@plugins"; interface LayoutProps { readonly children: React.ReactNode; } -export default function Layout({ children }: LayoutProps) { +const Layout = withOverride("Layout", function Layout({ children }: LayoutProps) { const { isCollapsed, toggleSidebar } = useSidebar(); return (
+
); -} +}); + +export default Layout; diff --git a/src/features/layout/components/header/Header.tsx b/src/features/layout/components/header/Header.tsx index 450bfc36..6881fadd 100644 --- a/src/features/layout/components/header/Header.tsx +++ b/src/features/layout/components/header/Header.tsx @@ -19,10 +19,11 @@ import { SignOutBtn } from "@features/auth"; import { getImageUrl } from "@shared/utils"; import styles from "./Header.module.scss"; +import { withOverride } from "@plugins"; const LOGO_LIGHT = getImageUrl("ST_icon_light_theme.svg"); -export default function Header() { +const Header = withOverride("Header", function Header() { return (
); -} +}); + +export default Header; diff --git a/src/features/layout/components/sidebar/Sidebar.tsx b/src/features/layout/components/sidebar/Sidebar.tsx index 3e9119e4..79b4c070 100644 --- a/src/features/layout/components/sidebar/Sidebar.tsx +++ b/src/features/layout/components/sidebar/Sidebar.tsx @@ -24,6 +24,7 @@ import { ReactComponent as TenantManagementIcon } from "@assets/tenant-nav-icon. import { ReactComponent as UserManagementIcon } from "@assets/user-nav-icon.svg"; import { ROUTES } from "@shared/navigation"; +import { withOverride } from "@plugins"; export const NAVIGATION_ITEMS = [ { @@ -51,7 +52,7 @@ interface SidebarProps { readonly onToggle: () => void; } -export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { +const Sidebar = withOverride("Sidebar", function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const location = useLocation(); const isItemActive = (href: string) => { @@ -99,4 +100,6 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { ); -} +}); + +export default Sidebar; diff --git a/src/features/roles-and-permissions/components/RolesList.tsx b/src/features/roles-and-permissions/components/RolesList.tsx index 8b78824e..cd8c668e 100644 --- a/src/features/roles-and-permissions/components/RolesList.tsx +++ b/src/features/roles-and-permissions/components/RolesList.tsx @@ -29,8 +29,9 @@ import RolesListTable from "./RolesListTable"; import { useRolesList } from "../hooks"; import { ROLES_PAGINATION_LIMIT } from "../constants"; import CreateNewRoleModal from "../modals/CreateNewRoleModal"; +import { withOverride } from "@plugins"; -export default function RolesList() { +const RolesList = withOverride("RolesList", function RolesList() { const { showErrorToast, showSuccessToast } = useToast(); const { roles, isFeatureEnabled, isLoading, error, refetch, createRole, searchQuery, setSearchQuery } = useRolesList(); @@ -127,4 +128,6 @@ export default function RolesList() { /> ); -} +}); + +export default RolesList; diff --git a/src/features/roles-and-permissions/components/RolesListFooter.tsx b/src/features/roles-and-permissions/components/RolesListFooter.tsx index e633b9ba..70e3746c 100644 --- a/src/features/roles-and-permissions/components/RolesListFooter.tsx +++ b/src/features/roles-and-permissions/components/RolesListFooter.tsx @@ -17,6 +17,7 @@ import { Flex, IconButton, Text } from "@radix-ui/themes"; import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; import styles from "./RolesListFooter.module.scss"; +import { withOverride } from "@plugins"; interface RolesListFooterProps { currentPage: number; @@ -29,49 +30,54 @@ interface RolesListFooterProps { onPreviousPage: () => void; } -export default function RolesListFooter({ - currentPage, - totalPages, - totalCount, - pageSize, - hasNextPage, - hasPreviousPage, - onNextPage, - onPreviousPage, -}: RolesListFooterProps) { - const startIndex = (currentPage - 1) * pageSize + 1; - const endIndex = Math.min(currentPage * pageSize, totalCount); +const RolesListFooter = withOverride( + "RolesListFooter", + function RolesListFooter({ + currentPage, + totalPages, + totalCount, + pageSize, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + }: RolesListFooterProps) { + const startIndex = (currentPage - 1) * pageSize + 1; + const endIndex = Math.min(currentPage * pageSize, totalCount); - return ( - - - {totalCount > 0 ? `${startIndex} - ${endIndex} of ${totalCount}` : "0 of 0"} - - - + - - - - - + weight="medium"> + {totalCount > 0 ? `${startIndex} - ${endIndex} of ${totalCount}` : "0 of 0"} + + + + + + + + + - - ); -} + ); + } +); + +export default RolesListFooter; diff --git a/src/features/roles-and-permissions/components/RolesListHeader.tsx b/src/features/roles-and-permissions/components/RolesListHeader.tsx index bf7eb866..471e229f 100644 --- a/src/features/roles-and-permissions/components/RolesListHeader.tsx +++ b/src/features/roles-and-permissions/components/RolesListHeader.tsx @@ -19,6 +19,7 @@ import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons"; import Button from "@shared/components/button"; import styles from "./RolesListHeader.module.scss"; +import { withOverride } from "@plugins"; interface RolesListHeaderProps { isLoading: boolean; @@ -27,48 +28,48 @@ interface RolesListHeaderProps { onAddRoleClick: () => void; } -export default function RolesListHeader({ - isLoading, - searchQuery, - onSearchChange, - onAddRoleClick, -}: RolesListHeaderProps) { - return ( - +const RolesListHeader = withOverride( + "RolesListHeader", + function RolesListHeader({ isLoading, searchQuery, onSearchChange, onAddRoleClick }: RolesListHeaderProps) { + return ( - + + onSearchChange(e.target.value)} + disabled={isLoading} + className={styles.header__search}> + + + + + + - - - ); -} + ); + } +); + +export default RolesListHeader; diff --git a/src/features/roles-and-permissions/components/RolesListItem.tsx b/src/features/roles-and-permissions/components/RolesListItem.tsx index ef45e522..7c4fffa1 100644 --- a/src/features/roles-and-permissions/components/RolesListItem.tsx +++ b/src/features/roles-and-permissions/components/RolesListItem.tsx @@ -19,13 +19,14 @@ import { ChevronRightIcon } from "@radix-ui/react-icons"; import { useNavigationHelpers } from "@shared/navigation"; import styles from "./RolesListItem.module.scss"; +import { withOverride } from "@plugins"; interface RolesListItemProps { role: string; permissions: string[] | undefined; } -export default function RolesListItem({ role, permissions }: RolesListItemProps) { +const RolesListItem = withOverride("RolesListItem", function RolesListItem({ role, permissions }: RolesListItemProps) { const { goToRoleDetails } = useNavigationHelpers(); const handleClick = () => { @@ -79,4 +80,6 @@ export default function RolesListItem({ role, permissions }: RolesListItemProps) /> ); -} +}); + +export default RolesListItem; diff --git a/src/features/roles-and-permissions/components/RolesListTable.tsx b/src/features/roles-and-permissions/components/RolesListTable.tsx index 30ade75e..7da64831 100644 --- a/src/features/roles-and-permissions/components/RolesListTable.tsx +++ b/src/features/roles-and-permissions/components/RolesListTable.tsx @@ -19,69 +19,75 @@ import EmptyList from "@shared/components/empty"; import RolesListItem from "./RolesListItem"; import styles from "./RolesListTable.module.scss"; +import { withOverride } from "@plugins"; interface RolesListTableProps { roles: Array<{ role: string; permissions: string[] | undefined }>; isFeatureEnabled: boolean; } -export default function RolesListTable({ roles, isFeatureEnabled }: RolesListTableProps) { - const isEmpty = roles.length === 0; - const isFeatureDisabled = !isFeatureEnabled; +const RolesListTable = withOverride( + "RolesListTable", + function RolesListTable({ roles, isFeatureEnabled }: RolesListTableProps) { + const isEmpty = roles.length === 0; + const isFeatureDisabled = !isFeatureEnabled; - return ( - - - - User Roles - - - Permissions - - - - {isFeatureDisabled ? ( - - Enable this feature to manage user roles and permissions. Start by initialising the - UserRoles recipe in the recipeList on the backend.{" "} - - Click here - {" "} - for more details. - - } - /> - ) : isEmpty ? ( - - ) : ( - roles.map(({ role, permissions }) => ( - + + + User Roles + + + Permissions + + + + {isFeatureDisabled ? ( + + Enable this feature to manage user roles and permissions. Start by initialising the + UserRoles recipe in the recipeList on the backend.{" "} + + Click here + {" "} + for more details. + + } /> - )) - )} - - - ); -} + ) : isEmpty ? ( + + ) : ( + roles.map(({ role, permissions }) => ( + + )) + )} + + + ); + } +); + +export default RolesListTable; diff --git a/src/features/roles-and-permissions/modals/AddPermissionModal.tsx b/src/features/roles-and-permissions/modals/AddPermissionModal.tsx index 3d8457da..2b1187b0 100644 --- a/src/features/roles-and-permissions/modals/AddPermissionModal.tsx +++ b/src/features/roles-and-permissions/modals/AddPermissionModal.tsx @@ -22,6 +22,7 @@ import Button from "@shared/components/button"; import Form from "@shared/components/form"; import { useRolesList } from "../hooks"; +import { withOverride } from "@plugins"; interface AddPermissionModalProps { open: boolean; @@ -31,59 +32,64 @@ interface AddPermissionModalProps { isAdding: boolean; } -export default function AddPermissionModal({ - open, - handleClose, - existingPermissions, - onAddPermissions, - isAdding, -}: AddPermissionModalProps) { - const { allRoles } = useRolesList(); - const [selectedPermissions, setSelectedPermissions] = useState([]); +const AddPermissionModal = withOverride( + "AddPermissionModal", + function AddPermissionModal({ + open, + handleClose, + existingPermissions, + onAddPermissions, + isAdding, + }: AddPermissionModalProps) { + const { allRoles } = useRolesList(); + const [selectedPermissions, setSelectedPermissions] = useState([]); - // Get all permissions from all roles, excluding ones already assigned to this role - const allExistingPermissions = Array.from(new Set(allRoles.flatMap((role) => role.permissions || []))).sort(); - const availablePermissions = allExistingPermissions.filter((p) => !existingPermissions.includes(p)); + // Get all permissions from all roles, excluding ones already assigned to this role + const allExistingPermissions = Array.from(new Set(allRoles.flatMap((role) => role.permissions || []))).sort(); + const availablePermissions = allExistingPermissions.filter((p) => !existingPermissions.includes(p)); - const handleDone = async () => { - if (selectedPermissions.length > 0) { - await onAddPermissions(selectedPermissions); - setSelectedPermissions([]); - } else { - handleClose(); - } - }; + const handleDone = async () => { + if (selectedPermissions.length > 0) { + await onAddPermissions(selectedPermissions); + setSelectedPermissions([]); + } else { + handleClose(); + } + }; - const handleCloseModal = () => { - if (!isAdding) { - setSelectedPermissions([]); - handleClose(); - } - }; + const handleCloseModal = () => { + if (!isAdding) { + setSelectedPermissions([]); + handleClose(); + } + }; - return ( - -
- - - - - -
- ); -} + return ( + +
+ + + + + +
+ ); + } +); + +export default AddPermissionModal; diff --git a/src/features/roles-and-permissions/modals/CreateNewRoleModal.tsx b/src/features/roles-and-permissions/modals/CreateNewRoleModal.tsx index 29dda218..9c2d42e8 100644 --- a/src/features/roles-and-permissions/modals/CreateNewRoleModal.tsx +++ b/src/features/roles-and-permissions/modals/CreateNewRoleModal.tsx @@ -23,6 +23,7 @@ import Button from "@shared/components/button"; import { AssignPermission } from "@shared/components/assignPermission"; import { useRolesList } from "../hooks"; +import { withOverride } from "@plugins"; type CreateNewRoleModalProps = { handleClose: () => void; @@ -30,70 +31,75 @@ type CreateNewRoleModalProps = { onCreateRole: (roleName: string, permissions: string[]) => Promise; }; -export default function CreateNewRoleModal({ handleClose, open, onCreateRole }: CreateNewRoleModalProps) { - const { allRoles } = useRolesList(); - const [roleName, setRoleName] = useState(""); - const [selectedPermissions, setSelectedPermissions] = useState([]); - const [isLoading, setIsLoading] = useState(false); +const CreateNewRoleModal = withOverride( + "CreateNewRoleModal", + function CreateNewRoleModal({ handleClose, open, onCreateRole }: CreateNewRoleModalProps) { + const { allRoles } = useRolesList(); + const [roleName, setRoleName] = useState(""); + const [selectedPermissions, setSelectedPermissions] = useState([]); + const [isLoading, setIsLoading] = useState(false); - // Get all available permissions from all roles - const availablePermissions = Array.from(new Set(allRoles.flatMap((role) => role.permissions || []))).sort(); + // Get all available permissions from all roles + const availablePermissions = Array.from(new Set(allRoles.flatMap((role) => role.permissions || []))).sort(); - const handleSave = async () => { - if (!roleName.trim()) return; + const handleSave = async () => { + if (!roleName.trim()) return; - setIsLoading(true); - try { - await onCreateRole(roleName.trim(), selectedPermissions); - setRoleName(""); - setSelectedPermissions([]); - } finally { - setIsLoading(false); - } - }; + setIsLoading(true); + try { + await onCreateRole(roleName.trim(), selectedPermissions); + setRoleName(""); + setSelectedPermissions([]); + } finally { + setIsLoading(false); + } + }; - const handleCloseModal = () => { - if (!isLoading) { - setRoleName(""); - setSelectedPermissions([]); - handleClose(); - } - }; + const handleCloseModal = () => { + if (!isLoading) { + setRoleName(""); + setSelectedPermissions([]); + handleClose(); + } + }; - return ( - -
- - - Role Name - setRoleName(e.target.value)} - disabled={isLoading} - placeholder="Enter role name" - /> - - - - - - - -
- ); -} + return ( + +
+ + + Role Name + setRoleName(e.target.value)} + disabled={isLoading} + placeholder="Enter role name" + /> + + + + + + + +
+ ); + } +); + +export default CreateNewRoleModal; diff --git a/src/features/roles-and-permissions/modals/DeletePermissionModal.tsx b/src/features/roles-and-permissions/modals/DeletePermissionModal.tsx index 95ab8bf6..cb4dcf79 100644 --- a/src/features/roles-and-permissions/modals/DeletePermissionModal.tsx +++ b/src/features/roles-and-permissions/modals/DeletePermissionModal.tsx @@ -18,6 +18,7 @@ import { Flex, Text } from "@radix-ui/themes"; import { Modal } from "@shared/components/modal"; import Form from "@shared/components/form"; import Button from "@shared/components/button"; +import { withOverride } from "@plugins"; import styles from "./DeletePermissionModal.module.scss"; @@ -30,42 +31,47 @@ interface DeletePermissionModalProps { isDeleting: boolean; } -export default function DeletePermissionModal({ - open, - handleClose, - selectedPermissions, - onDeletePermissions, - isDeleting, -}: DeletePermissionModalProps) { - return ( - -
- - - Are you sure you want to remove{" "} - {selectedPermissions.length === 1 - ? "this permission from this role" - : `these ${selectedPermissions.length} permissions from this role`} - ? - - - - - -
-
- ); -} +const DeletePermissionModal = withOverride( + "DeletePermissionModal", + function DeletePermissionModal({ + open, + handleClose, + selectedPermissions, + onDeletePermissions, + isDeleting, + }: DeletePermissionModalProps) { + return ( + +
+ + + Are you sure you want to remove{" "} + {selectedPermissions.length === 1 + ? "this permission from this role" + : `these ${selectedPermissions.length} permissions from this role`} + ? + + + + + +
+
+ ); + } +); + +export default DeletePermissionModal; diff --git a/src/features/roles-and-permissions/modals/DeleteRoleModal.tsx b/src/features/roles-and-permissions/modals/DeleteRoleModal.tsx index f8be16ca..baa97aed 100644 --- a/src/features/roles-and-permissions/modals/DeleteRoleModal.tsx +++ b/src/features/roles-and-permissions/modals/DeleteRoleModal.tsx @@ -20,6 +20,7 @@ import Button from "@shared/components/button"; import Form from "@shared/components/form"; import { Modal } from "@shared/components/modal"; import { useToast } from "@shared/components/toast"; +import { withOverride } from "@plugins"; import { useRoleDetails } from "../hooks"; @@ -32,69 +33,75 @@ interface DeleteRoleModalProps { onDeleteSuccess: () => void; } -export default function DeleteRoleModal({ open, handleClose, roleId, onDeleteSuccess }: DeleteRoleModalProps) { - const { showErrorToast } = useToast(); - const { deleteRole } = useRoleDetails(roleId); - const [isDeleting, setIsDeleting] = useState(false); +const DeleteRoleModal = withOverride( + "DeleteRoleModal", + function DeleteRoleModal({ open, handleClose, roleId, onDeleteSuccess }: DeleteRoleModalProps) { + const { showErrorToast } = useToast(); + const { deleteRole } = useRoleDetails(roleId); + const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { - setIsDeleting(true); - try { - const response = await deleteRole(); + const handleDelete = async () => { + setIsDeleting(true); + try { + const response = await deleteRole(); - if (!response) { - throw new Error("Failed to delete role"); - } + if (!response) { + throw new Error("Failed to delete role"); + } - if (response.status === "OK") { - handleClose(); - onDeleteSuccess(); - } else if (response.status === "FEATURE_NOT_ENABLED_ERROR") { - showErrorToast("Feature is not enabled"); - } else { - throw new Error("Failed to delete role"); + if (response.status === "OK") { + handleClose(); + onDeleteSuccess(); + } else if (response.status === "FEATURE_NOT_ENABLED_ERROR") { + showErrorToast("Feature is not enabled"); + } else { + throw new Error("Failed to delete role"); + } + } catch { + showErrorToast("Something went wrong. Please try again!"); + } finally { + setIsDeleting(false); } - } catch { - showErrorToast("Something went wrong. Please try again!"); - } finally { - setIsDeleting(false); - } - }; + }; - return ( - -
- - - Are you certain you want to delete role "{roleId}"? This action is irreversible. - - - - - - -
-
- ); -} + return ( + +
+ + + Are you certain you want to delete role "{roleId}"? This action is + irreversible. + + + + + + +
+
+ ); + } +); + +export default DeleteRoleModal; diff --git a/src/features/roles-and-permissions/modals/RemoveAccessModal.tsx b/src/features/roles-and-permissions/modals/RemoveAccessModal.tsx index 23735767..7feb911b 100644 --- a/src/features/roles-and-permissions/modals/RemoveAccessModal.tsx +++ b/src/features/roles-and-permissions/modals/RemoveAccessModal.tsx @@ -18,6 +18,7 @@ import { Flex, Text } from "@radix-ui/themes"; import Form from "@shared/components/form"; import { Modal } from "@shared/components/modal"; import Button from "@shared/components/button"; +import { withOverride } from "@plugins"; import "./RemoveAccessModal.module.scss"; @@ -28,39 +29,44 @@ interface RemoveAccessModalProps { isRemoving: boolean; } -export default function RemoveAccessModal({ open, handleClose, onConfirmRemove, isRemoving }: RemoveAccessModalProps) { - return ( - - - - Are you sure you want to remove access for this user? This action is irreversible. - - - - - - - - ); -} +const RemoveAccessModal = withOverride( + "RemoveAccessModal", + function RemoveAccessModal({ open, handleClose, onConfirmRemove, isRemoving }: RemoveAccessModalProps) { + return ( + + + + Are you sure you want to remove access for this user? This action is irreversible. + + + + + + + + ); + } +); + +export default RemoveAccessModal; diff --git a/src/features/roles-and-permissions/roles-details/ManageAccessFooter.tsx b/src/features/roles-and-permissions/roles-details/ManageAccessFooter.tsx index bb8d0a84..eb2b8255 100644 --- a/src/features/roles-and-permissions/roles-details/ManageAccessFooter.tsx +++ b/src/features/roles-and-permissions/roles-details/ManageAccessFooter.tsx @@ -15,6 +15,7 @@ import { Flex, IconButton, Text } from "@radix-ui/themes"; import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { withOverride } from "@plugins"; interface ManageAccessFooterProps { currentPage: number; @@ -26,48 +27,53 @@ interface ManageAccessFooterProps { onPreviousPage: () => void; } -export default function ManageAccessFooter({ - currentPage, - totalCount, - pageSize, - hasNextPage, - hasPreviousPage, - onNextPage, - onPreviousPage, -}: ManageAccessFooterProps) { - const startIndex = (currentPage - 1) * pageSize + 1; - const endIndex = Math.min(currentPage * pageSize, totalCount); +const ManageAccessFooter = withOverride( + "ManageAccessFooter", + function ManageAccessFooter({ + currentPage, + totalCount, + pageSize, + hasNextPage, + hasPreviousPage, + onNextPage, + onPreviousPage, + }: ManageAccessFooterProps) { + const startIndex = (currentPage - 1) * pageSize + 1; + const endIndex = Math.min(currentPage * pageSize, totalCount); - return ( - - - {totalCount > 0 ? `${startIndex} - ${endIndex} of ${totalCount}` : "0 of 0"} - - - + - - - - - + weight="medium"> + {totalCount > 0 ? `${startIndex} - ${endIndex} of ${totalCount}` : "0 of 0"} + + + + + + + + + - - ); -} + ); + } +); + +export default ManageAccessFooter; diff --git a/src/features/roles-and-permissions/roles-details/ManageAccessHeader.tsx b/src/features/roles-and-permissions/roles-details/ManageAccessHeader.tsx index 97cfdb06..63264264 100644 --- a/src/features/roles-and-permissions/roles-details/ManageAccessHeader.tsx +++ b/src/features/roles-and-permissions/roles-details/ManageAccessHeader.tsx @@ -16,10 +16,11 @@ import { Flex } from "@radix-ui/themes"; import ItemLabel from "@shared/components/itemLabel"; +import { withOverride } from "@plugins"; import styles from "./ManageAccessHeader.module.scss"; -export default function ManageAccessHeader() { +const ManageAccessHeader = withOverride("ManageAccessHeader", function ManageAccessHeader() { return ( List of users who have access to this role ); -} +}); + +export default ManageAccessHeader; diff --git a/src/features/tenants/components/TenantsList.tsx b/src/features/tenants/components/TenantsList.tsx index 578e5f6d..ab5f423c 100644 --- a/src/features/tenants/components/TenantsList.tsx +++ b/src/features/tenants/components/TenantsList.tsx @@ -29,8 +29,9 @@ import TenantsListTable from "./TenantsListTable"; import TenantsListFooter from "./TenantsListFooter"; import { useNavigationHelpers } from "@shared/navigation"; import { useToast } from "@shared/components/toast"; +import { withOverride } from "@plugins"; -export default function TenantsList() { +const TenantsList = withOverride("TenantsList", function TenantsList() { const { goToTenantDetail } = useNavigationHelpers(); const { tenants, @@ -121,4 +122,6 @@ export default function TenantsList() { /> ); -} +}); + +export default TenantsList; diff --git a/src/features/tenants/components/TenantsListFooter.tsx b/src/features/tenants/components/TenantsListFooter.tsx index 1047688a..a45284cd 100644 --- a/src/features/tenants/components/TenantsListFooter.tsx +++ b/src/features/tenants/components/TenantsListFooter.tsx @@ -21,6 +21,7 @@ import IconButton from "@shared/components/iconButton"; import { TENANTS_PAGINATION_LIMIT } from "../constants"; import styles from "./TenantsListFooter.module.scss"; +import { withOverride } from "@plugins"; interface TenantsListFooterProps { totalCount: number; @@ -29,60 +30,60 @@ interface TenantsListFooterProps { isSearch: boolean; } -export default function TenantsListFooter({ - totalCount, - currentPage, - setCurrentPage, - isSearch, -}: TenantsListFooterProps) { - const totalPages = Math.ceil(totalCount / TENANTS_PAGINATION_LIMIT); - const startIndex = (currentPage - 1) * TENANTS_PAGINATION_LIMIT + 1; - const endIndex = Math.min(currentPage * TENANTS_PAGINATION_LIMIT, totalCount); +const TenantsListFooter = withOverride( + "TenantsListFooter", + function TenantsListFooter({ totalCount, currentPage, setCurrentPage, isSearch }: TenantsListFooterProps) { + const totalPages = Math.ceil(totalCount / TENANTS_PAGINATION_LIMIT); + const startIndex = (currentPage - 1) * TENANTS_PAGINATION_LIMIT + 1; + const endIndex = Math.min(currentPage * TENANTS_PAGINATION_LIMIT, totalCount); - const canGoPrevious = currentPage > 1; - const canGoNext = currentPage < totalPages; + const canGoPrevious = currentPage > 1; + const canGoNext = currentPage < totalPages; - return ( - - {isSearch ? ( - - {totalCount} results - - ) : ( - <> + return ( + + {isSearch ? ( - {startIndex} - {endIndex} of {totalCount} + {totalCount} results - - - setCurrentPage(currentPage - 1)}> - - - + setCurrentPage(currentPage + 1)}> - - - - - )} - - ); -} + weight="medium"> + {startIndex} - {endIndex} of {totalCount} + + + + setCurrentPage(currentPage - 1)}> + + + setCurrentPage(currentPage + 1)}> + + + + + )} + + ); + } +); + +export default TenantsListFooter; diff --git a/src/features/tenants/components/TenantsListHeader.tsx b/src/features/tenants/components/TenantsListHeader.tsx index 309e5f30..8607b7eb 100644 --- a/src/features/tenants/components/TenantsListHeader.tsx +++ b/src/features/tenants/components/TenantsListHeader.tsx @@ -19,6 +19,7 @@ import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons"; import Button from "@shared/components/button"; import styles from "./TenantsListHeader.module.scss"; +import { withOverride } from "@plugins"; interface TenantsListHeaderProps { searchQuery: string; @@ -27,46 +28,46 @@ interface TenantsListHeaderProps { isLoading: boolean; } -export default function TenantsListHeader({ - searchQuery, - setSearchQuery, - onAddTenant, - isLoading, -}: TenantsListHeaderProps) { - return ( - +const TenantsListHeader = withOverride( + "TenantsListHeader", + function TenantsListHeader({ searchQuery, setSearchQuery, onAddTenant, isLoading }: TenantsListHeaderProps) { + return ( - - setSearchQuery(e.target.value)}> - - - - - + justify="between" + gap="8" + mb="4" + className={styles["tenants-list-header"]}> + + + setSearchQuery(e.target.value)}> + + + + + + + - - - ); -} + ); + } +); + +export default TenantsListHeader; diff --git a/src/features/tenants/components/TenantsListItem.tsx b/src/features/tenants/components/TenantsListItem.tsx index 952f0a9f..482cd54e 100644 --- a/src/features/tenants/components/TenantsListItem.tsx +++ b/src/features/tenants/components/TenantsListItem.tsx @@ -20,12 +20,13 @@ import type { Tenant } from "@api/tenants/types"; import styles from "./TenantsListItem.module.scss"; import { useNavigationHelpers } from "@shared/navigation"; +import { withOverride } from "@plugins"; interface TenantsListItemProps { tenant: Tenant; } -export default function TenantsListItem({ tenant }: TenantsListItemProps) { +const TenantsListItem = withOverride("TenantsListItem", function TenantsListItem({ tenant }: TenantsListItemProps) { const { goToTenantDetail } = useNavigationHelpers(); return ( @@ -75,4 +76,6 @@ export default function TenantsListItem({ tenant }: TenantsListItemProps) { ); -} +}); + +export default TenantsListItem; diff --git a/src/features/tenants/components/TenantsListTable.tsx b/src/features/tenants/components/TenantsListTable.tsx index 2e82df05..6675864b 100644 --- a/src/features/tenants/components/TenantsListTable.tsx +++ b/src/features/tenants/components/TenantsListTable.tsx @@ -22,6 +22,7 @@ import { TENANTS_PAGINATION_LIMIT } from "../constants"; import TenantsListItem from "./TenantsListItem"; import styles from "./TenantsListTable.module.scss"; +import { withOverride } from "@plugins"; interface TenantsListTableProps { tenants: Tenant[]; @@ -29,63 +30,68 @@ interface TenantsListTableProps { isSearching: boolean; } -export default function TenantsListTable({ tenants, currentPage, isSearching }: TenantsListTableProps) { - const startIndex = (currentPage - 1) * TENANTS_PAGINATION_LIMIT; - const endIndex = startIndex + TENANTS_PAGINATION_LIMIT; - const paginatedTenants = tenants.slice(startIndex, endIndex); +const TenantsListTable = withOverride( + "TenantsListTable", + function TenantsListTable({ tenants, currentPage, isSearching }: TenantsListTableProps) { + const startIndex = (currentPage - 1) * TENANTS_PAGINATION_LIMIT; + const endIndex = startIndex + TENANTS_PAGINATION_LIMIT; + const paginatedTenants = tenants.slice(startIndex, endIndex); + + const getEmptyStateContent = () => { + if (isSearching) { + return { + iconUrl: "tenant.svg", + title: "No tenants found", + description: "No tenants match your search criteria. Try adjusting your search.", + }; + } - const getEmptyStateContent = () => { - if (isSearching) { return { iconUrl: "tenant.svg", - title: "No tenants found", - description: "No tenants match your search criteria. Try adjusting your search.", + title: "There are no tenants created", + description: "Once added, all tenants will be found here", }; - } - - return { - iconUrl: "tenant.svg", - title: "There are no tenants created", - description: "Once added, all tenants will be found here", }; - }; - const emptyState = getEmptyStateContent(); + const emptyState = getEmptyStateContent(); - return ( - - - - Tenant ID - - - Login Methods - - - {tenants.length === 0 ? ( - - ) : ( - - {paginatedTenants.map((tenant) => ( - - ))} + return ( + + + + Tenant ID + + + Login Methods + - )} - - ); -} + {tenants.length === 0 ? ( + + ) : ( + + {paginatedTenants.map((tenant) => ( + + ))} + + )} + + ); + } +); + +export default TenantsListTable; diff --git a/src/features/tenants/modals/AddNewProviderModal.tsx b/src/features/tenants/modals/AddNewProviderModal.tsx index f0b8aa9c..0d0ccdb6 100644 --- a/src/features/tenants/modals/AddNewProviderModal.tsx +++ b/src/features/tenants/modals/AddNewProviderModal.tsx @@ -19,6 +19,7 @@ import { PlusIcon } from "@radix-ui/react-icons"; import { Modal } from "@shared/components/modal"; import Button from "@shared/components/button"; import { getImageUrl } from "@shared/utils/index"; +import { withOverride } from "@plugins"; import styles from "./AddNewProviderModal.module.scss"; @@ -95,102 +96,111 @@ interface AddNewProviderModalProps { onProviderSelected: (providerId: string) => void; } -export default function AddNewProviderModal({ open, handleClose, onProviderSelected }: AddNewProviderModalProps) { - const handleSelectProvider = (providerId: string) => { - onProviderSelected(providerId); - handleClose(); - }; - return ( - - - - - Select the Provider that you want to add for you tenant from the list below - - +const AddNewProviderModal = withOverride( + "AddNewProviderModal", + function AddNewProviderModal({ open, handleClose, onProviderSelected }: AddNewProviderModalProps) { + const handleSelectProvider = (providerId: string) => { + onProviderSelected(providerId); + handleClose(); + }; + return ( + - - Enterprise Providers (OAuth) - - - {ENTERPRISE_PROVIDERS.map((provider) => ( - - - - ))} + className={styles["add-new-provider-modal"]}> + + + Select the Provider that you want to add for you tenant from the list below + - - - Social Providers (OAuth) - - {SOCIAL_PROVIDERS.map((provider) => ( - - - - ))} + + + Enterprise Providers (OAuth) + + + {ENTERPRISE_PROVIDERS.map((provider) => ( + + + + ))} + + + + + Social Providers (OAuth) + + + {SOCIAL_PROVIDERS.map((provider) => ( + + + + ))} + + + + + Custom OAuth Providers + + + + + SAML Providers + - - Custom OAuth Providers - - - - SAML Providers - - - - - ); -} + + ); + } +); + +export default AddNewProviderModal; diff --git a/src/features/tenants/modals/CreateNewTenantModal.tsx b/src/features/tenants/modals/CreateNewTenantModal.tsx index a5c677d3..9e3d7cb9 100644 --- a/src/features/tenants/modals/CreateNewTenantModal.tsx +++ b/src/features/tenants/modals/CreateNewTenantModal.tsx @@ -19,6 +19,7 @@ import { Flex, Text, TextField } from "@radix-ui/themes"; import { Modal } from "@shared/components/modal"; import Form from "@shared/components/form"; import Button from "@shared/components/button"; +import { withOverride } from "@plugins"; import styles from "./CreateNewTenantModal.module.scss"; import ItemLabel from "@shared/components/itemLabel"; @@ -30,86 +31,86 @@ interface CreateNewTenantModalProps { isCreating: boolean; } -export default function CreateNewTenantModal({ - open, - handleClose, - onCreateTenant, - isCreating, -}: CreateNewTenantModalProps) { - const [tenantId, setTenantId] = useState(""); - const [error, setError] = useState(undefined); +const CreateNewTenantModal = withOverride( + "CreateNewTenantModal", + function CreateNewTenantModal({ open, handleClose, onCreateTenant, isCreating }: CreateNewTenantModalProps) { + const [tenantId, setTenantId] = useState(""); + const [error, setError] = useState(undefined); - const handleSubmit = async () => { - if (tenantId.trim().length === 0) { - setError("Please enter a valid Tenant Id!"); - return; - } + const handleSubmit = async () => { + if (tenantId.trim().length === 0) { + setError("Please enter a valid Tenant Id!"); + return; + } + + try { + await onCreateTenant(tenantId.trim()); + setTenantId(""); + setError(undefined); + handleClose(); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("Something went wrong. Please try again later."); + } + } + }; + + const handleInputChange = (value: string) => { + setTenantId(value); + setError(undefined); + }; - try { - await onCreateTenant(tenantId.trim()); + const handleModalClose = () => { setTenantId(""); setError(undefined); handleClose(); - } catch (err) { - if (err instanceof Error) { - setError(err.message); - } else { - setError("Something went wrong. Please try again later."); - } - } - }; + }; - const handleInputChange = (value: string) => { - setTenantId(value); - setError(undefined); - }; + return ( + +
+ + + + Tenant Id + + handleInputChange(e.target.value)} + autoFocus + disabled={isCreating} + /> + {error && ( + + {error} + + )} + + + + + +
+
+ ); + } +); - const handleModalClose = () => { - setTenantId(""); - setError(undefined); - handleClose(); - }; - - return ( - -
- - - - Tenant Id - - handleInputChange(e.target.value)} - autoFocus - disabled={isCreating} - /> - {error && ( - - {error} - - )} - - - - - -
-
- ); -} +export default CreateNewTenantModal; diff --git a/src/features/tenants/modals/DeleteProviderConfigModal.tsx b/src/features/tenants/modals/DeleteProviderConfigModal.tsx index f3f5c686..fba478f9 100644 --- a/src/features/tenants/modals/DeleteProviderConfigModal.tsx +++ b/src/features/tenants/modals/DeleteProviderConfigModal.tsx @@ -22,6 +22,7 @@ import Button from "@shared/components/button"; import { useToast } from "@shared/components/toast"; import { useDeleteThirdPartyProviderService } from "@api/tenants"; import { useTenantDetails } from "@features/tenants/hooks/useTenantDetails"; +import { withOverride } from "@plugins"; import styles from "./DeleteProviderConfigModal.module.scss"; @@ -33,75 +34,80 @@ interface DeleteProviderConfigModalProps { onSuccess?: () => void; } -export default function DeleteProviderConfigModal({ - open, - handleClose, - tenantId, - providerId, - onSuccess, -}: DeleteProviderConfigModalProps) { - const [isDeleting, setIsDeleting] = useState(false); - const deleteThirdPartyProvider = useDeleteThirdPartyProviderService(); - const { refetch } = useTenantDetails(tenantId); - const { showSuccessToast, showErrorToast } = useToast(); +const DeleteProviderConfigModal = withOverride( + "DeleteProviderConfigModal", + function DeleteProviderConfigModal({ + open, + handleClose, + tenantId, + providerId, + onSuccess, + }: DeleteProviderConfigModalProps) { + const [isDeleting, setIsDeleting] = useState(false); + const deleteThirdPartyProvider = useDeleteThirdPartyProviderService(); + const { refetch } = useTenantDetails(tenantId); + const { showSuccessToast, showErrorToast } = useToast(); - const handleDelete = async () => { - try { - setIsDeleting(true); - const response = await deleteThirdPartyProvider(tenantId, providerId); + const handleDelete = async () => { + try { + setIsDeleting(true); + const response = await deleteThirdPartyProvider(tenantId, providerId); - if (response.status === "OK") { - showSuccessToast("Success", "Provider deleted successfully"); - await refetch(); - handleClose(); - if (onSuccess) { - onSuccess(); + if (response.status === "OK") { + showSuccessToast("Success", "Provider deleted successfully"); + await refetch(); + handleClose(); + if (onSuccess) { + onSuccess(); + } + } else { + showErrorToast("Error", "Failed to delete provider"); } - } else { - showErrorToast("Error", "Failed to delete provider"); + } catch (error) { + showErrorToast("Error", "An unexpected error occurred"); + } finally { + setIsDeleting(false); } - } catch (error) { - showErrorToast("Error", "An unexpected error occurred"); - } finally { - setIsDeleting(false); - } - }; + }; - return ( - -
- - - Are you certain you want to delete this provider? This action is irreversible. - - - - - - -
-
- ); -} + return ( + +
+ + + Are you certain you want to delete this provider? This action is irreversible. + + + + + + +
+
+ ); + } +); + +export default DeleteProviderConfigModal; diff --git a/src/features/tenants/modals/DeleteTenantModal.tsx b/src/features/tenants/modals/DeleteTenantModal.tsx index 6bc54432..aa248221 100644 --- a/src/features/tenants/modals/DeleteTenantModal.tsx +++ b/src/features/tenants/modals/DeleteTenantModal.tsx @@ -18,6 +18,7 @@ import { Flex, Text } from "@radix-ui/themes"; import { Modal } from "@shared/components/modal"; import Form from "@shared/components/form"; import Button from "@shared/components/button"; +import { withOverride } from "@plugins"; import styles from "./DeleteTenantModal.module.scss"; @@ -29,49 +30,48 @@ interface DeleteTenantModalProps { isDeleting: boolean; } -export default function DeleteTenantModal({ - open, - handleClose, - tenantId, - onDeleteTenant, - isDeleting, -}: DeleteTenantModalProps) { - const handleDelete = async () => { - try { - await onDeleteTenant(); - handleClose(); - } catch (err) { - // Error handling is done in the parent component - } - }; +const DeleteTenantModal = withOverride( + "DeleteTenantModal", + function DeleteTenantModal({ open, handleClose, tenantId, onDeleteTenant, isDeleting }: DeleteTenantModalProps) { + const handleDelete = async () => { + try { + await onDeleteTenant(); + handleClose(); + } catch (err) { + // Error handling is done in the parent component + } + }; - return ( - -
- - - Are you certain you want to delete tenant{" "} - "{tenantId}"? This action is - irreversible. - - - - - -
-
- ); -} + return ( + +
+ + + Are you certain you want to delete tenant{" "} + "{tenantId}"? This action + is irreversible. + + + + + +
+
+ ); + } +); + +export default DeleteTenantModal; diff --git a/src/features/tenants/modals/EditConfigurationPropertyModal.tsx b/src/features/tenants/modals/EditConfigurationPropertyModal.tsx index 60f78cfe..9576370c 100644 --- a/src/features/tenants/modals/EditConfigurationPropertyModal.tsx +++ b/src/features/tenants/modals/EditConfigurationPropertyModal.tsx @@ -26,6 +26,7 @@ import Callout from "@shared/components/callout"; import Button from "@shared/components/button"; import { useToast } from "@shared/components/toast"; import { useTenantDetails } from "@features/tenants/hooks/useTenantDetails"; +import { withOverride } from "@plugins"; import styles from "./EditConfigurationPropertyModal.module.scss"; @@ -36,210 +37,215 @@ interface EditConfigurationPropertyModalProps { tenantId: string; } -export default function EditConfigurationPropertyModal({ - open, - handleClose, - config, - tenantId, -}: EditConfigurationPropertyModalProps) { - const [currentValue, setCurrentValue] = useState(config.value); - const [isLoading, setIsLoading] = useState(false); - const { updateCoreConfig, refetch } = useTenantDetails(tenantId); - const { showSuccessToast, showErrorToast } = useToast(); - - const isMultiValue = Array.isArray(config.possibleValues) && config.possibleValues.length > 0; - - const toggleNull = useCallback(() => { - setCurrentValue((currentValue) => { - if (currentValue === null) { - // Restore to default value or appropriate zero value - if (config.valueType === "number") { - return config.defaultValue !== null ? config.defaultValue : 0; - } else if (config.valueType === "boolean") { - return config.defaultValue !== null ? config.defaultValue : false; +const EditConfigurationPropertyModal = withOverride( + "EditConfigurationPropertyModal", + function EditConfigurationPropertyModal({ + open, + handleClose, + config, + tenantId, + }: EditConfigurationPropertyModalProps) { + const [currentValue, setCurrentValue] = useState(config.value); + const [isLoading, setIsLoading] = useState(false); + const { updateCoreConfig, refetch } = useTenantDetails(tenantId); + const { showSuccessToast, showErrorToast } = useToast(); + + const isMultiValue = Array.isArray(config.possibleValues) && config.possibleValues.length > 0; + + const toggleNull = useCallback(() => { + setCurrentValue((currentValue) => { + if (currentValue === null) { + // Restore to default value or appropriate zero value + if (config.valueType === "number") { + return config.defaultValue !== null ? config.defaultValue : 0; + } else if (config.valueType === "boolean") { + return config.defaultValue !== null ? config.defaultValue : false; + } else { + return config.defaultValue !== null ? config.defaultValue : ""; + } } else { - return config.defaultValue !== null ? config.defaultValue : ""; + return null; } - } else { - return null; - } - }); - }, [config.defaultValue, config.valueType]); - - const handleSaveProperty = async () => { - try { - setIsLoading(true); - - // Parse value based on type - let parsedValue: string | number | boolean | null = currentValue; - - if (config.valueType === "number" && typeof currentValue === "string") { - parsedValue = parseInt(currentValue, 10); - if (isNaN(parsedValue as number)) { - showErrorToast("Invalid Value", "Please enter a valid number"); - setIsLoading(false); - return; + }); + }, [config.defaultValue, config.valueType]); + + const handleSaveProperty = async () => { + try { + setIsLoading(true); + + // Parse value based on type + let parsedValue: string | number | boolean | null = currentValue; + + if (config.valueType === "number" && typeof currentValue === "string") { + parsedValue = parseInt(currentValue, 10); + if (isNaN(parsedValue as number)) { + showErrorToast("Invalid Value", "Please enter a valid number"); + setIsLoading(false); + return; + } } - } - await updateCoreConfig({ - name: config.key, - value: parsedValue, - }); + await updateCoreConfig({ + name: config.key, + value: parsedValue, + }); + + await refetch(); + showSuccessToast("Success", `Property "${config.key}" updated successfully`); + handleClose(); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : "Something went wrong. Please try again."; + showErrorToast("Update Failed", errorMessage); + } finally { + setIsLoading(false); + } + }; - await refetch(); - showSuccessToast("Success", `Property "${config.key}" updated successfully`); - handleClose(); - } catch (e: unknown) { - const errorMessage = e instanceof Error ? e.message : "Something went wrong. Please try again."; - showErrorToast("Update Failed", errorMessage); - } finally { - setIsLoading(false); - } - }; - - const renderValueInput = () => { - // Multi-value select (dropdown) - if (isMultiValue && config.possibleValues) { - return ( - setCurrentValue(value)} - disabled={currentValue === null}> - - - {config.possibleValues.map((option) => ( - - {option} - - ))} - - - ); - } + const renderValueInput = () => { + // Multi-value select (dropdown) + if (isMultiValue && config.possibleValues) { + return ( + setCurrentValue(value)} + disabled={currentValue === null}> + + + {config.possibleValues.map((option) => ( + + {option} + + ))} + + + ); + } - // Boolean type - use switch - if (config.valueType === "boolean") { - if (currentValue === null) { + // Boolean type - use switch + if (config.valueType === "boolean") { + if (currentValue === null) { + return ( + + [null] + + ); + } return ( - - [null] - + + setCurrentValue(checked)} + /> + {currentValue ? "True" : "False"} + ); } + + // String or number type - use text field return ( - - setCurrentValue(checked)} - /> - {currentValue ? "True" : "False"} - + { + const value = e.target.value; + setCurrentValue(value); + }} + placeholder={currentValue === null ? "[null]" : ""} + disabled={currentValue === null} + color="gray" + size="3" + variant="surface" + className={styles["edit-configuration-property-modal__value"]} + /> ); - } + }; - // String or number type - use text field return ( - { - const value = e.target.value; - setCurrentValue(value); - }} - placeholder={currentValue === null ? "[null]" : ""} - disabled={currentValue === null} - color="gray" - size="3" - variant="surface" - className={styles["edit-configuration-property-modal__value"]} - /> - ); - }; - - return ( - -
- - - Property Name: - - - {config.key} - - - - + + + - Value: + className={styles["edit-configuration-property-modal__heading__label"]}> + Property Name: - {renderValueInput()} - {config.isNullable && ( - - - Set value as null - - )} - - {config.description && ( - - - {config.description} - - {config.defaultValue !== null && ( - - Default Value: {String(config.defaultValue)} - + + {config.key} + + + + + + Value: + + {renderValueInput()} + {config.isNullable && ( + + + Set value as null + )} - - )} + + {config.description && ( + + + {config.description} + + {config.defaultValue !== null && ( + + Default Value: {String(config.defaultValue)} + + )} + + )} + +
+ + - - - - -
- ); -} + + ); + } +); + +export default EditConfigurationPropertyModal; diff --git a/src/features/tenants/modals/EditPluginPropertyModal.tsx b/src/features/tenants/modals/EditPluginPropertyModal.tsx index 892dc61d..9b08c5e1 100644 --- a/src/features/tenants/modals/EditPluginPropertyModal.tsx +++ b/src/features/tenants/modals/EditPluginPropertyModal.tsx @@ -22,6 +22,7 @@ import Form from "@shared/components/form"; import { useToast } from "@shared/components/toast"; import { copyToClipboard } from "@shared/utils/copyToClipboard"; import Button from "@shared/components/button"; +import { withOverride } from "@plugins"; import styles from "./EditPluginPropertyModal.module.scss"; @@ -32,15 +33,12 @@ interface EditPluginPropertyModalProps { databaseType: "postgres" | "mysql"; } -export default function EditPluginPropertyModal({ - open, - handleClose, - tenantId, - databaseType, -}: EditPluginPropertyModalProps) { - const { showSuccessToast, showErrorToast } = useToast(); +const EditPluginPropertyModal = withOverride( + "EditPluginPropertyModal", + function EditPluginPropertyModal({ open, handleClose, tenantId, databaseType }: EditPluginPropertyModalProps) { + const { showSuccessToast, showErrorToast } = useToast(); - const command = `curl --location --request PUT '${getConnectionUri()}/recipe/multitenancy/tenant/v2' \\ + const command = `curl --location --request PUT '${getConnectionUri()}/recipe/multitenancy/tenant/v2' \\ --header 'api-key: ' \\ --header 'Content-Type: application/json' \\ --data-raw '{ @@ -54,58 +52,61 @@ export default function EditPluginPropertyModal({ } }'`; - const handleCopy = async () => { - await copyToClipboard( - command, - () => { - showSuccessToast("Copied", "Command copied to clipboard"); - }, - () => { - showErrorToast("Failed", "Could not copy to clipboard"); - } - ); - }; + const handleCopy = async () => { + await copyToClipboard( + command, + () => { + showSuccessToast("Copied", "Command copied to clipboard"); + }, + () => { + showErrorToast("Failed", "Could not copy to clipboard"); + } + ); + }; - return ( - -
- - - Use the following curl request to modify multiple database properties at once. - - + return ( + + + + + Use the following curl request to modify multiple database properties at once. + - - cURL Command - - + direction="column" + className={styles["edit-plugin-property-modal__command"]}> + + + cURL Command + + + +
+								{command}
+							
-
-							{command}
-						
-
-
-
-
- ); -} + + + + ); + } +); + +export default EditPluginPropertyModal; diff --git a/src/features/tenants/modals/UneditableConfigurationModal.tsx b/src/features/tenants/modals/UneditableConfigurationModal.tsx index 946a80a7..8b0c6cd0 100644 --- a/src/features/tenants/modals/UneditableConfigurationModal.tsx +++ b/src/features/tenants/modals/UneditableConfigurationModal.tsx @@ -17,6 +17,7 @@ import { Text } from "@radix-ui/themes"; import { Modal } from "@shared/components/modal"; import Form from "@shared/components/form"; +import { withOverride } from "@plugins"; import styles from "./UneditableConfigurationModal.module.scss"; @@ -26,23 +27,28 @@ interface UneditableConfigurationModalProps { reason: React.ReactNode; } -export default function UneditableConfigurationModal({ open, handleClose, reason }: UneditableConfigurationModalProps) { - return ( - -
- - - {reason} - - -
-
- ); -} +const UneditableConfigurationModal = withOverride( + "UneditableConfigurationModal", + function UneditableConfigurationModal({ open, handleClose, reason }: UneditableConfigurationModalProps) { + return ( + +
+ + + {reason} + + +
+
+ ); + } +); + +export default UneditableConfigurationModal; diff --git a/src/features/tenants/tenant-details/LoginMethods.tsx b/src/features/tenants/tenant-details/LoginMethods.tsx index d44853c6..0cce5f49 100644 --- a/src/features/tenants/tenant-details/LoginMethods.tsx +++ b/src/features/tenants/tenant-details/LoginMethods.tsx @@ -22,117 +22,121 @@ import { getImageUrl } from "@shared/utils"; import TabSelector from "@shared/components/tabSelector"; import ItemLabel from "@shared/components/itemLabel"; import { useToast } from "@shared/components/toast"; +import { withOverride } from "@plugins"; import { useTenantDetails } from "../hooks/useTenantDetails"; import styles from "./LoginMethods.module.scss"; import { SupertokensPreview } from "@shared/components/supertokens-preview"; -export const LoginMethods = ({ tenantInfo }: { tenantInfo: TenantInfo }) => { - const enabledFirstFactors = tenantInfo.firstFactors || []; - - return ( - - - The login methods you wish to activate for the tenant - - - {enabledFirstFactors.length === 0 && ( - - - At least one login method needs to be enabled for the user to log in to the tenant. - - - )} +export const LoginMethods = withOverride( + "LoginMethods", + function LoginMethods({ tenantInfo }: { tenantInfo: TenantInfo }) { + const enabledFirstFactors = tenantInfo.firstFactors || []; + return ( + direction="column" + className={styles["login-methods"]}> + + The login methods you wish to activate for the tenant + + + {enabledFirstFactors.length === 0 && ( + + + At least one login method needs to be enabled for the user to log in to the tenant. + + + )} + - {FIRST_FACTOR_IDS.map((factor) => { - const isEnabled = enabledFirstFactors.includes(factor.id); - return ( - - ); - })} + className={styles["login-methods__content"]} + width="100%" + p="4" + gap="3"> + + {FIRST_FACTOR_IDS.map((factor) => { + const isEnabled = enabledFirstFactors.includes(factor.id); + return ( + + ); + })} + + + + Preview + + {enabledFirstFactors.length === 0 ? ( + + Shield + + Select login methods to see preview + + + ) : ( + + + + )} + - - - Preview - - {enabledFirstFactors.length === 0 ? ( - - Shield + {enabledFirstFactors.length > 0 && ( + + - Select login methods to see preview + size="2" + className={styles["login-methods__footer__callout__text"]}> + {enabledFirstFactors.length} login methods enabled: Users will be able to + sign up using any of the selected methods - - ) : ( - - - - )} - + + + )} - {enabledFirstFactors.length > 0 && ( - - - - {enabledFirstFactors.length} login methods enabled: Users will be able to sign - up using any of the selected methods - - - - )} - - ); -}; + ); + } +); interface LoginMethodItemProps { factorId: string; @@ -142,97 +146,100 @@ interface LoginMethodItemProps { tenantId: string; } -const LoginMethodItem = ({ factorId, label, description, isEnabled, tenantId }: LoginMethodItemProps) => { - const [isLoading, setIsLoading] = useState(false); - const { showErrorToast } = useToast(); - const [error, setError] = useState< - "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR" | "UNKNOWN_TENANT_ERROR" | "GENERIC_ERROR" | null - >(null); - const { updateFirstFactor } = useTenantDetails(tenantId); +export const LoginMethodItem = withOverride( + "LoginMethodItem", + function LoginMethodItem({ factorId, label, description, isEnabled, tenantId }: LoginMethodItemProps) { + const [isLoading, setIsLoading] = useState(false); + const { showErrorToast } = useToast(); + const [error, setError] = useState< + "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR" | "UNKNOWN_TENANT_ERROR" | "GENERIC_ERROR" | null + >(null); + const { updateFirstFactor } = useTenantDetails(tenantId); - const handleToggle = async () => { - try { - setIsLoading(true); - const response = await updateFirstFactor({ factorId, enable: !isEnabled }); + const handleToggle = async () => { + try { + setIsLoading(true); + const response = await updateFirstFactor({ factorId, enable: !isEnabled }); - if (response.status !== "OK") { - if (response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR") { - setError("RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR"); - } else if (response.status === "UNKNOWN_TENANT_ERROR") { - setError("UNKNOWN_TENANT_ERROR"); - showErrorToast("Could not update login method. Tenant not found!"); + if (response.status !== "OK") { + if (response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR") { + setError("RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR"); + } else if (response.status === "UNKNOWN_TENANT_ERROR") { + setError("UNKNOWN_TENANT_ERROR"); + showErrorToast("Could not update login method. Tenant not found!"); + } else { + setError("GENERIC_ERROR"); + showErrorToast("Could not update login method. Something went wrong!"); + } } else { - setError("GENERIC_ERROR"); - showErrorToast("Could not update login method. Something went wrong!"); + setError(null); } - } else { - setError(null); + } catch (error) { + setError("GENERIC_ERROR"); + showErrorToast("Could not update login method. Something went wrong!"); + } finally { + setIsLoading(false); } - } catch (error) { - setError("GENERIC_ERROR"); - showErrorToast("Could not update login method. Something went wrong!"); - } finally { - setIsLoading(false); - } - }; + }; - return ( - + return ( + className={styles["login-method-item"]}> + direction="column" + className={styles["login-method-item__container"]}> - - {label} - - + + + {label} + + + {description} + + + - {description} - + variant="classic" + checked={isEnabled} + disabled={isLoading} + onCheckedChange={handleToggle} + className={styles["login-method-item__container__method__switch"]} + /> - - - {(() => { - switch (error) { - case "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR": - return ( - - ⚠️ This login method is not configured in your backend SDK. Please check your - configuration. - - ); + {(() => { + switch (error) { + case "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR": + return ( + + ⚠️ This login method is not configured in your backend SDK. Please check your + configuration. + + ); - default: - return null; - } - })()} + default: + return null; + } + })()} + - - ); -}; + ); + } +); diff --git a/src/features/tenants/tenant-details/Providers.tsx b/src/features/tenants/tenant-details/Providers.tsx index 9d5b6739..9fa9ae34 100644 --- a/src/features/tenants/tenant-details/Providers.tsx +++ b/src/features/tenants/tenant-details/Providers.tsx @@ -30,180 +30,184 @@ import AddNewProviderModal from "@features/tenants/modals/AddNewProviderModal"; import { PROVIDERS_WITH_ADDITIONAL_CONFIG } from "@features/tenants/constants/providers"; import { ProviderConfiguration } from "./provider-configuration/ProviderConfiguration"; import { AdditionalConfigForms } from "./provider-configuration/AdditionalConfigForms"; +import { withOverride } from "@plugins"; import styles from "./Providers.module.scss"; -export const Providers = ({ - tenantId, - tenantInfo, -}: { - tenantId: string; - tenantInfo: { - thirdParty: { providers: { thirdPartyId: string; name: string }[] }; - firstFactors: string[]; - }; -}) => { - const [isNewProviderModalOpen, setIsNewProviderModalOpen] = useState(false); - const [selectedProvider, setSelectedProvider] = useState( - tenantInfo.thirdParty.providers.length > 0 ? tenantInfo.thirdParty.providers[0].thirdPartyId : undefined - ); - const [newProviderId, setNewProviderId] = useState(undefined); - const [isAddingNewProvider, setIsAddingNewProvider] = useState(false); - - const handleSelectProvider = (providerId: string) => { - setSelectedProvider(providerId); - setIsAddingNewProvider(false); - setNewProviderId(undefined); - }; - - const handleProviderDeleted = () => { - setSelectedProvider( +export const Providers = withOverride( + "Providers", + function Providers({ + tenantId, + tenantInfo, + }: { + tenantId: string; + tenantInfo: { + thirdParty: { providers: { thirdPartyId: string; name: string }[] }; + firstFactors: string[]; + }; + }) { + const [isNewProviderModalOpen, setIsNewProviderModalOpen] = useState(false); + const [selectedProvider, setSelectedProvider] = useState( tenantInfo.thirdParty.providers.length > 0 ? tenantInfo.thirdParty.providers[0].thirdPartyId : undefined ); - setIsAddingNewProvider(false); - setNewProviderId(undefined); - }; + const [newProviderId, setNewProviderId] = useState(undefined); + const [isAddingNewProvider, setIsAddingNewProvider] = useState(false); - const handleNewProviderSelected = (providerId: string) => { - setNewProviderId(providerId); - setIsAddingNewProvider(true); - setSelectedProvider(undefined); - }; + const handleSelectProvider = (providerId: string) => { + setSelectedProvider(providerId); + setIsAddingNewProvider(false); + setNewProviderId(undefined); + }; - const handleProviderSaved = () => { - setIsAddingNewProvider(false); - setNewProviderId(undefined); - // The tenant info will be refreshed, so we'll see the new provider - }; + const handleProviderDeleted = () => { + setSelectedProvider( + tenantInfo.thirdParty.providers.length > 0 ? tenantInfo.thirdParty.providers[0].thirdPartyId : undefined + ); + setIsAddingNewProvider(false); + setNewProviderId(undefined); + }; - const handleCancelAddProvider = () => { - setIsAddingNewProvider(false); - setNewProviderId(undefined); - }; + const handleNewProviderSelected = (providerId: string) => { + setNewProviderId(providerId); + setIsAddingNewProvider(true); + setSelectedProvider(undefined); + }; - const getProviderIcon = (thirdPartyId: string) => { - const builtInProvider = IN_BUILT_THIRD_PARTY_PROVIDERS.find((p) => thirdPartyId.startsWith(p.id)); - if (builtInProvider) { - return builtInProvider.icon; - } - return "permission.svg"; - }; + const handleProviderSaved = () => { + setIsAddingNewProvider(false); + setNewProviderId(undefined); + // The tenant info will be refreshed, so we'll see the new provider + }; - const getProviderName = (provider: { thirdPartyId: string; name: string }) => { - return provider.name || provider.thirdPartyId; - }; + const handleCancelAddProvider = () => { + setIsAddingNewProvider(false); + setNewProviderId(undefined); + }; - const tenantHasThirdPartyEnabled = tenantInfo.firstFactors?.includes(FactorIds.THIRDPARTY); + const getProviderIcon = (thirdPartyId: string) => { + const builtInProvider = IN_BUILT_THIRD_PARTY_PROVIDERS.find((p) => thirdPartyId.startsWith(p.id)); + if (builtInProvider) { + return builtInProvider.icon; + } + return "permission.svg"; + }; - return ( - - - - Configure third-party OAuth 2.0/OIDC/SAML providers available for user sign-in/sign-up - - {tenantHasThirdPartyEnabled && ( - <> - - setIsNewProviderModalOpen(false)} - onProviderSelected={handleNewProviderSelected} - /> - - )} - - {tenantInfo.thirdParty.providers.length === 0 && !isAddingNewProvider ? ( - - ) : ( - <> - {tenantInfo.thirdParty.providers.length > 0 && ( - - {tenantInfo.thirdParty.providers.map((provider) => { - const isActive = selectedProvider === provider.thirdPartyId; - const buttonClass = `${styles["provider-button"]} ${ - isActive ? styles["provider-button--active"] : "" - }`; - const labelClass = `${styles["provider-button__label"]} ${ - isActive ? styles["provider-button__label--active"] : "" - }`; + const getProviderName = (provider: { thirdPartyId: string; name: string }) => { + return provider.name || provider.thirdPartyId; + }; - return ( - - ); - })} - - )} - {selectedProvider && !isAddingNewProvider && ( - - - - )} - {isAddingNewProvider && newProviderId && ( - - + + + Configure third-party OAuth 2.0/OIDC/SAML providers available for user sign-in/sign-up + + {tenantHasThirdPartyEnabled && ( + <> + + setIsNewProviderModalOpen(false)} + onProviderSelected={handleNewProviderSelected} /> - + )} - - )} - - ); -}; + + {tenantInfo.thirdParty.providers.length === 0 && !isAddingNewProvider ? ( + + ) : ( + <> + {tenantInfo.thirdParty.providers.length > 0 && ( + + {tenantInfo.thirdParty.providers.map((provider) => { + const isActive = selectedProvider === provider.thirdPartyId; + const buttonClass = `${styles["provider-button"]} ${ + isActive ? styles["provider-button--active"] : "" + }`; + const labelClass = `${styles["provider-button__label"]} ${ + isActive ? styles["provider-button__label--active"] : "" + }`; + + return ( + + ); + })} + + )} + {selectedProvider && !isAddingNewProvider && ( + + + + )} + {isAddingNewProvider && newProviderId && ( + + + + )} + + )} + + ); + } +); interface ProviderConfigWrapperProps { tenantId: string; @@ -224,125 +228,128 @@ interface ProviderConfigWrapperProps { * 3. Then show full provider configuration form * 4. For editing existing providers OR providers without additional config → Show full form directly */ -const ProviderConfigWrapper = ({ - tenantId, - providerId, - isAddingNewProvider, - onDelete, - onSave, - onCancel, -}: ProviderConfigWrapperProps) => { - const [isLoading, setIsLoading] = useState(false); - const [providerConfigResponse, setProviderConfigResponse] = useState(); - const [hasFilledAdditionalConfig, setHasFilledAdditionalConfig] = useState( - !PROVIDERS_WITH_ADDITIONAL_CONFIG.includes(providerId) - ); - const [additionalConfig, setAdditionalConfig] = useState | undefined>(); +export const ProviderConfigWrapper = withOverride( + "ProviderConfigWrapper", + function ProviderConfigWrapper({ + tenantId, + providerId, + isAddingNewProvider, + onDelete, + onSave, + onCancel, + }: ProviderConfigWrapperProps) { + const [isLoading, setIsLoading] = useState(false); + const [providerConfigResponse, setProviderConfigResponse] = useState(); + const [hasFilledAdditionalConfig, setHasFilledAdditionalConfig] = useState( + !PROVIDERS_WITH_ADDITIONAL_CONFIG.includes(providerId) + ); + const [additionalConfig, setAdditionalConfig] = useState | undefined>(); - const getThirdPartyProviderInfo = useGetThirdPartyProviderInfoService(); - const providerNeedsAdditionalConfig = PROVIDERS_WITH_ADDITIONAL_CONFIG.includes(providerId); + const getThirdPartyProviderInfo = useGetThirdPartyProviderInfoService(); + const providerNeedsAdditionalConfig = PROVIDERS_WITH_ADDITIONAL_CONFIG.includes(providerId); - useEffect(() => { - const fetchProviderInfo = async () => { - try { - setIsLoading(true); - const response = await getThirdPartyProviderInfo(tenantId, providerId, additionalConfig); - if (response.status === "OK") { - setProviderConfigResponse(response.providerConfig); + useEffect(() => { + const fetchProviderInfo = async () => { + try { + setIsLoading(true); + const response = await getThirdPartyProviderInfo(tenantId, providerId, additionalConfig); + if (response.status === "OK") { + setProviderConfigResponse(response.providerConfig); - // Special case for boxy-saml: check if boxyAPIKey is present - if ( - providerId.startsWith("boxy-saml") && - response.providerConfig.clients?.[0]?.additionalConfig?.boxyAPIKey === undefined - ) { - setHasFilledAdditionalConfig(false); - } else { - setHasFilledAdditionalConfig(true); + // Special case for boxy-saml: check if boxyAPIKey is present + if ( + providerId.startsWith("boxy-saml") && + response.providerConfig.clients?.[0]?.additionalConfig?.boxyAPIKey === undefined + ) { + setHasFilledAdditionalConfig(false); + } else { + setHasFilledAdditionalConfig(true); + } } + } catch (error) { + console.error("Failed to fetch provider info:", error); + } finally { + setIsLoading(false); } - } catch (error) { - console.error("Failed to fetch provider info:", error); - } finally { - setIsLoading(false); + }; + + // Fetch provider info if: + // 1. Not adding new provider (editing existing) + // 2. OR adding new provider that doesn't need additional config + // 3. OR adding new provider that needs additional config and has filled it + // 4. OR it's boxy-saml (special case - always fetch to check boxyAPIKey) + if ( + !isAddingNewProvider || + !providerNeedsAdditionalConfig || + hasFilledAdditionalConfig || + providerId.startsWith("boxy-saml") + ) { + void fetchProviderInfo(); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantId, providerId, isAddingNewProvider, additionalConfig, hasFilledAdditionalConfig]); + + const handleAdditionalConfigContinue = (config: Record) => { + setAdditionalConfig(config); + setHasFilledAdditionalConfig(true); }; - // Fetch provider info if: - // 1. Not adding new provider (editing existing) - // 2. OR adding new provider that doesn't need additional config - // 3. OR adding new provider that needs additional config and has filled it - // 4. OR it's boxy-saml (special case - always fetch to check boxyAPIKey) - if ( - !isAddingNewProvider || - !providerNeedsAdditionalConfig || - hasFilledAdditionalConfig || - providerId.startsWith("boxy-saml") - ) { - void fetchProviderInfo(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantId, providerId, isAddingNewProvider, additionalConfig, hasFilledAdditionalConfig]); + // Show additional config form if: + // 1. Provider needs additional config + // 2. User hasn't filled it yet + // 3. AND (adding new provider OR editing boxy-saml without boxyAPIKey) + const shouldShowAdditionalConfigForm = + providerNeedsAdditionalConfig && !hasFilledAdditionalConfig && isAddingNewProvider; - const handleAdditionalConfigContinue = (config: Record) => { - setAdditionalConfig(config); - setHasFilledAdditionalConfig(true); - }; + const handleCancel = () => { + if (onCancel) { + onCancel(); + } + }; - // Show additional config form if: - // 1. Provider needs additional config - // 2. User hasn't filled it yet - // 3. AND (adding new provider OR editing boxy-saml without boxyAPIKey) - const shouldShowAdditionalConfigForm = - providerNeedsAdditionalConfig && !hasFilledAdditionalConfig && isAddingNewProvider; + if (shouldShowAdditionalConfigForm) { + return ( + + ); + } - const handleCancel = () => { - if (onCancel) { - onCancel(); + // Special case: boxy-saml without boxyAPIKey (editing existing provider) + if ( + providerId.startsWith("boxy-saml") && + !hasFilledAdditionalConfig && + providerConfigResponse?.clients?.[0]?.additionalConfig?.boxyAPIKey === undefined + ) { + return ( + + ); } - }; - if (shouldShowAdditionalConfigForm) { - return ( - - ); - } + if (isLoading) { + return ; + } - // Special case: boxy-saml without boxyAPIKey (editing existing provider) - if ( - providerId.startsWith("boxy-saml") && - !hasFilledAdditionalConfig && - providerConfigResponse?.clients?.[0]?.additionalConfig?.boxyAPIKey === undefined - ) { + // Show full provider configuration form return ( - ); } - - if (isLoading) { - return ; - } - - // Show full provider configuration form - return ( - - ); -}; +); diff --git a/src/features/tenants/tenant-details/SecondaryFactors.tsx b/src/features/tenants/tenant-details/SecondaryFactors.tsx index cf08b318..9bdf298a 100644 --- a/src/features/tenants/tenant-details/SecondaryFactors.tsx +++ b/src/features/tenants/tenant-details/SecondaryFactors.tsx @@ -22,6 +22,7 @@ import { getImageUrl } from "@shared/utils/index"; import TabSelector from "@shared/components/tabSelector"; import ItemLabel from "@shared/components/itemLabel"; import { useToast } from "@shared/components/toast"; +import { withOverride } from "@plugins"; import { useTenantDetails } from "../hooks/useTenantDetails"; import styles from "./SecondaryFactors.module.scss"; @@ -31,128 +32,131 @@ type SecondaryFactor = "totp" | "otp-email" | "otp-phone"; type MFAError = null | "MFA_NOT_INITIALIZED" | "MFA_REQUIREMENTS_FOR_AUTH_OVERRIDDEN"; -export const SecondaryFactors = ({ tenantInfo }: { tenantInfo: TenantInfo }) => { - const [requiredSecondaryFactors, setRequiredSecondaryFactors] = useState( - (tenantInfo.requiredSecondaryFactors as SecondaryFactor[]) || [] - ); - const [mfaError, setMfaError] = useState(null); +export const SecondaryFactors = withOverride( + "SecondaryFactors", + function SecondaryFactors({ tenantInfo }: { tenantInfo: TenantInfo }) { + const [requiredSecondaryFactors, setRequiredSecondaryFactors] = useState( + (tenantInfo.requiredSecondaryFactors as SecondaryFactor[]) || [] + ); + const [mfaError, setMfaError] = useState(null); - const handleSecondaryFactorToggle = (factorId: string, enable: boolean) => { - setRequiredSecondaryFactors((prev) => { - if (enable) { - return [...prev, factorId as SecondaryFactor]; - } else { - return prev.filter((id) => id !== factorId); - } - }); - }; + const handleSecondaryFactorToggle = (factorId: string, enable: boolean) => { + setRequiredSecondaryFactors((prev) => { + if (enable) { + return [...prev, factorId as SecondaryFactor]; + } else { + return prev.filter((id) => id !== factorId); + } + }); + }; - return ( - - - - The secondary factors necessary for successful authentication for this tenant post-login. - - + return ( + + + + The secondary factors necessary for successful authentication for this tenant post-login. + + - {mfaError === "MFA_NOT_INITIALIZED" && ( - - - You need to initialize the MFA recipe to use secondary factors.{" "} - - Click here - {" "} - to see MFA docs for more info. - - - )} + {mfaError === "MFA_NOT_INITIALIZED" && ( + + + You need to initialize the MFA recipe to use secondary factors.{" "} + + Click here + {" "} + to see MFA docs for more info. + + + )} - {mfaError === "MFA_REQUIREMENTS_FOR_AUTH_OVERRIDDEN" && ( - - - Please note that the MFA functions are overridden in the SDK and the required secondary factors - settings will be based on the overridden logic. - - - )} + {mfaError === "MFA_REQUIREMENTS_FOR_AUTH_OVERRIDDEN" && ( + + + Please note that the MFA functions are overridden in the SDK and the required secondary + factors settings will be based on the overridden logic. + + + )} - - {SECONDARY_FACTOR_IDS.map((factor) => { - const isRequired = requiredSecondaryFactors.includes(factor.id as SecondaryFactor); - return ( - - ); - })} - - - - Preview - - {requiredSecondaryFactors.length === 0 ? ( - - Shield - - Select secondary factor to see preview - - - ) : ( - - - - )} + className={styles["secondary-factors__content"]} + width="100%" + p="4" + gap="3"> + + {SECONDARY_FACTOR_IDS.map((factor) => { + const isRequired = requiredSecondaryFactors.includes(factor.id as SecondaryFactor); + return ( + + ); + })} + + + + Preview + + {requiredSecondaryFactors.length === 0 ? ( + + Shield + + Select secondary factor to see preview + + + ) : ( + + + + )} + - - ); -}; + ); + } +); interface SecondaryFactorItemProps { factorId: string; @@ -164,100 +168,105 @@ interface SecondaryFactorItemProps { onToggle: (factorId: string, enable: boolean) => void; } -const SecondaryFactorItem = ({ - factorId, - label, - description, - isRequired, - tenantId, - setMfaError, - onToggle, -}: SecondaryFactorItemProps) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { updateRequiredSecondaryFactor } = useTenantDetails(tenantId); - const { showErrorToast } = useToast(); +export const SecondaryFactorItem = withOverride( + "SecondaryFactorItem", + function SecondaryFactorItem({ + factorId, + label, + description, + isRequired, + tenantId, + setMfaError, + onToggle, + }: SecondaryFactorItemProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { updateRequiredSecondaryFactor } = useTenantDetails(tenantId); + const { showErrorToast } = useToast(); - const handleToggle = async () => { - try { - setIsLoading(true); - const newState = !isRequired; - const response = await updateRequiredSecondaryFactor({ factorId, enable: newState }); + const handleToggle = async () => { + try { + setIsLoading(true); + const newState = !isRequired; + const response = await updateRequiredSecondaryFactor({ factorId, enable: newState }); - if (response.status !== "OK") { - if (response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR") { - setError(response.message); - } else if (response.status === "MFA_NOT_INITIALIZED_ERROR") { - setMfaError("MFA_NOT_INITIALIZED"); + if (response.status !== "OK") { + if (response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR") { + setError(response.message); + } else if (response.status === "MFA_NOT_INITIALIZED_ERROR") { + setMfaError("MFA_NOT_INITIALIZED"); + } else { + throw new Error(response.status); + } } else { - throw new Error(response.status); + // Update local state when the backend update is successful + onToggle(factorId, newState); + if (response.isMFARequirementsForAuthOverridden) { + setMfaError("MFA_REQUIREMENTS_FOR_AUTH_OVERRIDDEN"); + } else { + setError(null); + } } - } else { - // Update local state when the backend update is successful - onToggle(factorId, newState); - if (response.isMFARequirementsForAuthOverridden) { - setMfaError("MFA_REQUIREMENTS_FOR_AUTH_OVERRIDDEN"); - } else { - setError(null); - } - } - // If this is not a MFA related error then clear the error - if ( - (response.status === "OK" && !response.isMFARequirementsForAuthOverridden) || - response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR" - ) { - setMfaError(null); + // If this is not a MFA related error then clear the error + if ( + (response.status === "OK" && !response.isMFARequirementsForAuthOverridden) || + response.status === "RECIPE_NOT_CONFIGURED_ON_BACKEND_SDK_ERROR" + ) { + setMfaError(null); + } + } catch (error) { + const errorMessage = (error as Error).message; + showErrorToast( + errorMessage === "UNKNOWN_TENANT_ERROR" ? "Tenant does not exist" : "Something went wrong!" + ); + } finally { + setIsLoading(false); } - } catch (error) { - const errorMessage = (error as Error).message; - showErrorToast(errorMessage === "UNKNOWN_TENANT_ERROR" ? "Tenant does not exist" : "Something went wrong!"); - } finally { - setIsLoading(false); - } - }; + }; - return ( - - + return ( + - - {label} - - - {description} - - {error && ( + justify="between" + className={styles["secondary-factors__content__main__item"]} + align="center" + mx="4" + py="4" + gap="2"> + - ⚠️ {error} + size="2" + weight="medium" + className={styles["secondary-factors__content__main__item__name"]}> + {label} + + + {description} - )} + {error && ( + + ⚠️ {error} + + )} + + - - - ); -}; + ); + } +); diff --git a/src/features/tenants/tenant-details/TenantDetails.tsx b/src/features/tenants/tenant-details/TenantDetails.tsx index 42d848b9..6f9b1288 100644 --- a/src/features/tenants/tenant-details/TenantDetails.tsx +++ b/src/features/tenants/tenant-details/TenantDetails.tsx @@ -39,6 +39,7 @@ import { SecondaryFactors } from "./SecondaryFactors"; import CoreConfiguration from "./core-configuration/CoreConfiguration"; import { Providers } from "./Providers"; import { useNavigationHelpers } from "@shared/navigation"; +import { withOverride } from "@plugins"; import styles from "./TenantDetails.module.scss"; @@ -63,129 +64,132 @@ const tenantDetailTabs: { name: string; value: TenantDetailTab }[] = [ }, ]; -const TenantDetailContent = ({ - tenantId, - tenantInfo, - onDeleteTenant, - isDeletingTenant, -}: { - tenantId: string; - tenantInfo: NonNullable["tenantInfo"]>; - onDeleteTenant: () => Promise; - isDeletingTenant: boolean; -}) => { - const [deleteTenantModalOpen, setDeleteTenantModalOpen] = useState(false); - const [selectedTab, setSelectedTab] = useState("login-methods"); - const { goToTenantsList, goToUsersList } = useNavigationHelpers(); - - const handleTabChange = (tab: TenantDetailTab) => { - setSelectedTab(tab); - }; - - const handleDeleteTenant = async () => { - try { - await onDeleteTenant(); - goToTenantsList(); - } catch (err) { - // Error handling - } - }; - - const canDeleteTenant = tenantId !== PUBLIC_TENANT_ID; - - return ( - - - - - {tenantInfo.tenantId} - - {canDeleteTenant && ( +export const TenantDetailContent = withOverride( + "TenantDetailContent", + function TenantDetailContent({ + tenantId, + tenantInfo, + onDeleteTenant, + isDeletingTenant, + }: { + tenantId: string; + tenantInfo: NonNullable["tenantInfo"]>; + onDeleteTenant: () => Promise; + isDeletingTenant: boolean; + }) { + const [deleteTenantModalOpen, setDeleteTenantModalOpen] = useState(false); + const [selectedTab, setSelectedTab] = useState("login-methods"); + const { goToTenantsList, goToUsersList } = useNavigationHelpers(); + + const handleTabChange = (tab: TenantDetailTab) => { + setSelectedTab(tab); + }; + + const handleDeleteTenant = async () => { + try { + await onDeleteTenant(); + goToTenantsList(); + } catch (err) { + // Error handling + } + }; + + const canDeleteTenant = tenantId !== PUBLIC_TENANT_ID; + + return ( + + + + + {tenantInfo.tenantId} + + {canDeleteTenant && ( + + )} + + + + + Total Number of Users: + {tenantInfo.userCount} + - )} - - - - - Total Number of Users: - {tenantInfo.userCount} - - - - - handleTabChange(tab as TenantDetailTab)} - selectedTab={selectedTab}> - {(() => { - switch (selectedTab) { - case "login-methods": - return ; - case "secondary-factors": - return ; - case "providers": - return ( - - ); - case "core-configuration": - return ( - - ); - default: - return assertNever(selectedTab); - } - })()} - - - {canDeleteTenant && ( - setDeleteTenantModalOpen(false)} - tenantId={tenantId} - onDeleteTenant={handleDeleteTenant} - isDeleting={isDeletingTenant} - /> - )} - - ); -}; - -export default function TenantDetails({ tenantId }: { tenantId: string }) { + + + handleTabChange(tab as TenantDetailTab)} + selectedTab={selectedTab}> + {(() => { + switch (selectedTab) { + case "login-methods": + return ; + case "secondary-factors": + return ; + case "providers": + return ( + + ); + case "core-configuration": + return ( + + ); + default: + return assertNever(selectedTab); + } + })()} + + + {canDeleteTenant && ( + setDeleteTenantModalOpen(false)} + tenantId={tenantId} + onDeleteTenant={handleDeleteTenant} + isDeleting={isDeletingTenant} + /> + )} + + ); + } +); + +const TenantDetails = withOverride("TenantDetails", function TenantDetails({ tenantId }: { tenantId: string }) { const { goToTenantsList } = useNavigationHelpers(); const { tenantInfo, isLoading, error, deleteTenant, isDeletingTenant } = useTenantDetails(tenantId); const handleBackToItemList = () => { @@ -246,4 +250,6 @@ export default function TenantDetails({ tenantId }: { tenantId: string }) { ); -} +}); + +export default TenantDetails; diff --git a/src/features/tenants/tenant-details/core-configuration/CoreConfigTableRow.tsx b/src/features/tenants/tenant-details/core-configuration/CoreConfigTableRow.tsx index 27c7d33b..141d17f2 100644 --- a/src/features/tenants/tenant-details/core-configuration/CoreConfigTableRow.tsx +++ b/src/features/tenants/tenant-details/core-configuration/CoreConfigTableRow.tsx @@ -18,6 +18,7 @@ import { InfoCircledIcon, Pencil1Icon, QuestionMarkIcon } from "@radix-ui/react- import type { CoreConfigFieldInfo } from "@api/tenants/types"; import { PUBLIC_TENANT_ID } from "@shared/constants"; +import { withOverride } from "@plugins"; import styles from "./CoreConfigTableRow.module.scss"; @@ -28,75 +29,75 @@ interface CoreConfigTableRowProps { onUneditableClick: () => void; } -export default function CoreConfigTableRow({ - tenantId, - config, - onEditClick, - onUneditableClick, -}: CoreConfigTableRowProps) { - const isPublicTenant = tenantId === PUBLIC_TENANT_ID; +const CoreConfigTableRow = withOverride( + "CoreConfigTableRow", + function CoreConfigTableRow({ tenantId, config, onEditClick, onUneditableClick }: CoreConfigTableRowProps) { + const isPublicTenant = tenantId === PUBLIC_TENANT_ID; - // Determine if the property is editable - const isUneditable = - isPublicTenant || // config of public tenant are not editable - (config.isPluginProperty && !config.isPluginPropertyEditable) || // plugin property that is marked as not editable - (!isPublicTenant && !config.isDifferentAcrossTenants); // in a non-public tenant, config that's not different across tenants are not editable + // Determine if the property is editable + const isUneditable = + isPublicTenant || // config of public tenant are not editable + (config.isPluginProperty && !config.isPluginPropertyEditable) || // plugin property that is marked as not editable + (!isPublicTenant && !config.isDifferentAcrossTenants); // in a non-public tenant, config that's not different across tenants are not editable - // Display value - show the actual value, matching old implementation - const displayValue = `${config.value}`; + // Display value - show the actual value, matching old implementation + const displayValue = `${config.value}`; - return ( - <> - + return ( + <> - {config.description && ( - - - - )} - - {config.key} - - - - + width="100%" + className={styles["core-config-table-row"]} + p="3"> + + {config.description && ( + + + + )} - {displayValue} + {config.key} - - {isUneditable ? ( - - - - ) : ( - onEditClick(config)}> - - - )} + + + + + {displayValue} + + + {isUneditable ? ( + + + + ) : ( + onEditClick(config)}> + + + )} + - - - ); -} + + ); + } +); + +export default CoreConfigTableRow; diff --git a/src/features/tenants/tenant-details/core-configuration/CoreConfiguration.tsx b/src/features/tenants/tenant-details/core-configuration/CoreConfiguration.tsx index 1740cec7..dcc7e24f 100644 --- a/src/features/tenants/tenant-details/core-configuration/CoreConfiguration.tsx +++ b/src/features/tenants/tenant-details/core-configuration/CoreConfiguration.tsx @@ -22,6 +22,7 @@ import ItemLabel from "@shared/components/itemLabel"; import TabSelector from "@shared/components/tabSelector"; import Loader from "@shared/components/loader"; import DashboardError from "@shared/components/error"; +import { withOverride } from "@plugins"; import CoreConfigurationTable from "./CoreConfigurationTable"; import PluginPropertiesSection from "./PluginPropertiesSection"; @@ -36,68 +37,71 @@ interface CoreConfigurationProps { * Separates regular properties from plugin (database) properties and displays * them in different sections. */ -function CoreConfiguration({ tenantId, coreConfig }: CoreConfigurationProps) { - const [state] = useState<"LOADING" | "SUCCESS" | "ERROR">("SUCCESS"); +const CoreConfiguration = withOverride( + "CoreConfiguration", + function CoreConfiguration({ tenantId, coreConfig }: CoreConfigurationProps) { + const [state] = useState<"LOADING" | "SUCCESS" | "ERROR">("SUCCESS"); - // Filter and detect properties - memoized to avoid recalculation on every render - const { regularProperties, pluginProperties, hasPluginProperties, databaseType } = useMemo(() => { - const regular = coreConfig.filter((config) => !config.isPluginProperty); - const plugin = coreConfig.filter((config) => config.isPluginProperty); - const hasPlugin = plugin.length > 0; + // Filter and detect properties - memoized to avoid recalculation on every render + const { regularProperties, pluginProperties, hasPluginProperties, databaseType } = useMemo(() => { + const regular = coreConfig.filter((config) => !config.isPluginProperty); + const plugin = coreConfig.filter((config) => config.isPluginProperty); + const hasPlugin = plugin.length > 0; - // Detect database type from plugin properties - let dbType: "postgres" | "mysql" | null = null; - if (hasPlugin) { - if (plugin.some((property) => property.key.startsWith("postgresql_"))) { - dbType = "postgres"; - } else if (plugin.some((property) => property.key.startsWith("mysql_"))) { - dbType = "mysql"; + // Detect database type from plugin properties + let dbType: "postgres" | "mysql" | null = null; + if (hasPlugin) { + if (plugin.some((property) => property.key.startsWith("postgresql_"))) { + dbType = "postgres"; + } else if (plugin.some((property) => property.key.startsWith("mysql_"))) { + dbType = "mysql"; + } } - } - - return { - regularProperties: regular, - pluginProperties: plugin, - hasPluginProperties: hasPlugin, - databaseType: dbType, - }; - }, [coreConfig]); - switch (state) { - case "LOADING": - return ; - case "SUCCESS": - return ( - - - - Customize the SuperTokens core settings that you want to use for your tenant. - - + return { + regularProperties: regular, + pluginProperties: plugin, + hasPluginProperties: hasPlugin, + databaseType: dbType, + }; + }, [coreConfig]); - {/* Regular Properties Table */} - + switch (state) { + case "LOADING": + return ; + case "SUCCESS": + return ( + + + + Customize the SuperTokens core settings that you want to use for your tenant. + + - {/* Plugin Properties Section */} - {hasPluginProperties && ( - - )} - - ); - case "ERROR": - return ; - default: - return assertNever(state); + + {/* Plugin Properties Section */} + {hasPluginProperties && ( + + )} + + ); + case "ERROR": + return ; + default: + return assertNever(state); + } } -} +); export default CoreConfiguration; diff --git a/src/features/tenants/tenant-details/core-configuration/CoreConfigurationTable.tsx b/src/features/tenants/tenant-details/core-configuration/CoreConfigurationTable.tsx index 1791d57f..5faa83f5 100644 --- a/src/features/tenants/tenant-details/core-configuration/CoreConfigurationTable.tsx +++ b/src/features/tenants/tenant-details/core-configuration/CoreConfigurationTable.tsx @@ -21,6 +21,7 @@ import { PUBLIC_TENANT_ID } from "@shared/constants"; import Paper from "@shared/components/paper"; import UneditableConfigurationModal from "@features/tenants/modals/UneditableConfigurationModal"; import EditConfigurationPropertyModal from "@features/tenants/modals/EditConfigurationPropertyModal"; +import { withOverride } from "@plugins"; import CoreConfigTableRow from "./CoreConfigTableRow"; import { getUneditableReason } from "./CoreConfigurationUneditableReason"; @@ -31,78 +32,83 @@ interface CoreConfigurationTableProps { coreConfig: CoreConfigFieldInfo[]; } -export default function CoreConfigurationTable({ tenantId, coreConfig }: CoreConfigurationTableProps) { - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [isUneditableModalOpen, setIsUneditableModalOpen] = useState(false); - const [selectedConfig, setSelectedConfig] = useState(null); +const CoreConfigurationTable = withOverride( + "CoreConfigurationTable", + function CoreConfigurationTable({ tenantId, coreConfig }: CoreConfigurationTableProps) { + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isUneditableModalOpen, setIsUneditableModalOpen] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); - const isPublicTenant = tenantId === PUBLIC_TENANT_ID; + const isPublicTenant = tenantId === PUBLIC_TENANT_ID; - const handleEditClick = (config: CoreConfigFieldInfo) => { - setSelectedConfig(config); - setIsEditModalOpen(true); - }; + const handleEditClick = (config: CoreConfigFieldInfo) => { + setSelectedConfig(config); + setIsEditModalOpen(true); + }; - const handleUneditableClick = () => { - setIsUneditableModalOpen(true); - }; + const handleUneditableClick = () => { + setIsUneditableModalOpen(true); + }; - return ( - <> - - - - Property Name - - - Value - - - - - {coreConfig.map((config) => ( - - ))} - - + return ( + <> + + + + Property Name + + + Value + + + + + {coreConfig.map((config) => ( + + ))} + + - {isEditModalOpen && selectedConfig && ( - setIsEditModalOpen(false)} - config={selectedConfig} - tenantId={tenantId} - /> - )} - {isUneditableModalOpen && ( - setIsUneditableModalOpen(false)} - reason={getUneditableReason(isPublicTenant)} - /> - )} - - ); -} + {isEditModalOpen && selectedConfig && ( + setIsEditModalOpen(false)} + config={selectedConfig} + tenantId={tenantId} + /> + )} + {isUneditableModalOpen && ( + setIsUneditableModalOpen(false)} + reason={getUneditableReason(isPublicTenant)} + /> + )} + + ); + } +); + +export default CoreConfigurationTable; diff --git a/src/features/tenants/tenant-details/core-configuration/PluginPropertiesSection.tsx b/src/features/tenants/tenant-details/core-configuration/PluginPropertiesSection.tsx index f5944e56..ed7060f3 100644 --- a/src/features/tenants/tenant-details/core-configuration/PluginPropertiesSection.tsx +++ b/src/features/tenants/tenant-details/core-configuration/PluginPropertiesSection.tsx @@ -17,6 +17,7 @@ import { useState } from "react"; import { Flex, Text } from "@radix-ui/themes"; import type { CoreConfigFieldInfo } from "@api/tenants/types"; +import { withOverride } from "@plugins"; import { EditPluginPropertyModal } from "@features/tenants/modals"; @@ -30,55 +31,56 @@ interface PluginPropertiesSectionProps { databaseType: "postgres" | "mysql" | null; } -export default function PluginPropertiesSection({ - tenantId, - pluginProperties, - databaseType, -}: PluginPropertiesSectionProps) { - const [showPluginDialog, setShowPluginDialog] = useState(false); +const PluginPropertiesSection = withOverride( + "PluginPropertiesSection", + function PluginPropertiesSection({ tenantId, pluginProperties, databaseType }: PluginPropertiesSectionProps) { + const [showPluginDialog, setShowPluginDialog] = useState(false); - return ( - + return ( - - Database Properties - - - Some of these properties need to be modified together, hence they cannot be directly modified from - the UI, instead you can make an API request to core to modify these properties.{" "} - setShowPluginDialog(true)} - className={styles["plugin-properties-section__link"]}> - Click here - {" "} - to see an example. - - - - + gap="3"> + + + Database Properties + + + Some of these properties need to be modified together, hence they cannot be directly modified + from the UI, instead you can make an API request to core to modify these properties.{" "} + setShowPluginDialog(true)} + className={styles["plugin-properties-section__link"]}> + Click here + {" "} + to see an example. + + - {showPluginDialog && databaseType !== null && ( - setShowPluginDialog(false)} + - )} - - ); -} + + {showPluginDialog && databaseType !== null && ( + setShowPluginDialog(false)} + tenantId={tenantId} + databaseType={databaseType} + /> + )} + + ); + } +); + +export default PluginPropertiesSection; diff --git a/src/features/tenants/tenant-details/provider-configuration/AdditionalConfigForms.tsx b/src/features/tenants/tenant-details/provider-configuration/AdditionalConfigForms.tsx index ea9fb9a3..0f8a300e 100644 --- a/src/features/tenants/tenant-details/provider-configuration/AdditionalConfigForms.tsx +++ b/src/features/tenants/tenant-details/provider-configuration/AdditionalConfigForms.tsx @@ -20,6 +20,7 @@ import Button from "@shared/components/button"; import ItemLabel from "@shared/components/itemLabel"; import { getImageUrl, isValidHttpUrl } from "@shared/utils/index"; import { IN_BUILT_THIRD_PARTY_PROVIDERS, SAML_PROVIDER_ID } from "@shared/constants"; +import { withOverride } from "@plugins"; import styles from "./AdditionalConfigForms.module.scss"; @@ -30,97 +31,102 @@ interface AdditionalConfigFormsProps { currentAdditionalConfig?: Record; } -export const AdditionalConfigForms = ({ - providerId, - onContinue, - onCancel, - currentAdditionalConfig, -}: AdditionalConfigFormsProps) => { - const renderForm = () => { - switch (providerId) { - case "google-workspaces": - return ( - - ); - case "active-directory": - return ( - - ); - case "okta": - return ( - - ); - case "boxy-saml": - return ( - - ); - default: - return null; - } - }; - - const inBuiltProviderInfo = IN_BUILT_THIRD_PARTY_PROVIDERS.find((provider) => providerId.startsWith(provider.id)); - const isSAML = providerId.startsWith(SAML_PROVIDER_ID); - - const providerLabel = isSAML ? "SAML Provider" : inBuiltProviderInfo?.label ?? providerId; - const providerIcon = isSAML ? "saml.svg" : inBuiltProviderInfo?.icon; - - return ( - +export const AdditionalConfigForms = withOverride( + "AdditionalConfigForms", + function AdditionalConfigForms({ + providerId, + onContinue, + onCancel, + currentAdditionalConfig, + }: AdditionalConfigFormsProps) { + const renderForm = () => { + switch (providerId) { + case "google-workspaces": + return ( + + ); + case "active-directory": + return ( + + ); + case "okta": + return ( + + ); + case "boxy-saml": + return ( + + ); + default: + return null; + } + }; + + const inBuiltProviderInfo = IN_BUILT_THIRD_PARTY_PROVIDERS.find((provider) => + providerId.startsWith(provider.id) + ); + const isSAML = providerId.startsWith(SAML_PROVIDER_ID); + + const providerLabel = isSAML ? "SAML Provider" : inBuiltProviderInfo?.label ?? providerId; + const providerIcon = isSAML ? "saml.svg" : inBuiltProviderInfo?.icon; + + return ( + width="100%" + direction="column" + className={styles["additional-config"]}> - - Configure new provider - - {providerIcon && ( - + + - {providerLabel} - + Configure new provider + + {providerIcon && ( + - {providerLabel} - - - )} + variant="soft" + color="gray" + className={styles["additional-config__header__badge"]}> + {providerLabel} + + {providerLabel} + + + )} + + {renderForm()} - {renderForm()} - - ); -}; + ); + } +); interface FormProps { onContinue: (additionalConfig: Record) => void; diff --git a/src/features/tenants/tenant-details/provider-configuration/ProviderConfiguration.tsx b/src/features/tenants/tenant-details/provider-configuration/ProviderConfiguration.tsx index e4928b1d..5f5e07cb 100644 --- a/src/features/tenants/tenant-details/provider-configuration/ProviderConfiguration.tsx +++ b/src/features/tenants/tenant-details/provider-configuration/ProviderConfiguration.tsx @@ -28,6 +28,7 @@ import type { ProviderConfigResponse } from "@api/tenants/types"; import { useTenantDetails } from "@features/tenants/hooks/useTenantDetails"; import DeleteProviderConfigModal from "@features/tenants/modals/DeleteProviderConfigModal"; import { IN_BUILT_PROVIDERS_CUSTOM_FIELDS_FOR_CLIENT, SAML_NAME_OPTIONS } from "@features/tenants/constants/providers"; +import { withOverride } from "@plugins"; import { getInitialProviderState, @@ -60,781 +61,801 @@ interface ProviderConfigurationProps { additionalConfig?: Record; } -export const ProviderConfiguration = ({ - tenantId, - providerId, - isAddingNewProvider, - onDelete, - onSave, - onCancel, - providerConfigResponse: initialProviderConfigResponse, - additionalConfig, -}: ProviderConfigurationProps) => { - const [isLoading, setIsLoading] = useState(!initialProviderConfigResponse); - const [isEditing, setIsEditing] = useState(isAddingNewProvider); - const [isSaving, setIsSaving] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [providerConfigResponse, setProviderConfigResponse] = useState( - initialProviderConfigResponse - ); - const [providerConfigState, setProviderConfigState] = useState(null); - const [errors, setErrors] = useState>({}); - const [emailSelectValue, setEmailSelectValue] = useState("always"); - const [isSuffixFieldVisible, setIsSuffixFieldVisible] = useState(false); - - const getThirdPartyProviderInfo = useGetThirdPartyProviderInfoService(); - const createOrUpdateThirdPartyProvider = useCreateOrUpdateThirdPartyProviderService(); - const { tenantInfo, refetch } = useTenantDetails(tenantId); - const { showSuccessToast, showErrorToast } = useToast(); - - const isSAMLProvider = providerId?.startsWith(SAML_PROVIDER_ID); - const inBuiltProviderInfo = IN_BUILT_THIRD_PARTY_PROVIDERS.find((provider) => providerId?.startsWith(provider.id)); - const baseProviderId = isSAMLProvider ? SAML_PROVIDER_ID : inBuiltProviderInfo?.id ?? ""; - const shouldUseSuffixField = isAddingNewProvider && (Boolean(inBuiltProviderInfo) || isSAMLProvider); - - const customFieldProviderKey = Object.keys(IN_BUILT_PROVIDERS_CUSTOM_FIELDS_FOR_CLIENT).find((id) => - providerId?.startsWith(id) - ); - const customFields = customFieldProviderKey - ? IN_BUILT_PROVIDERS_CUSTOM_FIELDS_FOR_CLIENT[customFieldProviderKey] - : undefined; - - useEffect(() => { - // If we already have provider config response (passed from parent), use it - if (initialProviderConfigResponse) { - const initialState = getInitialProviderState(initialProviderConfigResponse, providerId); - setProviderConfigState(initialState); - - // Set email select value - if (initialProviderConfigResponse.requireEmail === false) { - setEmailSelectValue("sometimes"); - } else { - setEmailSelectValue("always"); +export const ProviderConfiguration = withOverride( + "ProviderConfiguration", + function ProviderConfiguration({ + tenantId, + providerId, + isAddingNewProvider, + onDelete, + onSave, + onCancel, + providerConfigResponse: initialProviderConfigResponse, + additionalConfig, + }: ProviderConfigurationProps) { + const [isLoading, setIsLoading] = useState(!initialProviderConfigResponse); + const [isEditing, setIsEditing] = useState(isAddingNewProvider); + const [isSaving, setIsSaving] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [providerConfigResponse, setProviderConfigResponse] = useState( + initialProviderConfigResponse + ); + const [providerConfigState, setProviderConfigState] = useState(null); + const [errors, setErrors] = useState>({}); + const [emailSelectValue, setEmailSelectValue] = useState("always"); + const [isSuffixFieldVisible, setIsSuffixFieldVisible] = useState(false); + + const getThirdPartyProviderInfo = useGetThirdPartyProviderInfoService(); + const createOrUpdateThirdPartyProvider = useCreateOrUpdateThirdPartyProviderService(); + const { tenantInfo, refetch } = useTenantDetails(tenantId); + const { showSuccessToast, showErrorToast } = useToast(); + + const isSAMLProvider = providerId?.startsWith(SAML_PROVIDER_ID); + const inBuiltProviderInfo = IN_BUILT_THIRD_PARTY_PROVIDERS.find((provider) => + providerId?.startsWith(provider.id) + ); + const baseProviderId = isSAMLProvider ? SAML_PROVIDER_ID : inBuiltProviderInfo?.id ?? ""; + const shouldUseSuffixField = isAddingNewProvider && (Boolean(inBuiltProviderInfo) || isSAMLProvider); + + const customFieldProviderKey = Object.keys(IN_BUILT_PROVIDERS_CUSTOM_FIELDS_FOR_CLIENT).find((id) => + providerId?.startsWith(id) + ); + const customFields = customFieldProviderKey + ? IN_BUILT_PROVIDERS_CUSTOM_FIELDS_FOR_CLIENT[customFieldProviderKey] + : undefined; + + useEffect(() => { + // If we already have provider config response (passed from parent), use it + if (initialProviderConfigResponse) { + const initialState = getInitialProviderState(initialProviderConfigResponse, providerId); + setProviderConfigState(initialState); + + // Set email select value + if (initialProviderConfigResponse.requireEmail === false) { + setEmailSelectValue("sometimes"); + } else { + setEmailSelectValue("always"); + } + setIsLoading(false); + return; } - setIsLoading(false); - return; - } - // Otherwise fetch provider info - const fetchProviderInfo = async () => { - try { - setIsLoading(true); - const response = await getThirdPartyProviderInfo(tenantId, providerId, additionalConfig); - if (response.status === "OK") { - setProviderConfigResponse(response.providerConfig); - const initialState = getInitialProviderState(response.providerConfig, providerId); - setProviderConfigState(initialState); - - // Set email select value - if (response.providerConfig.requireEmail === false) { - setEmailSelectValue("sometimes"); - } else { - setEmailSelectValue("always"); + // Otherwise fetch provider info + const fetchProviderInfo = async () => { + try { + setIsLoading(true); + const response = await getThirdPartyProviderInfo(tenantId, providerId, additionalConfig); + if (response.status === "OK") { + setProviderConfigResponse(response.providerConfig); + const initialState = getInitialProviderState(response.providerConfig, providerId); + setProviderConfigState(initialState); + + // Set email select value + if (response.providerConfig.requireEmail === false) { + setEmailSelectValue("sometimes"); + } else { + setEmailSelectValue("always"); + } } + } catch (error) { + showErrorToast("Error", "Failed to fetch provider configuration"); + } finally { + setIsLoading(false); } - } catch (error) { - showErrorToast("Error", "Failed to fetch provider configuration"); - } finally { + }; + + if (!isAddingNewProvider) { + void fetchProviderInfo(); + } else { + const initialState = getInitialProviderState(undefined, providerId); + setProviderConfigState(initialState); setIsLoading(false); } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantId, providerId, isAddingNewProvider, initialProviderConfigResponse]); - if (!isAddingNewProvider) { - void fetchProviderInfo(); - } else { - const initialState = getInitialProviderState(undefined, providerId); - setProviderConfigState(initialState); - setIsLoading(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantId, providerId, isAddingNewProvider, initialProviderConfigResponse]); + const handleThirdPartyIdSuffixChange = (e: React.ChangeEvent) => { + if (!providerConfigState) return; - const handleThirdPartyIdSuffixChange = (e: React.ChangeEvent) => { - if (!providerConfigState) return; + const suffixValue = e.target.value.trim(); + if (suffixValue === "") { + setProviderConfigState({ ...providerConfigState, thirdPartyId: baseProviderId }); + } else { + setProviderConfigState({ + ...providerConfigState, + thirdPartyId: `${baseProviderId}-${suffixValue}`, + }); + } + }; + + const showSuffixField = () => { + if (!providerConfigState) return; - const suffixValue = e.target.value.trim(); - if (suffixValue === "") { - setProviderConfigState({ ...providerConfigState, thirdPartyId: baseProviderId }); - } else { + setIsSuffixFieldVisible(true); setProviderConfigState({ ...providerConfigState, - thirdPartyId: `${baseProviderId}-${suffixValue}`, + thirdPartyId: baseProviderId, }); - } - }; - - const showSuffixField = () => { - if (!providerConfigState) return; - - setIsSuffixFieldVisible(true); - setProviderConfigState({ - ...providerConfigState, - thirdPartyId: baseProviderId, - }); - // Clear any thirdPartyId errors when showing suffix field - setErrors((prev) => { - const { thirdPartyId, ...rest } = prev; - return rest; - }); - }; - - const handleUserInfoFieldChange = ({ - name, - key, - value, - }: { - name: "fromIdTokenPayload" | "fromUserInfoAPI"; - key: string; - value: string; - }) => { - if (!providerConfigState) return; - setProviderConfigState({ - ...providerConfigState, - userInfoMap: { - ...providerConfigState.userInfoMap, - [name]: { - ...providerConfigState.userInfoMap[name], - [key]: value, + // Clear any thirdPartyId errors when showing suffix field + setErrors((prev) => { + const { thirdPartyId, ...rest } = prev; + return rest; + }); + }; + + const handleUserInfoFieldChange = ({ + name, + key, + value, + }: { + name: "fromIdTokenPayload" | "fromUserInfoAPI"; + key: string; + value: string; + }) => { + if (!providerConfigState) return; + setProviderConfigState({ + ...providerConfigState, + userInfoMap: { + ...providerConfigState.userInfoMap, + [name]: { + ...providerConfigState.userInfoMap[name], + [key]: value, + }, }, - }, - }); - }; - - const handleEmailSelectChange = (value: EmailSelectState) => { - if (!providerConfigState) return; - setEmailSelectValue(value); - if (value === "never") { - setProviderConfigState({ ...providerConfigState, requireEmail: false }); - } else { - setProviderConfigState({ ...providerConfigState, requireEmail: true }); - } - }; + }); + }; - const handleAddNewClient = () => { - if (!providerConfigState) return; + const handleEmailSelectChange = (value: EmailSelectState) => { + if (!providerConfigState) return; + setEmailSelectValue(value); + if (value === "never") { + setProviderConfigState({ ...providerConfigState, requireEmail: false }); + } else { + setProviderConfigState({ ...providerConfigState, requireEmail: true }); + } + }; - let additionalConfig: [string, string | null][] = providerConfigState.clients[0]?.additionalConfig - ? [...providerConfigState.clients[0].additionalConfig] - : [["", ""]]; + const handleAddNewClient = () => { + if (!providerConfigState) return; - // Apply custom fields if needed - if (customFields) { - additionalConfig = customFields.map((field) => [field.id, ""] as [string, string | null]); - } + let additionalConfig: [string, string | null][] = providerConfigState.clients[0]?.additionalConfig + ? [...providerConfigState.clients[0].additionalConfig] + : [["", ""]]; - setProviderConfigState({ - ...providerConfigState, - clients: [ - ...(providerConfigState?.clients ?? []), - { - clientId: "", - clientSecret: "", - clientType: "", - scope: providerConfigState.clients[0]?.scope ? [...providerConfigState.clients[0].scope] : [""], - additionalConfig, - forcePKCE: providerConfigState.clients[0]?.forcePKCE || false, - key: crypto.randomUUID(), - }, - ], - }); - }; + // Apply custom fields if needed + if (customFields) { + additionalConfig = customFields.map((field) => [field.id, ""] as [string, string | null]); + } + + setProviderConfigState({ + ...providerConfigState, + clients: [ + ...(providerConfigState?.clients ?? []), + { + clientId: "", + clientSecret: "", + clientType: "", + scope: providerConfigState.clients[0]?.scope ? [...providerConfigState.clients[0].scope] : [""], + additionalConfig, + forcePKCE: providerConfigState.clients[0]?.forcePKCE || false, + key: crypto.randomUUID(), + }, + ], + }); + }; - const handleSave = async () => { - if (!providerConfigState || !tenantInfo) return; + const handleSave = async () => { + if (!providerConfigState || !tenantInfo) return; - setErrors({}); + setErrors({}); - const existingProviderIds = tenantInfo.thirdParty.providers.map((p) => p.thirdPartyId); - const validationErrors = validateProviderConfig( - providerConfigState, - existingProviderIds, - isAddingNewProvider, - customFields - ); + const existingProviderIds = tenantInfo.thirdParty.providers.map((p) => p.thirdPartyId); + const validationErrors = validateProviderConfig( + providerConfigState, + existingProviderIds, + isAddingNewProvider, + customFields + ); - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - showErrorToast("Validation Error", "Please ensure all fields are correctly filled out before saving."); - return; - } + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + showErrorToast("Validation Error", "Please ensure all fields are correctly filled out before saving."); + return; + } - try { - setIsSaving(true); - const normalizedConfig = normalizeProviderConfig(providerConfigState); - const response = await createOrUpdateThirdPartyProvider(tenantId, normalizedConfig); - - if (response.status === "OK") { - showSuccessToast("Success", "Provider configuration saved successfully"); - setIsEditing(false); - await refetch(); - if (onSave) { - onSave(); + try { + setIsSaving(true); + const normalizedConfig = normalizeProviderConfig(providerConfigState); + const response = await createOrUpdateThirdPartyProvider(tenantId, normalizedConfig); + + if (response.status === "OK") { + showSuccessToast("Success", "Provider configuration saved successfully"); + setIsEditing(false); + await refetch(); + if (onSave) { + onSave(); + } + } else if (response.status === "BOXY_ERROR") { + showErrorToast("Error", response.message); } - } else if (response.status === "BOXY_ERROR") { - showErrorToast("Error", response.message); + } catch (error) { + showErrorToast("Error", "Failed to save provider configuration"); + } finally { + setIsSaving(false); } - } catch (error) { - showErrorToast("Error", "Failed to save provider configuration"); - } finally { - setIsSaving(false); - } - }; + }; - if (isLoading || !providerConfigState) { - return ; - } + if (isLoading || !providerConfigState) { + return ; + } - const formHasError = Object.values(errors).some((error) => error !== ""); + const formHasError = Object.values(errors).some((error) => error !== ""); - return ( - - {/* Header */} + return ( + width="100%" + direction="column" + className={styles["provider-configuration"]}> + {/* Header */} - - {isAddingNewProvider ? "Configure new provider" : "Provider Configuration"} - - {inBuiltProviderInfo && ( - + + - {inBuiltProviderInfo.label} - + {isAddingNewProvider ? "Configure new provider" : "Provider Configuration"} + + {inBuiltProviderInfo && ( + - {inBuiltProviderInfo.label} - - + variant="soft" + color="gray" + className={styles["provider-configuration__header__badge"]}> + {inBuiltProviderInfo.label} + + {inBuiltProviderInfo.label} + + + )} + + + {/* Action buttons in header */} + {isAddingNewProvider ? ( + + + + + ) : isEditing ? ( + + + + + ) : ( + + + + + )} - {/* Action buttons in header */} - {isAddingNewProvider ? ( + {/* Form Content */} + + {/* Provider Information */} - - - - ) : isEditing ? ( - - - + error={errors.name} + /> + )} - ) : ( - - + {/* Clients Section */} + + Clients + + + {providerConfigState.clients.map((client, index) => ( + { + const newClients = [...providerConfigState.clients]; + newClients[index] = updatedClient; + setProviderConfigState({ ...providerConfigState, clients: newClients }); + }} + handleDeleteClient={() => { + setProviderConfigState({ + ...providerConfigState, + clients: providerConfigState.clients.filter((_, i) => i !== index), + }); + }} + disabled={!isEditing} + /> + ))} - )} - - - {/* Form Content */} - - {/* Provider Information */} - - {/* Third Party ID - with suffix support for built-in and SAML providers */} - {shouldUseSuffixField ? ( - baseProviderId.length + 1 - ? providerConfigState.thirdPartyId.slice(baseProviderId.length + 1) - : "" - } - onSuffixChange={handleThirdPartyIdSuffixChange} - onShowSuffixField={showSuffixField} - isSuffixFieldVisible={isSuffixFieldVisible} - error={errors.thirdPartyId} - disabled={!isEditing} - /> - ) : ( + + + {/* OIDC Discovery Endpoint */} - setProviderConfigState({ ...providerConfigState, thirdPartyId: e.target.value }) + setProviderConfigState({ + ...providerConfigState, + oidcDiscoveryEndpoint: e.target.value, + }) } - error={errors.thirdPartyId} + error={errors.oidcDiscoveryEndpoint} /> - )} - {/* Name */} - {isSAMLProvider ? ( - - - - - - {errors.name && ( - - {errors.name} - - )} - - ) : ( + + + {/* Authorization Endpoint */} setProviderConfigState({ ...providerConfigState, name: e.target.value })} - error={errors.name} + label="Authorization Endpoint" + tooltip="The authorization endpoint of the provider" + disabled={!isEditing || providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden} + value={ + providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden + ? "Cannot edit this because you have provided a custom override" + : providerConfigState.authorizationEndpoint + } + onChange={(e) => + setProviderConfigState({ + ...providerConfigState, + authorizationEndpoint: e.target.value, + }) + } + error={errors.authorizationEndpoint} /> - )} - - {/* Clients Section */} - - Clients - - - {providerConfigState.clients.map((client, index) => ( - { - const newClients = [...providerConfigState.clients]; - newClients[index] = updatedClient; - setProviderConfigState({ ...providerConfigState, clients: newClients }); - }} - handleDeleteClient={() => { + setProviderConfigState({ ...providerConfigState, - clients: providerConfigState.clients.filter((_, i) => i !== index), - }); - }} - disabled={!isEditing} + authorizationEndpointQueryParams: items, + }) + } + disabled={!isEditing || providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden} /> - ))} - - - - - {/* OIDC Discovery Endpoint */} - - setProviderConfigState({ ...providerConfigState, oidcDiscoveryEndpoint: e.target.value }) - } - error={errors.oidcDiscoveryEndpoint} - /> - - - {/* Authorization Endpoint */} - - setProviderConfigState({ ...providerConfigState, authorizationEndpoint: e.target.value }) - } - error={errors.authorizationEndpoint} - /> - - - setProviderConfigState({ ...providerConfigState, authorizationEndpointQueryParams: items }) - } - disabled={!isEditing || providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden} - /> + {providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden && ( + + Note: You cannot edit the above fields because this provider is using a + custom override for getAuthorisationRedirectUrl + + )} - {providerConfigResponse?.isGetAuthorisationRedirectUrlOverridden && ( - - Note: You cannot edit the above fields because this provider is using a - custom override for getAuthorisationRedirectUrl - - )} + - + {/* Token Endpoint */} + + setProviderConfigState({ ...providerConfigState, tokenEndpoint: e.target.value }) + } + error={errors.tokenEndpoint} + /> - {/* Token Endpoint */} - - setProviderConfigState({ ...providerConfigState, tokenEndpoint: e.target.value }) - } - error={errors.tokenEndpoint} - /> - - - setProviderConfigState({ ...providerConfigState, tokenEndpointBodyParams: items }) - } - disabled={!isEditing || providerConfigResponse?.isExchangeAuthCodeForOAuthTokensOverridden} - /> + + setProviderConfigState({ ...providerConfigState, tokenEndpointBodyParams: items }) + } + disabled={!isEditing || providerConfigResponse?.isExchangeAuthCodeForOAuthTokensOverridden} + /> - {providerConfigResponse?.isExchangeAuthCodeForOAuthTokensOverridden && ( - - Note: You cannot edit the above fields because this provider is using a - custom override for exchangeAuthCodeForOAuthTokens - - )} + {providerConfigResponse?.isExchangeAuthCodeForOAuthTokensOverridden && ( + + Note: You cannot edit the above fields because this provider is using a + custom override for exchangeAuthCodeForOAuthTokens + + )} - + - {/* User Info Endpoint */} - - setProviderConfigState({ ...providerConfigState, userInfoEndpoint: e.target.value }) - } - error={errors.userInfoEndpoint} - /> - - - setProviderConfigState({ ...providerConfigState, userInfoEndpointQueryParams: items }) - } - disabled={!isEditing || providerConfigResponse?.isGetUserInfoOverridden} - /> - - - setProviderConfigState({ ...providerConfigState, userInfoEndpointHeaders: items }) - } - disabled={!isEditing || providerConfigResponse?.isGetUserInfoOverridden} - /> + {/* User Info Endpoint */} + + setProviderConfigState({ ...providerConfigState, userInfoEndpoint: e.target.value }) + } + error={errors.userInfoEndpoint} + /> - {/* Email Frequency */} - - - + setProviderConfigState({ ...providerConfigState, userInfoEndpointQueryParams: items }) + } + disabled={!isEditing || providerConfigResponse?.isGetUserInfoOverridden} /> - - {emailSelectValue === "never" && ( - - Note: We will generate a fake email for the end users automatically using - their user id. - - )} + + setProviderConfigState({ ...providerConfigState, userInfoEndpointHeaders: items }) + } + disabled={!isEditing || providerConfigResponse?.isGetUserInfoOverridden} + /> - {emailSelectValue === "sometimes" && ( + {/* Email Frequency */} - - - setProviderConfigState({ ...providerConfigState, requireEmail: !checked }) - } + + - )} - {/* User Info Maps */} - - - + {emailSelectValue === "never" && ( + + Note: We will generate a fake email for the end users automatically + using their user id. + + )} - {providerConfigResponse?.isGetUserInfoOverridden && ( - - Note: You cannot edit the above fields because this provider is using a - custom override for getUserInfo - - )} + {emailSelectValue === "sometimes" && ( + + + + setProviderConfigState({ ...providerConfigState, requireEmail: !checked }) + } + disabled={!isEditing} + /> + + )} - + {/* User Info Maps */} + - {/* JWKS URI */} - setProviderConfigState({ ...providerConfigState, jwksURI: e.target.value })} - error={errors.jwksURI} - /> - - + - {/* Delete Modal */} - setIsDeleteModalOpen(false)} - tenantId={tenantId} - providerId={providerId} - onSuccess={() => { - setIsDeleteModalOpen(false); - if (onDelete) { - onDelete(); - } - }} - /> - {/* Footer - Save button for when editing */} - {isEditing && ( - <> - - - {formHasError && ( + {providerConfigResponse?.isGetUserInfoOverridden && ( - Please ensure all fields are correctly filled out before saving. + color="orange" + className={styles["provider-configuration__warning-text"]}> + Note: You cannot edit the above fields because this provider is using a + custom override for getUserInfo )} - + - + {/* JWKS URI */} + + setProviderConfigState({ ...providerConfigState, jwksURI: e.target.value }) + } + error={errors.jwksURI} + /> - - )} - - ); -}; + + + {/* Delete Modal */} + setIsDeleteModalOpen(false)} + tenantId={tenantId} + providerId={providerId} + onSuccess={() => { + setIsDeleteModalOpen(false); + if (onDelete) { + onDelete(); + } + }} + /> + {/* Footer - Save button for when editing */} + {isEditing && ( + <> + + + {formHasError && ( + + Please ensure all fields are correctly filled out before saving. + + )} + + + + + + )} + + ); + } +); diff --git a/src/features/tenants/tenant-details/provider-configuration/components/ClientConfigSection.tsx b/src/features/tenants/tenant-details/provider-configuration/components/ClientConfigSection.tsx index 4cdfce53..17daa4dd 100644 --- a/src/features/tenants/tenant-details/provider-configuration/components/ClientConfigSection.tsx +++ b/src/features/tenants/tenant-details/provider-configuration/components/ClientConfigSection.tsx @@ -17,6 +17,7 @@ import { Button, Flex, Switch, Text, TextArea } from "@radix-ui/themes"; import { PlusIcon, TrashIcon } from "@radix-ui/react-icons"; import IconButton from "@shared/components/iconButton"; +import { withOverride } from "@plugins"; import type { ProviderClientState } from "../providerConfigHelpers"; import { ProviderConfigInputLabel } from "./ProviderConfigInputLabel"; @@ -48,229 +49,234 @@ interface ClientConfigSectionProps { disabled?: boolean; } -export const ClientConfigSection = ({ - client, - clientIndex, - clientsCount, - providerId, - errors, - customFields, - setClient, - handleDeleteClient, - disabled, -}: ClientConfigSectionProps) => { - const isAppleProvider = providerId.startsWith("apple"); +export const ClientConfigSection = withOverride( + "ClientConfigSection", + function ClientConfigSection({ + client, + clientIndex, + clientsCount, + providerId, + errors, + customFields, + setClient, + handleDeleteClient, + disabled, + }: ClientConfigSectionProps) { + const isAppleProvider = providerId.startsWith("apple"); - const handleScopeChange = (scopeIndex: number, newScope: string) => { - const newScopes = [...client.scope]; - newScopes[scopeIndex] = newScope; - setClient({ ...client, scope: newScopes }); - }; + const handleScopeChange = (scopeIndex: number, newScope: string) => { + const newScopes = [...client.scope]; + newScopes[scopeIndex] = newScope; + setClient({ ...client, scope: newScopes }); + }; - const handleRemoveScope = (scopeIndex: number) => { - setClient({ ...client, scope: client.scope.filter((_, i) => i !== scopeIndex) }); - }; + const handleRemoveScope = (scopeIndex: number) => { + setClient({ ...client, scope: client.scope.filter((_, i) => i !== scopeIndex) }); + }; - const handleAddScope = () => { - setClient({ ...client, scope: [...client.scope, ""] }); - }; + const handleAddScope = () => { + setClient({ ...client, scope: [...client.scope, ""] }); + }; - const handleCustomFieldChange = (fieldId: string, value: string) => { - const newConfig = [...client.additionalConfig]; - const existingIndex = newConfig.findIndex(([key]) => key === fieldId); - if (existingIndex >= 0) { - newConfig[existingIndex] = [fieldId, value]; - } else { - newConfig.push([fieldId, value]); - } - setClient({ ...client, additionalConfig: newConfig }); - }; + const handleCustomFieldChange = (fieldId: string, value: string) => { + const newConfig = [...client.additionalConfig]; + const existingIndex = newConfig.findIndex(([key]) => key === fieldId); + if (existingIndex >= 0) { + newConfig[existingIndex] = [fieldId, value]; + } else { + newConfig.push([fieldId, value]); + } + setClient({ ...client, additionalConfig: newConfig }); + }; - const getCustomFieldValue = (fieldId: string): string => { - return client.additionalConfig.find(([key]) => key === fieldId)?.[1] || ""; - }; + const getCustomFieldValue = (fieldId: string): string => { + return client.additionalConfig.find(([key]) => key === fieldId)?.[1] || ""; + }; - return ( - - {clientsCount > 1 && ( - - - - - - )} - - setClient({ ...client, clientId: e.target.value })} - error={errors[`clients.${clientIndex}.clientId`]} - /> + return ( + + {clientsCount > 1 && ( + + + + + + )} - {!isAppleProvider && ( setClient({ ...client, clientSecret: e.target.value })} - error={errors[`clients.${clientIndex}.clientSecret`]} + value={client.clientId} + onChange={(e) => setClient({ ...client, clientId: e.target.value })} + error={errors[`clients.${clientIndex}.clientId`]} /> - )} - 1} - disabled={disabled} - value={client.clientType} - onChange={(e) => setClient({ ...client, clientType: e.target.value })} - error={errors[`clients.${clientIndex}.clientType`]} - /> + {!isAppleProvider && ( + setClient({ ...client, clientSecret: e.target.value })} + error={errors[`clients.${clientIndex}.clientSecret`]} + /> + )} - + 1} + disabled={disabled} + value={client.clientType} + onChange={(e) => setClient({ ...client, clientType: e.target.value })} + error={errors[`clients.${clientIndex}.clientType`]} + /> - {/* Scopes */} - - - - {client.scope.map((scope, scopeIndex) => ( - - handleScopeChange(scopeIndex, e.target.value)} - /> - handleRemoveScope(scopeIndex)} - disabled={disabled} - /> - - ))} - - - - + - {/* Additional Config / Custom Fields */} - {customFields && customFields.length > 0 && ( - <> - + {/* Scopes */} + + - ( + + handleScopeChange(scopeIndex, e.target.value)} + /> + handleRemoveScope(scopeIndex)} + disabled={disabled} + /> + + ))} + + + + + + {/* Additional Config / Custom Fields */} + {customFields && customFields.length > 0 && ( + <> + + + + Additional Configuration + + {customFields.map((field) => { + const fieldValue = getCustomFieldValue(field.id); + const fieldError = errors[`clients.${clientIndex}.additionalConfig.${field.id}`]; + + if (field.type === "multiline") { + return ( + + +