diff --git a/backend/src/db/migrations/20250627010508_env-overrides.ts b/backend/src/db/migrations/20250627010508_env-overrides.ts new file mode 100644 index 000000000..535360a80 --- /dev/null +++ b/backend/src/db/migrations/20250627010508_env-overrides.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.binary("encryptedEnvOverrides").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides"); + if (hasColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("encryptedEnvOverrides"); + }); + } +} diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index de4975b20..b5e160096 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -34,7 +34,8 @@ export const SuperAdminSchema = z.object({ encryptedGitHubAppConnectionClientSecret: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionSlug: zodBuffer.nullable().optional(), encryptedGitHubAppConnectionId: zodBuffer.nullable().optional(), - encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional() + encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional(), + encryptedEnvOverrides: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 8db5c16c4..2523c763c 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { QueueWorkerProfile } from "@app/lib/types"; +import { BadRequestError } from "../errors"; import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -341,8 +342,11 @@ const envSchema = z export type TEnvConfig = Readonly>; let envCfg: TEnvConfig; +let originalEnvConfig: TEnvConfig; export const getConfig = () => envCfg; +export const getOriginalConfig = () => originalEnvConfig; + // cannot import singleton logger directly as it needs config to load various transport export const initEnvConfig = (logger?: CustomLogger) => { const parsedEnv = envSchema.safeParse(process.env); @@ -352,10 +356,115 @@ export const initEnvConfig = (logger?: CustomLogger) => { process.exit(-1); } - envCfg = Object.freeze(parsedEnv.data); + const config = Object.freeze(parsedEnv.data); + envCfg = config; + + if (!originalEnvConfig) { + originalEnvConfig = config; + } + return envCfg; }; +// A list of environment variables that can be overwritten +export const overwriteSchema: { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; description?: string }[]; + }; +} = { + azure: { + name: "Azure", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + google_sso: { + name: "Google SSO", + fields: [ + { + key: "CLIENT_ID_GOOGLE_LOGIN", + description: "The Client ID of your GCP OAuth2 application." + }, + { + key: "CLIENT_SECRET_GOOGLE_LOGIN", + description: "The Client Secret of your GCP OAuth2 application." + } + ] + }, + github_sso: { + name: "GitHub SSO", + fields: [ + { + key: "CLIENT_ID_GITHUB_LOGIN", + description: "The Client ID of your GitHub OAuth application." + }, + { + key: "CLIENT_SECRET_GITHUB_LOGIN", + description: "The Client Secret of your GitHub OAuth application." + } + ] + }, + gitlab_sso: { + name: "GitLab SSO", + fields: [ + { + key: "CLIENT_ID_GITLAB_LOGIN", + description: "The Client ID of your GitLab application." + }, + { + key: "CLIENT_SECRET_GITLAB_LOGIN", + description: "The Secret of your GitLab application." + }, + { + key: "CLIENT_GITLAB_LOGIN_URL", + description: + "The URL of your self-hosted instance of GitLab where the OAuth application is registered. If no URL is passed in, this will default to https://gitlab.com." + } + ] + } +}; + +export const overridableKeys = new Set( + Object.values(overwriteSchema).flatMap(({ fields }) => fields.map(({ key }) => key)) +); + +export const validateOverrides = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (!parsedResult.success) { + const errorDetails = parsedResult.error.issues + .map((issue) => `Key: "${issue.path.join(".")}", Error: ${issue.message}`) + .join("\n"); + throw new BadRequestError({ message: errorDetails }); + } +}; + +export const overrideEnvConfig = (config: Record) => { + const allowedOverrides = Object.fromEntries( + Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input)) + ); + + const tempEnv: Record = { ...process.env, ...allowedOverrides }; + const parsedResult = envSchema.safeParse(tempEnv); + + if (parsedResult.success) { + envCfg = Object.freeze(parsedResult.data); + } +}; + export const formatSmtpConfig = () => { const tlsOptions: { rejectUnauthorized: boolean; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0efe3f475..4704449fc 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -300,6 +300,7 @@ import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; import { registerV1Routes } from "./v1"; +import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; @@ -2046,6 +2047,16 @@ export const registerRoutes = async ( } } + const configSyncJob = await superAdminService.initializeEnvConfigSync(); + if (configSyncJob) { + cronJobs.push(configSyncJob); + } + + const oauthConfigSyncJob = await initializeOauthConfigSync(); + if (oauthConfigSyncJob) { + cronJobs.push(oauthConfigSyncJob); + } + server.decorate("store", { user: userDAL, kmipClient: kmipClientDAL diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index a21587db1..81e911621 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -8,7 +8,7 @@ import { SuperAdminSchema, UsersSchema } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; +import { getConfig, overridableKeys } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; @@ -42,7 +42,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { encryptedGitHubAppConnectionClientSecret: true, encryptedGitHubAppConnectionSlug: true, encryptedGitHubAppConnectionId: true, - encryptedGitHubAppConnectionPrivateKey: true + encryptedGitHubAppConnectionPrivateKey: true, + encryptedEnvOverrides: true }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), @@ -110,11 +111,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { .refine((content) => DOMPurify.sanitize(content) === content, { message: "Page frame content contains unsafe HTML." }) - .optional() + .optional(), + envOverrides: z.record(z.enum(Array.from(overridableKeys) as [string, ...string[]]), z.string()).optional() }), response: { 200: z.object({ - config: SuperAdminSchema.extend({ + config: SuperAdminSchema.omit({ + encryptedEnvOverrides: true + }).extend({ defaultAuthOrgSlug: z.string().nullable() }) }) @@ -381,6 +385,41 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/env-overrides", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.record( + z.string(), + z.object({ + name: z.string(), + fields: z + .object({ + key: z.string(), + value: z.string(), + hasEnvEntry: z.boolean(), + description: z.string().optional() + }) + .array() + }) + ) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const envOverrides = await server.services.superAdmin.getEnvOverridesOrganized(); + return envOverrides; + } + }); + server.route({ method: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index b6b3cb8aa..5e2518362 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -9,6 +9,7 @@ import { Authenticator } from "@fastify/passport"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; +import { CronJob } from "cron"; import { Strategy as GitLabStrategy } from "passport-gitlab2"; import { Strategy as GoogleStrategy } from "passport-google-oauth20"; import { Strategy as OAuth2Strategy } from "passport-oauth2"; @@ -25,27 +26,14 @@ import { AuthMethod } from "@app/services/auth/auth-type"; import { OrgAuthMethod } from "@app/services/org/org-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; -export const registerSsoRouter = async (server: FastifyZodProvider) => { +const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); + +let serverInstance: FastifyZodProvider | null = null; + +export const registerOauthMiddlewares = (server: FastifyZodProvider) => { + serverInstance = server; const appCfg = getConfig(); - const passport = new Authenticator({ key: "sso", userProperty: "passportUser" }); - const redisStore = new RedisStore({ - client: server.redis, - prefix: "oauth-session:", - ttl: 600 // 10 minutes - }); - - await server.register(fastifySession, { - secret: appCfg.COOKIE_SECRET_SIGN_KEY, - store: redisStore, - cookie: { - secure: appCfg.HTTPS_ENABLED, - sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server - } - }); - await server.register(passport.initialize()); - await server.register(passport.secureSession()); - // passport oauth strategy for Google const isGoogleOauthActive = Boolean(appCfg.CLIENT_ID_GOOGLE_LOGIN && appCfg.CLIENT_SECRET_GOOGLE_LOGIN); if (isGoogleOauthActive) { @@ -176,6 +164,49 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { ) ); } +}; + +export const refreshOauthConfig = () => { + if (!serverInstance) { + logger.warn("Cannot refresh OAuth config: server instance not available"); + return; + } + + logger.info("Refreshing OAuth configuration..."); + registerOauthMiddlewares(serverInstance); +}; + +export const initializeOauthConfigSync = async () => { + logger.info("Setting up background sync process for oauth configuration"); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", refreshOauthConfig); + job.start(); + + return job; +}; + +export const registerSsoRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + + const redisStore = new RedisStore({ + client: server.redis, + prefix: "oauth-session:", + ttl: 600 // 10 minutes + }); + + await server.register(fastifySession, { + secret: appCfg.COOKIE_SECRET_SIGN_KEY, + store: redisStore, + cookie: { + secure: appCfg.HTTPS_ENABLED, + sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server + } + }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + registerOauthMiddlewares(server); server.route({ url: "/redirect/google", diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 8a0d4dd14..2ca7a0c33 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -5,7 +5,13 @@ import jwt from "jsonwebtoken"; import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { + getConfig, + getOriginalConfig, + overrideEnvConfig, + overwriteSchema, + validateOverrides +} from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -33,6 +39,7 @@ import { TInvalidateCacheQueueFactory } from "./invalidate-cache-queue"; import { TSuperAdminDALFactory } from "./super-admin-dal"; import { CacheType, + EnvOverrides, LoginMethod, TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, @@ -234,6 +241,45 @@ export const superAdminServiceFactory = ({ adminIntegrationsConfig = config; }; + const getEnvOverrides = async () => { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if (!serverCfg || !serverCfg.encryptedEnvOverrides) { + return {}; + } + + const decrypt = kmsService.decryptWithRootKey(); + + const overrides = JSON.parse(decrypt(serverCfg.encryptedEnvOverrides).toString()) as Record; + + return overrides; + }; + + const getEnvOverridesOrganized = async (): Promise => { + const overrides = await getEnvOverrides(); + const ogConfig = getOriginalConfig(); + + return Object.fromEntries( + Object.entries(overwriteSchema).map(([groupKey, groupDef]) => [ + groupKey, + { + name: groupDef.name, + fields: groupDef.fields.map(({ key, description }) => ({ + key, + description, + value: overrides[key] || "", + hasEnvEntry: !!(ogConfig as unknown as Record)[key] + })) + } + ]) + ); + }; + + const $syncEnvConfig = async () => { + const config = await getEnvOverrides(); + overrideEnvConfig(config); + }; + const updateServerCfg = async ( data: TSuperAdminUpdate & { slackClientId?: string; @@ -246,6 +292,7 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; }, userId: string ) => { @@ -374,6 +421,17 @@ export const superAdminServiceFactory = ({ gitHubAppConnectionSettingsUpdated = true; } + let envOverridesUpdated = false; + if (data.envOverrides !== undefined) { + // Verify input format + validateOverrides(data.envOverrides); + + const encryptedEnvOverrides = encryptWithRoot(Buffer.from(JSON.stringify(data.envOverrides))); + updatedData.encryptedEnvOverrides = encryptedEnvOverrides; + updatedData.envOverrides = undefined; + envOverridesUpdated = true; + } + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); @@ -382,6 +440,10 @@ export const superAdminServiceFactory = ({ await $syncAdminIntegrationConfig(); } + if (envOverridesUpdated) { + await $syncEnvConfig(); + } + if ( updatedServerCfg.encryptedMicrosoftTeamsAppId && updatedServerCfg.encryptedMicrosoftTeamsClientSecret && @@ -814,6 +876,18 @@ export const superAdminServiceFactory = ({ return job; }; + const initializeEnvConfigSync = async () => { + logger.info("Setting up background sync process for environment overrides"); + + await $syncEnvConfig(); + + // sync every 5 minutes + const job = new CronJob("*/5 * * * *", $syncEnvConfig); + job.start(); + + return job; + }; + return { initServerCfg, updateServerCfg, @@ -833,6 +907,9 @@ export const superAdminServiceFactory = ({ getOrganizations, deleteOrganization, deleteOrganizationMembership, - initializeAdminIntegrationConfigSync + initializeAdminIntegrationConfigSync, + initializeEnvConfigSync, + getEnvOverrides, + getEnvOverridesOrganized }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 205c59f2c..b57a015a4 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,3 +1,5 @@ +import { TEnvConfig } from "@app/lib/config/env"; + export type TAdminSignUpDTO = { email: string; password: string; @@ -74,3 +76,10 @@ export type TAdminIntegrationConfig = { privateKey: string; }; }; + +export interface EnvOverrides { + [key: string]: { + name: string; + fields: { key: keyof TEnvConfig; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/docs/images/self-hosting/configuration/overrides/page.png b/docs/images/self-hosting/configuration/overrides/page.png new file mode 100644 index 000000000..660273d4f Binary files /dev/null and b/docs/images/self-hosting/configuration/overrides/page.png differ diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 90f3b2207..5e9121e12 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -794,3 +794,9 @@ If export type is set to `otlp`, you will have to configure a value for `OTEL_EX The TLS header used to propagate the client certificate from the load balancer to the server. + +## Environment Variable Overrides + +If you can't directly access and modify environment variables, you can update them using the [Server Admin Console](/documentation/platform/admin-panel/server-admin). + +![Environment Variables Overrides Page](../../images/self-hosting/configuration/overrides/page.png) diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx new file mode 100644 index 000000000..3ce7c8f19 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -0,0 +1,42 @@ +export const HighlightText = ({ + text, + highlight, + highlightClassName +}: { + text: string | undefined | null; + highlight: string; + highlightClassName?: string; +}) => { + if (!text) return null; + const searchTerm = highlight.toLowerCase().trim(); + + if (!searchTerm) return {text}; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearchTerm, "gi"); + + text.replace(regex, (match: string, offset: number) => { + if (offset > lastIndex) { + parts.push({text.substring(lastIndex, offset)}); + } + + parts.push( + + {match} + + ); + + lastIndex = offset + match.length; + + return match; + }); + + if (lastIndex < text.length) { + parts.push({text.substring(lastIndex)}); + } + + return parts; +}; diff --git a/frontend/src/components/v2/HighlightText/index.tsx b/frontend/src/components/v2/HighlightText/index.tsx new file mode 100644 index 000000000..a2ff1b504 --- /dev/null +++ b/frontend/src/components/v2/HighlightText/index.tsx @@ -0,0 +1 @@ +export { HighlightText } from "./HighlightText"; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index c628df955..871c7288c 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -10,6 +10,7 @@ import { AdminGetUsersFilters, AdminIntegrationsConfig, OrganizationWithProjects, + TGetEnvOverrides, TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig @@ -31,7 +32,8 @@ export const adminQueryKeys = { getAdminSlackConfig: () => ["admin-slack-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const, getInvalidateCache: () => ["admin-invalidate-cache"] as const, - getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const + getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const, + getEnvOverrides: () => ["env-overrides"] as const }; export const fetchServerConfig = async () => { @@ -163,3 +165,13 @@ export const useGetInvalidatingCacheStatus = (enabled = true) => { refetchInterval: (data) => (data ? 3000 : false) }); }; + +export const useGetEnvOverrides = () => { + return useQuery({ + queryKey: adminQueryKeys.getEnvOverrides(), + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/admin/env-overrides"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index c5d92b9da..4580c6581 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -48,6 +48,7 @@ export type TServerConfig = { authConsentContent?: string; pageFrameContent?: string; invalidatingCache: boolean; + envOverrides?: Record; }; export type TUpdateServerConfigDTO = { @@ -61,6 +62,7 @@ export type TUpdateServerConfigDTO = { gitHubAppConnectionSlug?: string; gitHubAppConnectionId?: string; gitHubAppConnectionPrivateKey?: string; + envOverrides?: Record; } & Partial; export type TCreateAdminUserDTO = { @@ -138,3 +140,10 @@ export type TInvalidateCacheDTO = { export type TGetInvalidatingCacheStatus = { invalidating: boolean; }; + +export interface TGetEnvOverrides { + [key: string]: { + name: string; + fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; + }; +} diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx index d5a8d2a4b..807bb307f 100644 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ b/frontend/src/layouts/AdminLayout/Sidebar.tsx @@ -29,6 +29,11 @@ const generalTabs = [ label: "Caching", icon: "note", link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: "unlock", + link: "/admin/environment" } ]; diff --git a/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx new file mode 100644 index 000000000..000dd1f0d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx @@ -0,0 +1,27 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { PageHeader } from "@app/components/v2"; + +import { EnvironmentPageForm } from "./components"; + +export const EnvironmentPage = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Admin" })} + +
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx new file mode 100644 index 000000000..0d2adba9d --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Control, Controller, useForm, useWatch } from "react-hook-form"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faChevronRight, + faExclamationTriangle, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, SecretInput, Tooltip } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; +import { useGetEnvOverrides, useUpdateServerConfig } from "@app/hooks/api"; + +type TForm = Record; + +export const GroupContainer = ({ + group, + control, + search +}: { + group: { + fields: { + key: string; + value: string; + hasEnvEntry: boolean; + description?: string; + }[]; + name: string; + }; + control: Control; + search: string; +}) => { + const [open, setOpen] = useState(false); + + return ( +
+
setOpen((o) => !o)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setOpen((o) => !o); + } + }} + > + + +
{group.name}
+
+ + {(open || search) && ( +
+ {group.fields.map((field) => ( +
+
+ + + + + + +
+ +
+ {field.hasEnvEntry && ( + + + + )} + + ( + + + + )} + /> +
+
+ ))} +
+ )} +
+ ); +}; + +export const EnvironmentPageForm = () => { + const { data: envOverrides } = useGetEnvOverrides(); + const { mutateAsync: updateServerConfig } = useUpdateServerConfig(); + const [search, setSearch] = useState(""); + + const allFields = useMemo(() => { + if (!envOverrides) return []; + return Object.values(envOverrides).flatMap((group) => group.fields); + }, [envOverrides]); + + const formSchema = useMemo(() => { + return z.object(Object.fromEntries(allFields.map((field) => [field.key, z.string()]))); + }, [allFields]); + + const defaultValues = useMemo(() => { + const values: Record = {}; + allFields.forEach((field) => { + values[field.key] = field.value ?? ""; + }); + return values; + }, [allFields]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues + }); + + const formValues = useWatch({ control }); + + const filteredData = useMemo(() => { + if (!envOverrides) return []; + + const searchTerm = search.toLowerCase().trim(); + if (!searchTerm) { + return Object.values(envOverrides); + } + + return Object.values(envOverrides) + .map((group) => { + const filteredFields = group.fields.filter( + (field) => + field.key.toLowerCase().includes(searchTerm) || + (field.description ?? "").toLowerCase().includes(searchTerm) + ); + + if (filteredFields.length > 0) { + return { ...group, fields: filteredFields }; + } + return null; + }) + .filter(Boolean); + }, [search, formValues, envOverrides]); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const onSubmit = useCallback( + async (formData: TForm) => { + try { + const filteredFormData = Object.fromEntries( + Object.entries(formData).filter(([, value]) => value !== "") + ); + await updateServerConfig({ + envOverrides: filteredFormData + }); + + createNotification({ + type: "success", + text: "Environment overrides updated successfully. It can take up to 5 minutes to take effect." + }); + + reset(formData); + } catch (error) { + const errorMessage = + (error as any)?.response?.data?.message || + (error as any)?.message || + "An unknown error occurred"; + createNotification({ + type: "error", + title: "Failed to update environment overrides", + text: errorMessage + }); + } + }, + [reset, updateServerConfig] + ); + + return ( +
+
+
+
+

Overrides

+ +
+ + Docs + +
+
+
+

+ Override specific environment variables. After saving, it may take up to 5 minutes for + variables to propagate throughout every container. +

+
+ +
+ +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search for keys, descriptions, and values..." + className="flex-1" + /> +
+ {filteredData.map((group) => ( + + ))} +
+ + ); +}; diff --git a/frontend/src/pages/admin/EnvironmentPage/components/index.ts b/frontend/src/pages/admin/EnvironmentPage/components/index.ts new file mode 100644 index 000000000..44b82c206 --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/components/index.ts @@ -0,0 +1 @@ +export { EnvironmentPageForm } from "./EnvironmentPageForm"; diff --git a/frontend/src/pages/admin/EnvironmentPage/route.tsx b/frontend/src/pages/admin/EnvironmentPage/route.tsx new file mode 100644 index 000000000..8f9ac4c7e --- /dev/null +++ b/frontend/src/pages/admin/EnvironmentPage/route.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, linkOptions } from "@tanstack/react-router"; + +import { EnvironmentPage } from "./EnvironmentPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/admin/_admin-layout/environment" +)({ + component: EnvironmentPage, + beforeLoad: () => { + return { + breadcrumbs: [ + { + label: "Admin", + link: linkOptions({ to: "/admin" }) + }, + { + label: "Environment", + link: linkOptions({ + to: "/admin/environment" + }) + } + ] + }; + } +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index eda9bbaae..328bf2c42 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as authProviderSuccessPageRouteImport } from './pages/auth/Provid import { Route as authProviderErrorPageRouteImport } from './pages/auth/ProviderErrorPage/route' import { Route as userPersonalSettingsPageRouteImport } from './pages/user/PersonalSettingsPage/route' import { Route as adminIntegrationsPageRouteImport } from './pages/admin/IntegrationsPage/route' +import { Route as adminEnvironmentPageRouteImport } from './pages/admin/EnvironmentPage/route' import { Route as adminEncryptionPageRouteImport } from './pages/admin/EncryptionPage/route' import { Route as adminCachingPageRouteImport } from './pages/admin/CachingPage/route' import { Route as adminAuthenticationPageRouteImport } from './pages/admin/AuthenticationPage/route' @@ -565,6 +566,12 @@ const adminIntegrationsPageRouteRoute = adminIntegrationsPageRouteImport.update( } as any, ) +const adminEnvironmentPageRouteRoute = adminEnvironmentPageRouteImport.update({ + id: '/environment', + path: '/environment', + getParentRoute: () => adminLayoutRoute, +} as any) + const adminEncryptionPageRouteRoute = adminEncryptionPageRouteImport.update({ id: '/encryption', path: '/encryption', @@ -2180,6 +2187,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof adminEncryptionPageRouteImport parentRoute: typeof adminLayoutImport } + '/_authenticate/_inject-org-details/admin/_admin-layout/environment': { + id: '/_authenticate/_inject-org-details/admin/_admin-layout/environment' + path: '/environment' + fullPath: '/admin/environment' + preLoaderRoute: typeof adminEnvironmentPageRouteImport + parentRoute: typeof adminLayoutImport + } '/_authenticate/_inject-org-details/admin/_admin-layout/integrations': { id: '/_authenticate/_inject-org-details/admin/_admin-layout/integrations' path: '/integrations' @@ -4120,6 +4134,7 @@ interface adminLayoutRouteChildren { adminAuthenticationPageRouteRoute: typeof adminAuthenticationPageRouteRoute adminCachingPageRouteRoute: typeof adminCachingPageRouteRoute adminEncryptionPageRouteRoute: typeof adminEncryptionPageRouteRoute + adminEnvironmentPageRouteRoute: typeof adminEnvironmentPageRouteRoute adminIntegrationsPageRouteRoute: typeof adminIntegrationsPageRouteRoute adminMachineIdentitiesResourcesPageRouteRoute: typeof adminMachineIdentitiesResourcesPageRouteRoute adminOrganizationResourcesPageRouteRoute: typeof adminOrganizationResourcesPageRouteRoute @@ -4131,6 +4146,7 @@ const adminLayoutRouteChildren: adminLayoutRouteChildren = { adminAuthenticationPageRouteRoute: adminAuthenticationPageRouteRoute, adminCachingPageRouteRoute: adminCachingPageRouteRoute, adminEncryptionPageRouteRoute: adminEncryptionPageRouteRoute, + adminEnvironmentPageRouteRoute: adminEnvironmentPageRouteRoute, adminIntegrationsPageRouteRoute: adminIntegrationsPageRouteRoute, adminMachineIdentitiesResourcesPageRouteRoute: adminMachineIdentitiesResourcesPageRouteRoute, @@ -4333,6 +4349,7 @@ export interface FileRoutesByFullPath { '/admin/authentication': typeof adminAuthenticationPageRouteRoute '/admin/caching': typeof adminCachingPageRouteRoute '/admin/encryption': typeof adminEncryptionPageRouteRoute + '/admin/environment': typeof adminEnvironmentPageRouteRoute '/admin/integrations': typeof adminIntegrationsPageRouteRoute '/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren '/organization/gateways': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren @@ -4529,6 +4546,7 @@ export interface FileRoutesByTo { '/admin/authentication': typeof adminAuthenticationPageRouteRoute '/admin/caching': typeof adminCachingPageRouteRoute '/admin/encryption': typeof adminEncryptionPageRouteRoute + '/admin/environment': typeof adminEnvironmentPageRouteRoute '/admin/integrations': typeof adminIntegrationsPageRouteRoute '/projects/$projectId': typeof projectLayoutGeneralRouteWithChildren '/secret-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutSecretManagerProjectIdRouteWithChildren @@ -4725,6 +4743,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/admin/_admin-layout/authentication': typeof adminAuthenticationPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/caching': typeof adminCachingPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/encryption': typeof adminEncryptionPageRouteRoute + '/_authenticate/_inject-org-details/admin/_admin-layout/environment': typeof adminEnvironmentPageRouteRoute '/_authenticate/_inject-org-details/admin/_admin-layout/integrations': typeof adminIntegrationsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/organization/app-connections': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationAppConnectionsRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/organization/gateways': typeof AuthenticateInjectOrgDetailsOrgLayoutOrganizationGatewaysRouteWithChildren @@ -4934,6 +4953,7 @@ export interface FileRouteTypes { | '/admin/authentication' | '/admin/caching' | '/admin/encryption' + | '/admin/environment' | '/admin/integrations' | '/organization/app-connections' | '/organization/gateways' @@ -5129,6 +5149,7 @@ export interface FileRouteTypes { | '/admin/authentication' | '/admin/caching' | '/admin/encryption' + | '/admin/environment' | '/admin/integrations' | '/projects/$projectId' | '/secret-manager/$projectId' @@ -5323,6 +5344,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/admin/_admin-layout/authentication' | '/_authenticate/_inject-org-details/admin/_admin-layout/caching' | '/_authenticate/_inject-org-details/admin/_admin-layout/encryption' + | '/_authenticate/_inject-org-details/admin/_admin-layout/environment' | '/_authenticate/_inject-org-details/admin/_admin-layout/integrations' | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections' | '/_authenticate/_inject-org-details/_org-layout/organization/gateways' @@ -5729,6 +5751,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/admin/_admin-layout/authentication", "/_authenticate/_inject-org-details/admin/_admin-layout/caching", "/_authenticate/_inject-org-details/admin/_admin-layout/encryption", + "/_authenticate/_inject-org-details/admin/_admin-layout/environment", "/_authenticate/_inject-org-details/admin/_admin-layout/integrations", "/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities", "/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations", @@ -5775,6 +5798,10 @@ export const routeTree = rootRoute "filePath": "admin/EncryptionPage/route.tsx", "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" }, + "/_authenticate/_inject-org-details/admin/_admin-layout/environment": { + "filePath": "admin/EnvironmentPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" + }, "/_authenticate/_inject-org-details/admin/_admin-layout/integrations": { "filePath": "admin/IntegrationsPage/route.tsx", "parent": "/_authenticate/_inject-org-details/admin/_admin-layout" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index dc8981827..80d8fb29f 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -8,6 +8,7 @@ const adminRoute = route("/admin", [ index("admin/GeneralPage/route.tsx"), route("/encryption", "admin/EncryptionPage/route.tsx"), route("/authentication", "admin/AuthenticationPage/route.tsx"), + route("/environment", "admin/EnvironmentPage/route.tsx"), route("/integrations", "admin/IntegrationsPage/route.tsx"), route("/caching", "admin/CachingPage/route.tsx"), route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),