Compare commits

..

32 Commits

Author SHA1 Message Date
38c9242e5b misc: add plain support for user get token in CLI 2025-07-02 04:45:53 +08:00
cce2a54265 Merge pull request #3883 from Infisical/doc/add-mention-of-default-audience-support
doc: add mention of default audience support for CSI
2025-07-01 14:35:15 -04:00
d1033cb324 Merge pull request #3875 from Infisical/ENG-3009
feat(super-admin): Environment Overrides
2025-07-02 02:18:40 +08:00
7134e1dc66 misc: updated success notif 2025-07-02 02:18:04 +08:00
8aa26b77ed Fix check 2025-07-01 13:11:15 -04:00
4b06880320 Feedback fixes 2025-07-01 11:52:01 -04:00
124cd9f812 Merge pull request #3893 from Infisical/misc/added-missing-project-cert-endpoints-to-open-api-spec
misc: added missing project cert endpoints to open api spec
2025-07-01 23:39:37 +08:00
d531d069d1 Add azure app connection 2025-07-01 11:23:44 -04:00
522a5d477d Merge pull request #3889 from Infisical/minor-access-approval-modal-improvements
improvement(approval-policy): minor create policy layout adjustments
2025-07-01 08:21:26 -07:00
d2f0db669a Merge pull request #3894 from Infisical/fix/address-instance-of-github-dynamic-secret
fix: address instanceof check in github dynamic secret
2025-07-01 23:11:01 +08:00
4dd78d745b fix: address instanceof check in github dynamic secret 2025-07-01 20:45:00 +08:00
4fef5c305d misc: added missing project cert endpoints to open api spec 2025-07-01 18:53:13 +08:00
30f3543850 Merge pull request #3876 from Infisical/ENG-2977
feat(secret-sync): Allow custom field label on 1pass sync
2025-06-30 23:36:22 -04:00
114915f913 Merge pull request #3891 from Infisical/change-request-page-improvements
improvement(secret-approval-request): Color/layout styling adjustments to change request page
2025-06-30 19:35:40 -07:00
b5801af9a8 improvements: address feedback 2025-06-30 18:32:36 -07:00
20366a8c07 improvement: address feedback 2025-06-30 18:09:50 -07:00
447e28511c improvement: update stale/conflict text 2025-06-30 16:44:29 -07:00
650ed656e3 improvement: color/layout styling adjustments to change request page 2025-06-30 16:30:37 -07:00
54ac450b63 improvement: minor layout adjustments 2025-06-30 14:38:23 -07:00
3871fa552c Merge pull request #3888 from Infisical/revert-3885-misc/add-indices-for-referencing-columns-in-identity-access-token
Revert "misc: add indices for referencing columns in identity access token"
2025-06-30 17:27:31 -04:00
9c72ee7f10 Revert "misc: add indices for referencing columns in identity access token" 2025-07-01 05:23:51 +08:00
22e8617661 Merge pull request #3885 from Infisical/misc/add-indices-for-referencing-columns-in-identity-access-token
misc: add indices for referencing columns in identity access token
2025-06-30 17:01:20 -04:00
cb6c28ac26 UI updates 2025-06-30 14:08:27 -04:00
d3833c33b3 Merge pull request #3878 from Infisical/fix-approval-policy-bypassing
Fix bypassing approval policies
2025-06-30 13:37:28 -04:00
3723afe595 Merge branch 'main' into ENG-3009 2025-06-30 12:01:14 -04:00
14d6f6c048 doc: add mention of default audience support for CSI 2025-06-30 23:51:50 +08:00
58b61a861a Fix bypassing approval policies 2025-06-28 04:17:09 -04:00
d79a6b8f25 Lint fixes 2025-06-28 03:35:52 -04:00
217a09c97b Docs 2025-06-28 03:14:45 -04:00
a389ede03d Review fixes 2025-06-28 03:01:34 -04:00
10939fecc0 feat(super-admin): Environment Overrides 2025-06-28 02:35:38 -04:00
9af5a66bab feat(secret-sync): Allow custom field label on 1pass sync 2025-06-26 16:07:08 -04:00
42 changed files with 1300 additions and 403 deletions

View File

@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides");
if (hasColumn) {
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
t.dropColumn("encryptedEnvOverrides");
});
}
}

View File

@ -1,29 +0,0 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS ${TableName.IdentityAccessToken}_identityid_index
ON ${TableName.IdentityAccessToken} ("identityId")
`);
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS ${TableName.IdentityAccessToken}_identityuaclientsecretid_index
ON ${TableName.IdentityAccessToken} ("identityUAClientSecretId")
`);
}
export async function down(knex: Knex): Promise<void> {
await knex.raw(`
DROP INDEX IF EXISTS ${TableName.IdentityAccessToken}_identityid_index
`);
await knex.raw(`
DROP INDEX IF EXISTS ${TableName.IdentityAccessToken}_identityuaclientsecretid_index
`);
}
const config = { transaction: false };
export { config };

View File

@ -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<typeof SuperAdminSchema>;

View File

@ -350,6 +350,12 @@ export const accessApprovalRequestServiceFactory = ({
const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId);
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
// Calculate break glass attempt before sequence checks
const isBreakGlassApprovalAttempt =
policy.enforcementLevel === EnforcementLevel.Soft &&
actorId === accessApprovalRequest.requestedByUserId &&
status === ApprovalStatus.APPROVED;
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
// If user is (not an approver OR cant self approve) AND can't bypass policy
if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) {
@ -409,15 +415,14 @@ export const accessApprovalRequestServiceFactory = ({
const isApproverOfTheSequence = policy.approvers.find(
(el) => el.sequence === presentSequence.step && el.userId === actorId
);
if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" });
// Only throw if actor is not the approver and not bypassing
if (!isApproverOfTheSequence && !isBreakGlassApprovalAttempt) {
throw new BadRequestError({ message: "You are not a reviewer in this step" });
}
}
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
const isBreakGlassApprovalAttempt =
policy.enforcementLevel === EnforcementLevel.Soft &&
actorId === accessApprovalRequest.requestedByUserId &&
status === ApprovalStatus.APPROVED;
let reviewForThisActorProcessing: {
id: string;
requestId: string;

View File

@ -1,5 +1,5 @@
import axios from "axios";
import * as jwt from "jsonwebtoken";
import jwt from "jsonwebtoken";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";

View File

@ -2427,7 +2427,8 @@ export const SecretSyncs = {
keyOcid: "The OCID (Oracle Cloud Identifier) of the encryption key to use when creating secrets in the vault."
},
ONEPASS: {
vaultId: "The ID of the 1Password vault to sync secrets to."
vaultId: "The ID of the 1Password vault to sync secrets to.",
valueLabel: "The label of the entry that holds the secret value."
},
HEROKU: {
app: "The ID of the Heroku app to sync secrets to.",

View File

@ -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<z.infer<typeof envSchema>>;
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<string, string>) => {
const allowedOverrides = Object.fromEntries(
Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input<typeof envSchema>))
);
const tempEnv: Record<string, unknown> = { ...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<string, string>) => {
const allowedOverrides = Object.fromEntries(
Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input<typeof envSchema>))
);
const tempEnv: Record<string, unknown> = { ...process.env, ...allowedOverrides };
const parsedResult = envSchema.safeParse(tempEnv);
if (parsedResult.success) {
envCfg = Object.freeze(parsedResult.data);
}
};
export const formatSmtpConfig = () => {
const tlsOptions: {
rejectUnauthorized: boolean;

View File

@ -2045,6 +2045,10 @@ export const registerRoutes = async (
cronJobs.push(adminIntegrationsSyncJob);
}
}
const configSyncJob = await superAdminService.initializeEnvConfigSync();
if (configSyncJob) {
cronJobs.push(configSyncJob);
}
server.decorate<FastifyZodProvider["store"]>("store", {
user: userDAL,

View File

@ -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",

View File

@ -457,6 +457,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting],
params: z.object({
projectId: z.string().trim()
}),
@ -487,6 +489,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateCollections],
params: z.object({
projectId: z.string().trim()
}),
@ -549,6 +553,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
projectId: z.string().trim()
}),

View File

@ -6,7 +6,6 @@ import {
TOnePassListVariablesResponse,
TOnePassSyncWithCredentials,
TOnePassVariable,
TOnePassVariableDetails,
TPostOnePassVariable,
TPutOnePassVariable
} from "@app/services/secret-sync/1password/1password-sync-types";
@ -14,7 +13,10 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassListVariables) => {
// This should not be changed or it may break existing logic
const VALUE_LABEL_DEFAULT = "value";
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId, valueLabel }: TOnePassListVariables) => {
const { data } = await request.get<TOnePassListVariablesResponse>(`${instanceUrl}/v1/vaults/${vaultId}/items`, {
headers: {
Authorization: `Bearer ${apiToken}`,
@ -22,36 +24,49 @@ const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassList
}
});
const result: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
const items: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
const duplicates: Record<string, string> = {};
for await (const s of data) {
const { data: secret } = await request.get<TOnePassVariableDetails>(
`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: "application/json"
}
}
);
// eslint-disable-next-line no-continue
if (s.category !== "API_CREDENTIAL") continue;
const value = secret.fields.find((f) => f.label === "value")?.value;
const fieldId = secret.fields.find((f) => f.label === "value")?.id;
if (items[s.title]) {
duplicates[s.id] = s.title;
// eslint-disable-next-line no-continue
continue;
}
const { data: secret } = await request.get<TOnePassVariable>(`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`, {
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: "application/json"
}
});
const valueField = secret.fields.find((f) => f.label === valueLabel);
// eslint-disable-next-line no-continue
if (!value || !fieldId) continue;
if (!valueField || !valueField.value || !valueField.id) continue;
result[s.title] = {
items[s.title] = {
...secret,
value,
fieldId
value: valueField.value,
fieldId: valueField.id
};
}
return result;
return { items, duplicates };
};
const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, itemValue }: TPostOnePassVariable) => {
const createOnePassItem = async ({
instanceUrl,
apiToken,
vaultId,
itemTitle,
itemValue,
valueLabel
}: TPostOnePassVariable) => {
return request.post(
`${instanceUrl}/v1/vaults/${vaultId}/items`,
{
@ -63,7 +78,7 @@ const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, it
tags: ["synced-from-infisical"],
fields: [
{
label: "value",
label: valueLabel,
value: itemValue,
type: "CONCEALED"
}
@ -85,7 +100,9 @@ const updateOnePassItem = async ({
itemId,
fieldId,
itemTitle,
itemValue
itemValue,
valueLabel,
otherFields
}: TPutOnePassVariable) => {
return request.put(
`${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`,
@ -98,9 +115,10 @@ const updateOnePassItem = async ({
},
tags: ["synced-from-infisical"],
fields: [
...otherFields,
{
id: fieldId,
label: "value",
label: valueLabel,
value: itemValue,
type: "CONCEALED"
}
@ -128,13 +146,18 @@ export const OnePassSyncFns = {
const {
connection,
environment,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
const { items, duplicates } = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
@ -148,10 +171,19 @@ export const OnePassSyncFns = {
itemTitle: key,
itemValue: value,
itemId: items[key].id,
fieldId: items[key].fieldId
fieldId: items[key].fieldId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT,
otherFields: items[key].fields.filter((field) => field.label !== (valueLabel || VALUE_LABEL_DEFAULT))
});
} else {
await createOnePassItem({ instanceUrl, apiToken, vaultId, itemTitle: key, itemValue: value });
await createOnePassItem({
instanceUrl,
apiToken,
vaultId,
itemTitle: key,
itemValue: value,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
}
} catch (error) {
throw new SecretSyncError({
@ -163,7 +195,28 @@ export const OnePassSyncFns = {
if (secretSync.syncOptions.disableSecretDeletion) return;
for await (const [key, variable] of Object.entries(items)) {
// Delete duplicate item entries
for await (const [itemId, key] of Object.entries(duplicates)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
try {
await deleteOnePassItem({
instanceUrl,
apiToken,
vaultId,
itemId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
// Delete item entries that are not in secretMap
for await (const [key, item] of Object.entries(items)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
@ -173,7 +226,7 @@ export const OnePassSyncFns = {
instanceUrl,
apiToken,
vaultId,
itemId: variable.id
itemId: item.id
});
} catch (error) {
throw new SecretSyncError({
@ -187,13 +240,18 @@ export const OnePassSyncFns = {
removeSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => {
const {
connection,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
const { items } = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
for await (const [key, item] of Object.entries(items)) {
if (key in secretMap) {
@ -216,12 +274,19 @@ export const OnePassSyncFns = {
getSecrets: async (secretSync: TOnePassSyncWithCredentials) => {
const {
connection,
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
const instanceUrl = await getOnePassInstanceUrl(connection);
const { apiToken } = connection.credentials;
return listOnePassItems({ instanceUrl, apiToken, vaultId });
const res = await listOnePassItems({
instanceUrl,
apiToken,
vaultId,
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
});
return Object.fromEntries(Object.entries(res.items).map(([key, item]) => [key, { value: item.value }]));
}
};

View File

@ -11,7 +11,8 @@ import {
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const OnePassSyncDestinationConfigSchema = z.object({
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId)
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId),
valueLabel: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.valueLabel)
});
const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };

View File

@ -14,29 +14,32 @@ export type TOnePassSyncWithCredentials = TOnePassSync & {
connection: TOnePassConnection;
};
type Field = {
id: string;
type: string; // CONCEALED, STRING
label: string;
value: string;
};
export type TOnePassVariable = {
id: string;
title: string;
category: string; // API_CREDENTIAL, SECURE_NOTE, LOGIN, etc
};
export type TOnePassVariableDetails = TOnePassVariable & {
fields: {
id: string;
type: string; // CONCEALED, STRING
label: string;
value: string;
}[];
fields: Field[];
};
export type TOnePassListVariablesResponse = TOnePassVariable[];
export type TOnePassListVariables = {
type TOnePassBase = {
apiToken: string;
instanceUrl: string;
vaultId: string;
};
export type TOnePassListVariables = TOnePassBase & {
valueLabel: string;
};
export type TPostOnePassVariable = TOnePassListVariables & {
itemTitle: string;
itemValue: string;
@ -47,8 +50,9 @@ export type TPutOnePassVariable = TOnePassListVariables & {
fieldId: string;
itemTitle: string;
itemValue: string;
otherFields: Field[];
};
export type TDeleteOnePassVariable = TOnePassListVariables & {
export type TDeleteOnePassVariable = TOnePassBase & {
itemId: string;
};

View File

@ -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<string, string>;
return overrides;
};
const getEnvOverridesOrganized = async (): Promise<EnvOverrides> => {
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<string, string | undefined>)[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<string, string>;
},
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
};
};

View File

@ -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 }[];
};
}

View File

@ -114,6 +114,11 @@ var userGetTokenCmd = &cobra.Command{
loggedInUserDetails = util.EstablishUserLoginSession()
}
plain, err := cmd.Flags().GetBool("plain")
if err != nil {
util.HandleError(err, "[infisical user get token]: Unable to get plain flag")
}
if err != nil {
util.HandleError(err, "[infisical user get token]: Unable to get logged in user token")
}
@ -135,8 +140,12 @@ var userGetTokenCmd = &cobra.Command{
util.HandleError(err, "[infisical user get token]: Unable to parse token payload")
}
fmt.Println("Session ID:", tokenPayload.TokenVersionId)
fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken)
if plain {
fmt.Println(loggedInUserDetails.UserCredentials.JTWToken)
} else {
fmt.Println("Session ID:", tokenPayload.TokenVersionId)
fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken)
}
},
}
@ -240,7 +249,10 @@ var domainCmd = &cobra.Command{
func init() {
updateCmd.AddCommand(domainCmd)
userCmd.AddCommand(updateCmd)
userGetTokenCmd.Flags().Bool("plain", false, "print token without formatting")
userGetCmd.AddCommand(userGetTokenCmd)
userCmd.AddCommand(userGetCmd)
userCmd.AddCommand(switchCmd)
rootCmd.AddCommand(userCmd)

View File

@ -35,19 +35,40 @@ infisical user update domain
<Accordion title="infisical user get token">
Use this command to get your current Infisical access token and session information. This command requires you to be logged in.
The command will display:
The command will display:
- Your session ID
- Your full JWT access token
- Your session ID
- Your full JWT access token
```bash
infisical user get token
```
```bash
infisical user get token
```
Example output:
Example output:
```bash
Session ID: abc123-xyz-456
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### Flags
<Accordion title="--plain">
Output only the JWT token without formatting (no session ID)
Default value: `false`
```bash
# Example
infisical user get token --plain
```
Example output:
```bash
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
</Accordion>
```bash
Session ID: abc123-xyz-456
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
</Accordion>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 KiB

After

Width:  |  Height:  |  Size: 641 KiB

View File

@ -44,8 +44,11 @@ Currently, the Infisical CSI provider only supports static secrets.
### Install Secrets Store CSI Driver
In order to use the Infisical CSI provider, you will first have to install the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io/getting-started/installation) to your cluster. It is important that you define
the audience value for token requests as demonstrated below. The Infisical CSI provider will **NOT WORK** if this is not set.
In order to use the Infisical CSI provider, you will first have to install the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io/getting-started/installation) to your cluster.
#### Standard Installation
For most Kubernetes clusters, use the following installation:
```bash
helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
@ -62,7 +65,7 @@ helm install csi secrets-store-csi-driver/secrets-store-csi-driver \
The flags configure the following:
- `tokenRequests[0].audience=infisical`: Sets the audience value for service account token authentication (required)
- `tokenRequests[0].audience=infisical`: Sets the audience value for service account token authentication (recommended for environments that support custom audiences)
- `enableSecretRotation=true`: Enables automatic secret updates from Infisical
- `rotationPollInterval=2m`: Checks for secret updates every 2 minutes
- `syncSecret.enabled=true`: Enables syncing secrets to Kubernetes secrets
@ -76,6 +79,25 @@ The flags configure the following:
for the CSI driver.
</Info>
#### Installation for Environments Without Custom Audience Support
Some Kubernetes environments (such as AWS EKS) don't support custom audiences and will reject tokens with non-default audiences. For these environments, use this installation instead:
```bash
helm install csi secrets-store-csi-driver/secrets-store-csi-driver \
--namespace=kube-system \
--set enableSecretRotation=true \
--set rotationPollInterval=2m \
--set "syncSecret.enabled=true" \
```
<Warning>
**Environments without custom audience support**: Do not set a custom audience
when installing the CSI driver in environments that reject custom audiences.
Instead, use the installation above and set `useDefaultAudience: "true"` in
your SecretProviderClass configuration.
</Warning>
### Install Infisical CSI Provider
You would then have to install the Infisical CSI provider to your cluster.
@ -107,9 +129,12 @@ a machine identity with [Kubernetes authentication](https://infisical.com/docs/d
You can refer to the documentation for setting it up [here](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth#guide).
<Warning>
The allowed audience field of the Kubernetes authentication settings should
match the audience specified for the Secrets Store CSI driver during
installation.
**Important**: The "Allowed Audience" field in your machine identity's
Kubernetes authentication settings must match your CSI driver installation. If
you used the standard installation with `tokenRequests[0].audience=infisical`,
set the "Allowed Audience" field to `infisical`. If you used the installation
for environments without custom audience support, leave the "Allowed Audience"
field empty.
</Warning>
### Creating Secret Provider Class
@ -117,6 +142,8 @@ You can refer to the documentation for setting it up [here](https://infisical.co
With the Secrets Store CSI driver and the Infisical CSI provider installed, create a Kubernetes [SecretProviderClass](https://secrets-store-csi-driver.sigs.k8s.io/concepts.html#secretproviderclass) resource to establish
the connection between the CSI driver and the Infisical CSI provider for secret retrieval. You can create as many Secret Provider Classes as needed for your cluster.
#### Standard Configuration
```yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
@ -139,6 +166,41 @@ spec:
secretKey: "APP_SECRET"
```
#### Configuration for Environments Without Custom Audience Support
For environments that don't support custom audiences (such as AWS EKS), use this configuration instead:
```yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: my-infisical-app-csi-provider
spec:
provider: infisical
parameters:
infisicalUrl: "https://app.infisical.com"
authMethod: "kubernetes"
useDefaultAudience: "true"
identityId: "ad2f8c67-cbe2-417a-b5eb-1339776ec0b3"
projectId: "09eda1f8-85a3-47a9-8a6f-e27f133b2a36"
envSlug: "prod"
secrets: |
- secretPath: "/"
fileName: "dbPassword"
secretKey: "DB_PASSWORD"
- secretPath: "/app"
fileName: "appSecret"
secretKey: "APP_SECRET"
```
<Note>
**Key difference**: The only change from the standard configuration is the
addition of `useDefaultAudience: "true"`. This parameter tells the CSI
provider to use the default Kubernetes audience instead of a custom
"infisical" audience, which is required for environments that reject custom
audiences.
</Note>
<Note>
The SecretProviderClass should be provisioned in the same namespace as the pod
you intend to mount secrets to.
@ -189,6 +251,19 @@ spec:
`infisical`.
</Accordion>
<Accordion title="useDefaultAudience">
When set to `"true"`, the Infisical CSI provider will use the default
Kubernetes audience instead of a custom audience. This is required for
environments that don't support custom audiences (such as AWS EKS), which
reject tokens with non-default audiences. When using this option, do not set a
custom audience in the CSI driver installation. This defaults to `false`.
<Note>
When enabled, the CSI provider will dynamically create service account
tokens on-demand using the default Kubernetes audience, rather than using
pre-existing tokens from the CSI driver.
</Note>
</Accordion>
### Using Secret Provider Class
A pod can use the Secret Provider Class by mounting it as a CSI volume:
@ -252,6 +327,11 @@ kubectl logs csi-secrets-store-csi-driver-7h4jp -n=kube-system
- Invalid machine identity configuration
- Incorrect secret paths or keys
**Issues in environments without custom audience support:**
- **Token authentication failed with custom audience**: If you're seeing authentication errors in environments that don't support custom audiences (such as AWS EKS), ensure you're using the installation without custom audience and have set `useDefaultAudience: "true"` in your SecretProviderClass
- **Audience not allowed errors**: Make sure the "Allowed Audience" field is left empty in your machine identity's Kubernetes authentication configuration when using environments that don't support custom audiences
## Best Practices
For additional guidance on setting this up for your production cluster, you can refer to the Secrets Store CSI driver documentation [here](https://secrets-store-csi-driver.sigs.k8s.io/topics/best-practices).

View File

@ -36,6 +36,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
- **1Password Connection**: The 1Password Connection to authenticate with.
- **Vault**: The 1Password vault to sync secrets to.
- **Value Label**: The label of the 1Password item field that will hold your secret value.
</Step>
<Step title="Configure sync options">
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
@ -94,7 +95,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
"initialSyncBehavior": "overwrite-destination"
},
"destinationConfig": {
"vaultId": "..."
"vaultId": "...",
"valueLabel": "value"
}
}'
```
@ -145,7 +147,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
},
"destination": "1password",
"destinationConfig": {
"vaultId": "..."
"vaultId": "...",
"valueLabel": "value"
}
}
}
@ -160,4 +163,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
Infisical can only perform CRUD operations on the following item types:
- API Credentials
</Accordion>
<Accordion title="What is a 'Value Label'?">
It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option.
</Accordion>
</AccordionGroup>

View File

@ -4,7 +4,7 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2";
import {
TOnePassVault,
useOnePassConnectionListVaults
@ -32,6 +32,7 @@ export const OnePassSyncFields = () => {
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.vaultId", "");
setValue("destinationConfig.valueLabel", "");
}}
/>
@ -69,6 +70,22 @@ export const OnePassSyncFields = () => {
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
isOptional
label="Value Label"
tooltipText="It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option."
>
<Input value={value} onChange={onChange} placeholder="value" />
</FormControl>
)}
control={control}
name="destinationConfig.valueLabel"
/>
</>
);
};

View File

@ -6,7 +6,15 @@ import { SecretSync } from "@app/hooks/api/secretSyncs";
export const OnePassSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.OnePass }>();
const vaultId = watch("destinationConfig.vaultId");
const [vaultId, valueLabel] = watch([
"destinationConfig.vaultId",
"destinationConfig.valueLabel"
]);
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
return (
<>
<GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>
<GenericFieldLabel label="Value Key">{valueLabel || "value"}</GenericFieldLabel>
</>
);
};

View File

@ -7,7 +7,8 @@ export const OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.OnePass),
destinationConfig: z.object({
vaultId: z.string().trim().min(1, "Vault ID required")
vaultId: z.string().trim().min(1, "Vault ID required"),
valueLabel: z.string().trim().optional()
})
})
);

View File

@ -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 <span>{text}</span>;
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(<span key={`pre-${lastIndex}`}>{text.substring(lastIndex, offset)}</span>);
}
parts.push(
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
{match}
</span>
);
lastIndex = offset + match.length;
return match;
});
if (lastIndex < text.length) {
parts.push(<span key={`post-${lastIndex}`}>{text.substring(lastIndex)}</span>);
}
return parts;
};

View File

@ -0,0 +1 @@
export { HighlightText } from "./HighlightText";

View File

@ -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<TGetEnvOverrides>("/api/v1/admin/env-overrides");
return data;
}
});
};

View File

@ -48,6 +48,7 @@ export type TServerConfig = {
authConsentContent?: string;
pageFrameContent?: string;
invalidatingCache: boolean;
envOverrides?: Record<string, string>;
};
export type TUpdateServerConfigDTO = {
@ -61,6 +62,7 @@ export type TUpdateServerConfigDTO = {
gitHubAppConnectionSlug?: string;
gitHubAppConnectionId?: string;
gitHubAppConnectionPrivateKey?: string;
envOverrides?: Record<string, string>;
} & Partial<TServerConfig>;
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 }[];
};
}

View File

@ -6,6 +6,7 @@ export type TOnePassSync = TRootSecretSync & {
destination: SecretSync.OnePass;
destinationConfig: {
vaultId: string;
valueLabel?: string;
};
connection: {
app: AppConnection.OnePass;

View File

@ -41,6 +41,11 @@ const generalTabs = [
label: "Caching",
icon: "note",
link: "/admin/caching"
},
{
label: "Environment",
icon: "unlock",
link: "/admin/environment"
}
];

View File

@ -164,7 +164,10 @@ export const MinimizedOrgSidebar = () => {
const handleCopyToken = async () => {
try {
await window.navigator.clipboard.writeText(getAuthToken());
createNotification({ type: "success", text: "Copied current login session token to clipboard" });
createNotification({
type: "success",
text: "Copied current login session token to clipboard"
});
} catch (error) {
console.log(error);
createNotification({ type: "error", text: "Failed to copy user token to clipboard" });

View File

@ -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 (
<div className="bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
</Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader
title="Environment"
description="Manage the environment for your Infisical instance."
/>
<EnvironmentPageForm />
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,245 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Control, Controller, useForm, useWatch } from "react-hook-form";
import {
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<string, string>;
export const GroupContainer = ({
group,
control,
search
}: {
group: {
fields: {
key: string;
value: string;
hasEnvEntry: boolean;
description?: string;
}[];
name: string;
};
control: Control<TForm, any, TForm>;
search: string;
}) => {
const [open, setOpen] = useState(false);
return (
<div
key={group.name}
className="overflow-clip border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md last:border-b"
>
<div
className="flex h-14 cursor-pointer items-center px-5 py-4 text-sm text-gray-300"
role="button"
tabIndex={0}
onClick={() => setOpen((o) => !o)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOpen((o) => !o);
}
}}
>
<FontAwesomeIcon
className={`mr-8 transition-transform duration-100 ${open || search ? "rotate-90" : ""}`}
icon={faChevronRight}
/>
<div className="flex-grow select-none text-base">{group.name}</div>
</div>
{(open || search) && (
<div className="flex flex-col">
{group.fields.map((field) => (
<div
key={field.key}
className="flex items-center justify-between gap-4 border-t border-mineshaft-500 bg-mineshaft-700/50 p-4"
>
<div className="flex max-w-lg flex-col">
<span className="text-sm">
<HighlightText text={field.key} highlight={search} />
</span>
<span className="text-sm text-mineshaft-400">
<HighlightText text={field.description} highlight={search} />
</span>
</div>
<div className="flex grow items-center justify-end gap-2">
{field.hasEnvEntry && (
<Tooltip
content="Setting this value will override an existing environment variable"
className="text-center"
>
<FontAwesomeIcon icon={faExclamationTriangle} className="text-yellow" />
</Tooltip>
)}
<Controller
control={control}
name={field.key}
render={({ field: formGenField, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mb-0 w-full max-w-sm"
>
<SecretInput
{...formGenField}
autoComplete="off"
containerClassName="text-bunker-300 hover:border-mineshaft-400 border border-mineshaft-600 bg-bunker-600 px-2 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
)}
</div>
);
};
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<string, string> = {};
allFields.forEach((field) => {
values[field.key] = field.value ?? "";
});
return values;
}, [allFields]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
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 (
<form
className="flex flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex w-full flex-row items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Overrides</p>
</div>
<p className="text-sm text-bunker-300">Override specific environment variables.</p>
</div>
<div className="flex flex-row gap-2">
<Button
type="submit"
variant="outline_bg"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save Overrides
</Button>
</div>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search for keys, descriptions, and values..."
className="flex-1"
/>
<div className="flex flex-col">
{filteredData.map((group) => (
<GroupContainer group={group!} control={control} search={search} />
))}
</div>
</form>
);
};

View File

@ -0,0 +1 @@
export { EnvironmentPageForm } from "./EnvironmentPageForm";

View File

@ -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: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Environment",
link: linkOptions({
to: "/admin/environment"
})
}
]
};
}
});

View File

@ -439,39 +439,35 @@ export const ReviewAccessRequestModal = ({
</div>
) : (
<>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
className={twMerge("mr-2", bypassApproval ? "border-red/30 bg-red/10" : "")}
{isSoftEnforcement && request.isRequestedByCurrentUser && canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
className={twMerge("mr-2", bypassApproval ? "!border-red/30 !bg-red/10" : "")}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the policy"
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy
protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}

View File

@ -400,7 +400,7 @@ const Form = ({
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
className="flex-1"
>
<Select
isDisabled={isEditMode}
@ -419,6 +419,20 @@ const Form = ({
</FormControl>
)}
/>
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-1"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
@ -429,7 +443,7 @@ const Form = ({
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
className="flex-shrink"
>
<Input
{...field}
@ -443,20 +457,6 @@ const Form = ({
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
@ -467,35 +467,36 @@ const Form = ({
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
className="flex-1"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
className="flex-1"
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
@ -504,11 +505,11 @@ const Form = ({
</div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded border border-mineshaft-600 bg-mineshaft-900 p-2">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0 shadow-inner",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
@ -567,7 +568,7 @@ const Form = ({
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
className="flex-1"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
@ -597,7 +598,7 @@ const Form = ({
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
className="flex-1"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
@ -800,10 +801,15 @@ const Form = ({
</>
)}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
<Button
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
<Button onClick={() => onToggle(false)} colorSchema="secondary" variant="plain">
Close
</Button>
</div>

View File

@ -102,43 +102,73 @@ export const SecretApprovalRequestAction = ({
if (!hasMerged && status === "open") {
return (
<div className="flex w-full flex-col items-start justify-between py-4 transition-all">
<div className="flex items-center space-x-4 px-4">
<div
className={`flex items-center justify-center rounded-full ${isMergable ? "h-10 w-10 bg-green" : "h-11 w-11 bg-red-600"}`}
>
<FontAwesomeIcon
icon={isMergable ? faCheck : faXmark}
className={isMergable ? "text-lg text-black" : "text-2xl text-white"}
/>
<div className="flex w-full flex-col items-start justify-between py-4 text-mineshaft-100 transition-all">
<div className="flex w-full flex-col justify-between xl:flex-row xl:items-center">
<div className="mr-auto flex items-center space-x-4 px-4">
<div
className={`flex items-center justify-center rounded-full ${isMergable ? "h-8 w-8 bg-green" : "h-10 w-10 bg-red-600"}`}
>
<FontAwesomeIcon
icon={isMergable ? faCheck : faXmark}
className={isMergable ? "text-lg text-white" : "text-2xl text-white"}
/>
</div>
<span className="flex flex-col">
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
{isMergable ? "Good to merge" : "Merging is blocked"}
</p>
{!isMergable && (
<span className="inline-block text-xs text-mineshaft-300">
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
eligible reviewers.
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
)}
</span>
</div>
<span className="flex flex-col">
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
{isMergable ? "Good to merge" : "Merging is blocked"}
</p>
{!isMergable && (
<span className="inline-block text-xs text-bunker-200">
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
eligible reviewers.
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
</span>
<div className="mt-4 flex items-center justify-end space-x-2 px-4 xl:mt-0">
{canApprove || isSoftEnforcement ? (
<div className="flex items-center space-x-4">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
className="hover:border-red/60 hover:bg-red/10"
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
!(
(isMergable && canApprove) ||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="outline_bg"
>
Merge
</Button>
</div>
) : (
<div className="text-sm text-mineshaft-400">Only approvers can merge</div>
)}
</span>
</div>
</div>
{isSoftEnforcement && !isMergable && isBypasser && (
<div
className={`mt-4 w-full border-mineshaft-600 px-5 ${isMergable ? "border-t pb-2" : "border-y pb-4"}`}
>
<div className="mt-4 w-full border-t border-mineshaft-600 px-5">
<div className="mt-2 flex flex-col space-y-2 pt-2">
<Checkbox
onCheckedChange={(checked) => setByPassApproval(checked === true)}
isChecked={byPassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
className={twMerge("mr-2", byPassApproval ? "!border-red/30 !bg-red/10" : "")}
>
<span className="text-sm">
Merge without waiting for approval (bypass secret change policy)
@ -162,51 +192,18 @@ export const SecretApprovalRequestAction = ({
</div>
</div>
)}
<div className="mt-2 flex w-full items-center justify-end space-x-2 px-4">
{canApprove || isSoftEnforcement ? (
<div className="flex items-center space-x-4">
<Button
onClick={() => handleSecretApprovalStatusChange("close")}
isLoading={isStatusChanging}
variant="outline_bg"
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faClose} />}
className="hover:border-red/60 hover:bg-red/10"
>
Close request
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
isDisabled={
!(
(isMergable && canApprove) ||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
)
}
isLoading={isMerging}
onClick={handleSecretApprovalRequestMerge}
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
variant="solid"
>
Merge
</Button>
</div>
) : (
<div>Only approvers can merge</div>
)}
</div>
</div>
);
}
if (hasMerged && status === "close")
return (
<div className="flex w-full items-center justify-between rounded-md border border-primary/60 bg-primary/10">
<div className="flex items-start space-x-4 p-4">
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
<div className="flex w-full items-center justify-between rounded-md border border-green/60 bg-green/10">
<div className="flex items-start space-x-2 p-4">
<FontAwesomeIcon icon={faCheck} className="mt-0.5 text-xl text-green" />
<span className="flex flex-col">
Change request merged
<span className="inline-block text-xs text-bunker-200">
<span className="inline-block text-xs text-mineshaft-300">
Merged by {statusChangeByEmail}.
</span>
</span>
@ -215,26 +212,26 @@ export const SecretApprovalRequestAction = ({
);
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-start space-x-4">
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
<div className="flex w-full items-center justify-between rounded-md border border-yellow/60 bg-yellow/10">
<div className="flex items-start space-x-2 p-4">
<FontAwesomeIcon icon={faUserLock} className="mt-0.5 text-xl text-yellow" />
<span className="flex flex-col">
Secret approval has been closed
<span className="inline-block text-xs text-bunker-200">
<span className="inline-block text-xs text-mineshaft-300">
Closed by {statusChangeByEmail}
</span>
</span>
</div>
<div className="flex items-center space-x-6">
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
<Button
onClick={() => handleSecretApprovalStatusChange("open")}
isLoading={isStatusChanging}
variant="plain"
colorSchema="secondary"
className="mr-4 text-yellow/60 hover:text-yellow"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Reopen request
</Button>
</div>
);
};

View File

@ -3,11 +3,12 @@
/* eslint-disable no-nested-ternary */
import { useState } from "react";
import {
faCircleCheck,
faCircleXmark,
faExclamationTriangle,
faEye,
faEyeSlash,
faInfo,
faInfoCircle,
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -29,14 +30,14 @@ export type Props = {
};
const generateItemTitle = (op: CommitType) => {
let text = { label: "", color: "" };
if (op === CommitType.CREATE) text = { label: "create", color: "#60DD00" };
else if (op === CommitType.UPDATE) text = { label: "change", color: "#F8EB30" };
else text = { label: "deletion", color: "#F83030" };
let text = { label: "", className: "" };
if (op === CommitType.CREATE) text = { label: "create", className: "text-green-600" };
else if (op === CommitType.UPDATE) text = { label: "change", className: "text-yellow-600" };
else text = { label: "deletion", className: "text-red-600" };
return (
<div className="text-md pb-2 font-medium">
Request for <span style={{ color: text.color }}>secret {text.label}</span>
Request for <span className={text.className}>secret {text.label}</span>
</div>
);
};
@ -68,15 +69,15 @@ export const SecretApprovalRequestChangeItem = ({
<div className="flex items-center px-1 py-1">
<div className="flex-grow">{generateItemTitle(op)}</div>
{!hasMerged && isStale && (
<div className="flex items-center">
<FontAwesomeIcon icon={faInfo} className="text-sm text-primary-600" />
<span className="ml-2 text-xs">Secret has been changed(stale)</span>
<div className="flex items-center text-mineshaft-300">
<FontAwesomeIcon icon={faInfoCircle} className="text-xs" />
<span className="ml-1 text-xs">Secret has been changed (stale)</span>
</div>
)}
{hasMerged && hasConflict && (
<div className="flex items-center space-x-2 text-sm text-bunker-300">
<div className="flex items-center space-x-1 text-xs text-bunker-300">
<Tooltip content="Merge Conflict">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
<FontAwesomeIcon icon={faExclamationTriangle} className="text-xs text-red" />
</Tooltip>
<div>{generateConflictText(op)}</div>
</div>
@ -95,7 +96,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{secretVersion?.secretKey} </div>
<p className="max-w-lg break-words text-sm">{secretVersion?.secretKey}</p>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
@ -147,7 +148,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="max-h-[5rem] overflow-y-auto text-sm">
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
{secretVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
@ -186,15 +187,27 @@ export const SecretApprovalRequestChangeItem = ({
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.key}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.key}
</div>
</Tooltip>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.value}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tooltip>
</Tag>
</div>
))}
@ -215,13 +228,13 @@ export const SecretApprovalRequestChangeItem = ({
<div className="mb-4 flex flex-row justify-between">
<span className="text-md font-medium">New Secret</span>
<div className="rounded-full bg-green-600 px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
<FontAwesomeIcon icon={faCircleCheck} className="pr-1 text-white" />
New
</div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Key</div>
<div className="text-sm">{newVersion?.secretKey} </div>
<div className="max-w-md break-words text-sm">{newVersion?.secretKey} </div>
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Value</div>
@ -273,7 +286,7 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
<div className="max-h-[5rem] overflow-y-auto text-sm">
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
{newVersion?.secretComment || (
<span className="text-sm text-mineshaft-300">-</span>
)}{" "}
@ -281,15 +294,15 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-y-2">
{(newVersion?.tags?.length ?? 0) ? (
newVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
className="flex w-min items-center space-x-1.5 border border-mineshaft-500 bg-mineshaft-800"
key={`${newVersion.id}-${tagId}`}
>
<div
className="h-3 w-3 rounded-full"
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
@ -311,15 +324,27 @@ export const SecretApprovalRequestChangeItem = ({
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.key}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.key}
</div>
</Tooltip>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
<Tooltip
className="max-w-lg whitespace-normal break-words"
content={el.value}
>
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tooltip>
</Tag>
</div>
))}

View File

@ -3,12 +3,12 @@ import { Controller, useForm } from "react-hook-form";
import {
faAngleDown,
faArrowLeft,
faCheckCircle,
faCircle,
faBan,
faCheck,
faCodeBranch,
faComment,
faFolder,
faXmarkCircle
faHourglass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
EmptyState,
FormControl,
GenericFieldLabel,
IconButton,
TextArea,
Tooltip
@ -81,10 +82,10 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
return <FontAwesomeIcon icon={faCheck} size="xs" className="text-green" />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
return <FontAwesomeIcon icon={faBan} size="xs" className="text-red" />;
return <FontAwesomeIcon icon={faHourglass} size="xs" className="text-yellow" />;
};
type Props = {
@ -223,8 +224,8 @@ export const SecretApprovalRequestChanges = ({
const hasMerged = secretApprovalRequestDetails?.hasMerged;
return (
<div className="flex space-x-6">
<div className="flex-grow">
<div className="flex flex-col space-x-6 lg:flex-row">
<div className="flex-1 lg:max-w-[calc(100%-17rem)]">
<div className="sticky top-0 z-20 flex items-center space-x-4 bg-bunker-800 pb-6 pt-2">
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
<FontAwesomeIcon icon={faArrowLeft} />
@ -242,17 +243,17 @@ export const SecretApprovalRequestChanges = ({
: secretApprovalRequestDetails.status}
</span>
</div>
<div className="flex-grow flex-col">
<div className="-mt-0.5 flex-grow flex-col">
<div className="text-xl">
{generateCommitText(
secretApprovalRequestDetails.commits,
secretApprovalRequestDetails.isReplicated
)}
</div>
<div className="flex items-center space-x-2 text-xs text-gray-400">
<span className="-mt-1 flex items-center space-x-2 text-xs text-gray-400">
By {secretApprovalRequestDetails?.committerUser?.firstName} (
{secretApprovalRequestDetails?.committerUser?.email})
</div>
</span>
</div>
{!hasMerged &&
secretApprovalRequestDetails.status === "open" &&
@ -262,7 +263,10 @@ export const SecretApprovalRequestChanges = ({
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
>
<DropdownMenuTrigger asChild>
<Button rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}>
<Button
colorSchema="secondary"
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
>
Review
</Button>
</DropdownMenuTrigger>
@ -279,82 +283,87 @@ export const SecretApprovalRequestChanges = ({
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-40 border border-mineshaft-600 bg-bunker-800"
className="text-md mt-2 h-40 border border-mineshaft-600 bg-mineshaft-800 placeholder:text-mineshaft-400"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="mb-4 space-y-2"
aria-label="Status"
<div className="flex justify-between">
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error)}
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-red focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<div className="flex justify-end">
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
aria-label="Status"
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-400 text-green focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-400 text-red focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<Button
type="submit"
isLoading={isApproving || isRejecting || isSubmitting}
variant="outline_bg"
className="mt-auto h-min"
>
Submit Review
</Button>
@ -371,14 +380,14 @@ export const SecretApprovalRequestChanges = ({
<div className="text-sm text-bunker-300">
A secret import in
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{secretApprovalRequestDetails?.environment}
</p>
<div className="mr-2 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={approvalSecretPath}>
<p
@ -391,14 +400,14 @@ export const SecretApprovalRequestChanges = ({
</div>
has pending changes to be accepted from its source at{" "}
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{replicatedImport?.importEnv?.slug}
</p>
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={replicatedImport?.importPath}>
<p
@ -415,14 +424,14 @@ export const SecretApprovalRequestChanges = ({
<div className="text-sm text-bunker-300">
<p className="inline">Secret(s) in</p>
<p
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
style={{ padding: "2px 4px" }}
>
{secretApprovalRequestDetails?.environment}
</p>
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
</p>
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
<p
@ -463,7 +472,7 @@ export const SecretApprovalRequestChanges = ({
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4"
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4 text-sm text-mineshaft-100"
key={`required-approver-${requiredApprover.userId}`}
>
<div>
@ -477,14 +486,16 @@ export const SecretApprovalRequestChanges = ({
{reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"}
</span>{" "}
the request on{" "}
{format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
{format(
new Date(secretApprovalRequestDetails.createdAt),
"MM/dd/yyyy h:mm:ss aa"
)}
.
</div>
{reviewer?.comment && (
<FormControl label="Comment" className="mb-0 mt-4">
<TextArea value={reviewer.comment} isDisabled reSize="none">
{reviewer?.comment && reviewer.comment}
</TextArea>
</FormControl>
<GenericFieldLabel label="Comment" className="mt-2 max-w-4xl break-words">
{reviewer?.comment && reviewer.comment}
</GenericFieldLabel>
)}
</div>
);
@ -505,7 +516,7 @@ export const SecretApprovalRequestChanges = ({
/>
</div>
</div>
<div className="sticky top-0 w-1/5 cursor-default pt-4" style={{ minWidth: "240px" }}>
<div className="sticky top-0 z-[51] w-1/5 cursor-default pt-2" style={{ minWidth: "240px" }}>
<div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers
@ -526,17 +537,17 @@ export const SecretApprovalRequestChanges = ({
requiredApprover.lastName || ""
}`}
>
<span>{requiredApprover?.email} </span>
<span>{requiredApprover?.email}</span>
</Tooltip>
<span className="text-red">*</span>
</div>
<div>
{reviewer?.comment && (
<Tooltip content={reviewer.comment}>
<Tooltip className="max-w-lg break-words" content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
className="mr-1.5 text-mineshaft-300"
/>
</Tooltip>
)}

View File

@ -7,8 +7,13 @@ type Props = {
export const OnePassSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { vaultId }
destinationConfig: { vaultId, valueLabel }
} = secretSync;
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
return (
<>
<GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>
<GenericFieldLabel label="Value Key">{valueLabel || "value"}</GenericFieldLabel>
</>
);
};

View File

@ -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'
@ -607,6 +608,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',
@ -2353,6 +2360,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'
@ -4484,6 +4498,7 @@ interface adminLayoutRouteChildren {
adminAuthenticationPageRouteRoute: typeof adminAuthenticationPageRouteRoute
adminCachingPageRouteRoute: typeof adminCachingPageRouteRoute
adminEncryptionPageRouteRoute: typeof adminEncryptionPageRouteRoute
adminEnvironmentPageRouteRoute: typeof adminEnvironmentPageRouteRoute
adminIntegrationsPageRouteRoute: typeof adminIntegrationsPageRouteRoute
adminMachineIdentitiesResourcesPageRouteRoute: typeof adminMachineIdentitiesResourcesPageRouteRoute
adminOrganizationResourcesPageRouteRoute: typeof adminOrganizationResourcesPageRouteRoute
@ -4495,6 +4510,7 @@ const adminLayoutRouteChildren: adminLayoutRouteChildren = {
adminAuthenticationPageRouteRoute: adminAuthenticationPageRouteRoute,
adminCachingPageRouteRoute: adminCachingPageRouteRoute,
adminEncryptionPageRouteRoute: adminEncryptionPageRouteRoute,
adminEnvironmentPageRouteRoute: adminEnvironmentPageRouteRoute,
adminIntegrationsPageRouteRoute: adminIntegrationsPageRouteRoute,
adminMachineIdentitiesResourcesPageRouteRoute:
adminMachineIdentitiesResourcesPageRouteRoute,
@ -4697,6 +4713,7 @@ export interface FileRoutesByFullPath {
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
'/admin/caching': typeof adminCachingPageRouteRoute
'/admin/encryption': typeof adminEncryptionPageRouteRoute
'/admin/environment': typeof adminEnvironmentPageRouteRoute
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
'/cert-manager/$projectId': typeof certManagerLayoutRouteWithChildren
'/kms/$projectId': typeof kmsLayoutRouteWithChildren
@ -4918,6 +4935,7 @@ export interface FileRoutesByTo {
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
'/admin/caching': typeof adminCachingPageRouteRoute
'/admin/encryption': typeof adminEncryptionPageRouteRoute
'/admin/environment': typeof adminEnvironmentPageRouteRoute
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
'/cert-manager/$projectId': typeof certManagerLayoutRouteWithChildren
'/kms/$projectId': typeof kmsLayoutRouteWithChildren
@ -5139,6 +5157,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/cert-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutCertManagerProjectIdRouteWithChildren
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutKmsProjectIdRouteWithChildren
@ -5371,6 +5390,7 @@ export interface FileRouteTypes {
| '/admin/authentication'
| '/admin/caching'
| '/admin/encryption'
| '/admin/environment'
| '/admin/integrations'
| '/cert-manager/$projectId'
| '/kms/$projectId'
@ -5591,6 +5611,7 @@ export interface FileRouteTypes {
| '/admin/authentication'
| '/admin/caching'
| '/admin/encryption'
| '/admin/environment'
| '/admin/integrations'
| '/cert-manager/$projectId'
| '/kms/$projectId'
@ -5810,6 +5831,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/cert-manager/$projectId'
| '/_authenticate/_inject-org-details/_org-layout/kms/$projectId'
@ -6267,6 +6289,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",
@ -6309,6 +6332,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"

View File

@ -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"),