Merge remote-tracking branch 'origin/main' into fix/secretImportsPermissionIssue

This commit is contained in:
Carlos Monastyrski
2025-09-05 15:52:19 -03:00
116 changed files with 9716 additions and 5715 deletions
+41 -3
View File
@@ -25,6 +25,7 @@
"@fastify/multipart": "8.3.1", "@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0", "@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0", "@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0", "@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0", "@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
@@ -8044,6 +8045,42 @@
"toad-cache": "^3.3.0" "toad-cache": "^3.3.0"
} }
}, },
"node_modules/@fastify/reply-from": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.0.0",
"end-of-stream": "^1.4.4",
"fast-content-type-parse": "^1.1.0",
"fast-querystring": "^1.0.0",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.7.0",
"undici": "^5.19.1"
}
},
"node_modules/@fastify/reply-from/node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/reply-from/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/@fastify/request-context": { "node_modules/@fastify/request-context": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz",
@@ -29330,9 +29367,10 @@
} }
}, },
"node_modules/toad-cache": { "node_modules/toad-cache": {
"version": "3.3.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
+1
View File
@@ -145,6 +145,7 @@
"@fastify/multipart": "8.3.1", "@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0", "@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0", "@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0", "@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0", "@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
+5 -2
View File
@@ -1,6 +1,6 @@
import "fastify"; import "fastify";
import { Redis } from "ioredis"; import { Cluster, Redis } from "ioredis";
import { TUsers } from "@app/db/schemas"; import { TUsers } from "@app/db/schemas";
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
@@ -83,6 +83,7 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service";
import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgServiceFactory } from "@app/services/org/org-service";
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
@@ -161,6 +162,7 @@ declare module "fastify" {
}; };
// identity injection. depending on which kinda of token the information is filled in auth // identity injection. depending on which kinda of token the information is filled in auth
auth: TAuthMode; auth: TAuthMode;
shouldForwardWritesToPrimaryInstance: boolean;
permission: { permission: {
authMethod: ActorAuthMethod; authMethod: ActorAuthMethod;
type: ActorType; type: ActorType;
@@ -194,7 +196,7 @@ declare module "fastify" {
} }
interface FastifyInstance { interface FastifyInstance {
redis: Redis; redis: Redis | Cluster;
services: { services: {
login: TAuthLoginFactory; login: TAuthLoginFactory;
password: TAuthPasswordFactory; password: TAuthPasswordFactory;
@@ -303,6 +305,7 @@ declare module "fastify" {
bus: TEventBusService; bus: TEventBusService;
sse: TServerSentEventsService; sse: TServerSentEventsService;
identityAuthTemplate: TIdentityAuthTemplateServiceFactory; identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer
@@ -30,7 +30,7 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const CassandraProvider = (): TDynamicProviderFns => { export const CassandraProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs);
const hostIps = await Promise.all( await Promise.all(
providerInputs.host providerInputs.host
.split(",") .split(",")
.filter(Boolean) .filter(Boolean)
@@ -48,10 +48,10 @@ export const CassandraProvider = (): TDynamicProviderFns => {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
return { ...providerInputs, hostIps }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema> & { hostIps: string[] }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema>) => {
const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined;
const client = new cassandra.Client({ const client = new cassandra.Client({
sslOptions, sslOptions,
@@ -64,7 +64,7 @@ export const CassandraProvider = (): TDynamicProviderFns => {
}, },
keyspace: providerInputs.keyspace, keyspace: providerInputs.keyspace,
localDataCenter: providerInputs?.localDataCenter, localDataCenter: providerInputs?.localDataCenter,
contactPoints: providerInputs.hostIps contactPoints: providerInputs.host.split(",")
}); });
return client; return client;
}; };
@@ -28,14 +28,14 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const ElasticSearchProvider = (): TDynamicProviderFns => { export const ElasticSearchProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => {
const connection = new ElasticSearchClient({ const connection = new ElasticSearchClient({
node: { node: {
url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`), url: new URL(`${providerInputs.host}:${providerInputs.port}`),
...(providerInputs.ca && { ...(providerInputs.ca && {
ssl: { ssl: {
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -28,15 +28,15 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const MongoDBProvider = (): TDynamicProviderFns => { export const MongoDBProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema>) => {
const isSrv = !providerInputs.port; const isSrv = !providerInputs.port;
const uri = isSrv const uri = isSrv
? `mongodb+srv://${providerInputs.hostIp}` ? `mongodb+srv://${providerInputs.host}`
: `mongodb://${providerInputs.hostIp}:${providerInputs.port}`; : `mongodb://${providerInputs.host}:${providerInputs.port}`;
const client = new MongoClient(uri, { const client = new MongoClient(uri, {
auth: { auth: {
@@ -87,13 +87,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa
export const RabbitMqProvider = (): TDynamicProviderFns => { export const RabbitMqProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema>) => {
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`, baseURL: `${providerInputs.host}:${providerInputs.port}/api`,
auth: { auth: {
username: providerInputs.username, username: providerInputs.username,
password: providerInputs.password password: providerInputs.password
@@ -36,7 +36,7 @@ export const SapAseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, { validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password"].includes(val) allowedExpressions: (val) => ["username", "password"].includes(val)
}); });
@@ -45,16 +45,13 @@ export const SapAseProvider = (): TDynamicProviderFns => {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
} }
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async ( const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapAseSchema>, useMaster?: boolean) => {
providerInputs: z.infer<typeof DynamicSecretSapAseSchema> & { hostIp: string },
useMaster?: boolean
) => {
const connectionString = const connectionString =
`DRIVER={FreeTDS};` + `DRIVER={FreeTDS};` +
`SERVER=${providerInputs.hostIp};` + `SERVER=${providerInputs.host};` +
`PORT=${providerInputs.port};` + `PORT=${providerInputs.port};` +
`DATABASE=${useMaster ? "master" : providerInputs.database};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` +
`UID=${providerInputs.username};` + `UID=${providerInputs.username};` +
@@ -37,7 +37,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, { validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) allowedExpressions: (val) => ["username", "password", "expiration"].includes(val)
}); });
@@ -49,12 +49,12 @@ export const SapHanaProvider = (): TDynamicProviderFns => {
validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, { validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapHanaSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapHanaSchema>) => {
const client = hdb.createClient({ const client = hdb.createClient({
host: providerInputs.hostIp, host: providerInputs.host,
port: providerInputs.port, port: providerInputs.port,
user: providerInputs.username, user: providerInputs.username,
password: providerInputs.password, password: providerInputs.password,
@@ -150,8 +150,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
return { ...providerInputs, hostIp }; return { ...providerInputs, hostIp };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema> & { hostIp: string }) => {
const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const ssl = providerInputs.ca
? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host }
: undefined;
const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL;
const db = knex({ const db = knex({
@@ -159,7 +161,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
connection: { connection: {
database: providerInputs.database, database: providerInputs.database,
port: providerInputs.port, port: providerInputs.port,
host: providerInputs.host, host: providerInputs.client === SqlProviders.Postgres ? providerInputs.hostIp : providerInputs.host,
user: providerInputs.username, user: providerInputs.username,
password: providerInputs.password, password: providerInputs.password,
ssl, ssl,
@@ -209,8 +211,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
const validateConnection = async (inputs: unknown) => { const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
let isConnected = false; let isConnected = false;
const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host }); const db = await $getClient({ ...providerInputs, port, host, hostIp: providerInputs.hostIp });
// oracle needs from keyword // oracle needs from keyword
const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1";
@@ -1,11 +1,11 @@
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { z } from "zod"; import { z } from "zod";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { BusEventSchema, TopicName } from "./types"; import { BusEventSchema, TopicName } from "./types";
export const eventBusFactory = (redis: Redis) => { export const eventBusFactory = (redis: Redis | Cluster) => {
const publisher = redis.duplicate(); const publisher = redis.duplicate();
// Duplicate the publisher to create a subscriber. // Duplicate the publisher to create a subscriber.
// This is necessary because Redis does not allow a single connection to both publish and subscribe. // This is necessary because Redis does not allow a single connection to both publish and subscribe.
@@ -1,6 +1,6 @@
/* eslint-disable no-continue */ /* eslint-disable no-continue */
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { KeyStorePrefixes } from "@app/keystore/keystore"; import { KeyStorePrefixes } from "@app/keystore/keystore";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -12,7 +12,7 @@ import { BusEvent, RegisteredEvent } from "./types";
const AUTH_REFRESH_INTERVAL = 60 * 1000; const AUTH_REFRESH_INTERVAL = 60 * 1000;
const HEART_BEAT_INTERVAL = 15 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000;
export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { export const sseServiceFactory = (bus: TEventBusService, redis: Redis | Cluster) => {
const clients = new Set<EventStreamClient>(); const clients = new Set<EventStreamClient>();
const heartbeatInterval = setInterval(() => { const heartbeatInterval = setInterval(() => {
@@ -3,7 +3,7 @@ import { Readable } from "node:stream";
import { MongoAbility, PureAbility } from "@casl/ability"; import { MongoAbility, PureAbility } from "@casl/ability";
import { MongoQuery } from "@ucast/mongo2js"; import { MongoQuery } from "@ucast/mongo2js";
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
@@ -65,7 +65,7 @@ export type EventStreamClient = {
matcher: PureAbility; matcher: PureAbility;
}; };
export function createEventStreamClient(redis: Redis, options: IEventStreamClientOpts): EventStreamClient { export function createEventStreamClient(redis: Redis | Cluster, options: IEventStreamClientOpts): EventStreamClient {
const rules = options.registered.map((r) => { const rules = options.registered.map((r) => {
const secretPath = r.conditions?.secretPath; const secretPath = r.conditions?.secretPath;
const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath; const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath;
@@ -722,6 +722,16 @@ export const licenseServiceFactory = ({
await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId));
}; };
const getCustomerId = () => {
if (!selfHostedLicense) return "unknown";
return selfHostedLicense?.customerId;
};
const getLicenseId = () => {
if (!selfHostedLicense) return "unknown";
return selfHostedLicense?.licenseId;
};
return { return {
generateOrgCustomerId, generateOrgCustomerId,
removeOrgCustomer, removeOrgCustomer,
@@ -736,6 +746,8 @@ export const licenseServiceFactory = ({
return onPremFeatures; return onPremFeatures;
}, },
getPlan, getPlan,
getCustomerId,
getLicenseId,
invalidateGetPlan, invalidateGetPlan,
updateSubscriptionOrgMemberCount, updateSubscriptionOrgMemberCount,
refreshPlan, refreshPlan,
+1
View File
@@ -38,6 +38,7 @@ export const KeyStorePrefixes = {
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const,
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
`secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const,
+19 -3
View File
@@ -37,6 +37,8 @@ const envSchema = z
.default("false") .default("false")
.transform((el) => el === "true"), .transform((el) => el === "true"),
REDIS_URL: zpStr(z.string().optional()), REDIS_URL: zpStr(z.string().optional()),
REDIS_USERNAME: zpStr(z.string().optional()),
REDIS_PASSWORD: zpStr(z.string().optional()),
REDIS_SENTINEL_HOSTS: zpStr( REDIS_SENTINEL_HOSTS: zpStr(
z z
.string() .string()
@@ -49,6 +51,12 @@ const envSchema = z
REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"),
REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")),
REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")),
REDIS_CLUSTER_HOSTS: zpStr(
z
.string()
.optional()
.describe("Comma-separated list of Redis Cluster host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380")
),
HOST: zpStr(z.string().default("localhost")), HOST: zpStr(z.string().default("localhost")),
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default(
`postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`
@@ -218,6 +226,8 @@ const envSchema = z
), ),
PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7), PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7),
INFISICAL_PRIMARY_INSTANCE_URL: zpStr(z.string().optional()),
// HSM // HSM
HSM_LIB_PATH: zpStr(z.string().optional()), HSM_LIB_PATH: zpStr(z.string().optional()),
HSM_PIN: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()),
@@ -335,8 +345,8 @@ const envSchema = z
"Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined."
) )
.refine( .refine(
(data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS),
"Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined."
) )
.transform((data) => ({ .transform((data) => ({
...data, ...data,
@@ -346,7 +356,7 @@ const envSchema = z
: undefined, : undefined,
isCloud: Boolean(data.LICENSE_SERVER_KEY), isCloud: Boolean(data.LICENSE_SERVER_KEY),
isSmtpConfigured: Boolean(data.SMTP_HOST), isSmtpConfigured: Boolean(data.SMTP_HOST),
isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS || data.REDIS_CLUSTER_HOSTS),
isDevelopmentMode: data.NODE_ENV === "development", isDevelopmentMode: data.NODE_ENV === "development",
isTestMode: data.NODE_ENV === "test", isTestMode: data.NODE_ENV === "test",
isRotationDevelopmentMode: isRotationDevelopmentMode:
@@ -361,6 +371,12 @@ const envSchema = z
const [host, port] = el.trim().split(":"); const [host, port] = el.trim().split(":");
return { host: host.trim(), port: Number(port.trim()) }; return { host: host.trim(), port: Number(port.trim()) };
}), }),
REDIS_CLUSTER_HOSTS: data.REDIS_CLUSTER_HOSTS?.trim()
?.split(",")
.map((el) => {
const [host, port] = el.trim().split(":");
return { host: host.trim(), port: Number(port.trim()) };
}),
isSecretScanningConfigured: isSecretScanningConfigured:
Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_GIT_APP_ID) &&
Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) &&
+17 -1
View File
@@ -2,6 +2,11 @@ import { Redis } from "ioredis";
export type TRedisConfigKeys = Partial<{ export type TRedisConfigKeys = Partial<{
REDIS_URL: string; REDIS_URL: string;
REDIS_USERNAME: string;
REDIS_PASSWORD: string;
REDIS_CLUSTER_HOSTS: { host: string; port: number }[];
REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; REDIS_SENTINEL_HOSTS: { host: string; port: number }[];
REDIS_SENTINEL_MASTER_NAME: string; REDIS_SENTINEL_MASTER_NAME: string;
REDIS_SENTINEL_ENABLE_TLS: boolean; REDIS_SENTINEL_ENABLE_TLS: boolean;
@@ -12,6 +17,15 @@ export type TRedisConfigKeys = Partial<{
export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => {
if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null });
if (cfg.REDIS_CLUSTER_HOSTS) {
return new Redis.Cluster(cfg.REDIS_CLUSTER_HOSTS, {
redisOptions: {
username: cfg.REDIS_USERNAME,
password: cfg.REDIS_PASSWORD
}
});
}
return new Redis({ return new Redis({
// refine at tope will catch this case // refine at tope will catch this case
sentinels: cfg.REDIS_SENTINEL_HOSTS!, sentinels: cfg.REDIS_SENTINEL_HOSTS!,
@@ -19,6 +33,8 @@ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => {
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, sentinelUsername: cfg.REDIS_SENTINEL_USERNAME,
sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD,
enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS,
username: cfg.REDIS_USERNAME,
password: cfg.REDIS_PASSWORD
}); });
}; };
+4
View File
@@ -415,6 +415,7 @@ export const queueServiceFactory = (
redisCfg: TRedisConfigKeys, redisCfg: TRedisConfigKeys,
{ dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string }
): TQueueServiceFactory => { ): TQueueServiceFactory => {
const isClusterMode = Boolean(redisCfg?.REDIS_CLUSTER_HOSTS);
const connection = buildRedisFromConfig(redisCfg); const connection = buildRedisFromConfig(redisCfg);
const queueContainer = {} as Record< const queueContainer = {} as Record<
QueueName, QueueName,
@@ -457,6 +458,8 @@ export const queueServiceFactory = (
} }
queueContainer[name] = new Queue(name as string, { queueContainer[name] = new Queue(name as string, {
// ref: docs.bullmq.io/bull/patterns/redis-cluster
prefix: isClusterMode ? `{${name}}` : undefined,
...queueSettings, ...queueSettings,
...(crypto.isFipsModeEnabled() ...(crypto.isFipsModeEnabled()
? { ? {
@@ -472,6 +475,7 @@ export const queueServiceFactory = (
const appCfg = getConfig(); const appCfg = getConfig();
if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) {
workerContainer[name] = new Worker(name, jobFn, { workerContainer[name] = new Worker(name, jobFn, {
prefix: isClusterMode ? `{${name}}` : undefined,
...queueSettings, ...queueSettings,
...(crypto.isFipsModeEnabled() ...(crypto.isFipsModeEnabled()
? { ? {
+3 -2
View File
@@ -12,7 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
import ratelimiter from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit";
import { fastifyRequestContext } from "@fastify/request-context"; import { fastifyRequestContext } from "@fastify/request-context";
import fastify from "fastify"; import fastify from "fastify";
import { Redis } from "ioredis"; import { Cluster, Redis } from "ioredis";
import { Knex } from "knex"; import { Knex } from "knex";
import { HsmModule } from "@app/ee/services/hsm/hsm-types"; import { HsmModule } from "@app/ee/services/hsm/hsm-types";
@@ -43,7 +43,7 @@ type TMain = {
queue: TQueueServiceFactory; queue: TQueueServiceFactory;
keyStore: TKeyStoreFactory; keyStore: TKeyStoreFactory;
hsmModule: HsmModule; hsmModule: HsmModule;
redis: Redis; redis: Redis | Cluster;
envConfig: TEnvConfig; envConfig: TEnvConfig;
superAdminDAL: TSuperAdminDALFactory; superAdminDAL: TSuperAdminDALFactory;
}; };
@@ -76,6 +76,7 @@ export const main = async ({
server.setValidatorCompiler(validatorCompiler); server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler); server.setSerializerCompiler(serializerCompiler);
// @ts-expect-error akhilmhdh: even on setting it fastify as Redis | Cluster it's throwing error
server.decorate("redis", redis); server.decorate("redis", redis);
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => {
try { try {
+107 -100
View File
@@ -107,110 +107,117 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
}; };
// ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity). // ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity).
export const injectIdentity = fp(async (server: FastifyZodProvider) => { export const injectIdentity = fp(
server.decorateRequest("auth", null); async (server: FastifyZodProvider, opt: { shouldForwardWritesToPrimaryInstance?: boolean }) => {
server.addHook("onRequest", async (req) => { server.decorateRequest("auth", null);
const appCfg = getConfig(); server.decorateRequest("shouldForwardWritesToPrimaryInstance", Boolean(opt.shouldForwardWritesToPrimaryInstance));
server.addHook("onRequest", async (req) => {
const appCfg = getConfig();
if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { if (opt.shouldForwardWritesToPrimaryInstance && req.method !== "GET") {
return; return;
}
// Authentication is handled on a route-level here.
if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
return;
}
const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET);
if (!authMode) return;
switch (authMode) {
case AuthMode.JWT: {
const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token);
requestContext.set("orgId", orgId);
req.auth = {
authMode: AuthMode.JWT,
user,
userId: user.id,
tokenVersionId,
actor,
orgId: orgId as string,
authMethod: token.authMethod,
isMfaVerified: token.isMfaVerified,
token
};
break;
} }
case AuthMode.IDENTITY_ACCESS_TOKEN: {
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) {
const serverCfg = await getServerCfg(); return;
requestContext.set("orgId", identity.orgId); }
req.auth = {
authMode: AuthMode.IDENTITY_ACCESS_TOKEN, // Authentication is handled on a route-level here.
actor, if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
orgId: identity.orgId, return;
identityId: identity.identityId, }
identityName: identity.name,
authMethod: null, const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET);
isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId),
token if (!authMode) return;
};
if (token?.identityAuth?.oidc) { switch (authMode) {
requestContext.set("identityAuthInfo", { case AuthMode.JWT: {
identityId: identity.identityId, const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token);
oidc: token?.identityAuth?.oidc requestContext.set("orgId", orgId);
}); req.auth = {
authMode: AuthMode.JWT,
user,
userId: user.id,
tokenVersionId,
actor,
orgId: orgId as string,
authMethod: token.authMethod,
isMfaVerified: token.isMfaVerified,
token
};
break;
} }
if (token?.identityAuth?.kubernetes) { case AuthMode.IDENTITY_ACCESS_TOKEN: {
requestContext.set("identityAuthInfo", { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp);
const serverCfg = await getServerCfg();
requestContext.set("orgId", identity.orgId);
req.auth = {
authMode: AuthMode.IDENTITY_ACCESS_TOKEN,
actor,
orgId: identity.orgId,
identityId: identity.identityId, identityId: identity.identityId,
kubernetes: token?.identityAuth?.kubernetes identityName: identity.name,
}); authMethod: null,
isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId),
token
};
if (token?.identityAuth?.oidc) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
oidc: token?.identityAuth?.oidc
});
}
if (token?.identityAuth?.kubernetes) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
kubernetes: token?.identityAuth?.kubernetes
});
}
if (token?.identityAuth?.aws) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
aws: token?.identityAuth?.aws
});
}
break;
} }
if (token?.identityAuth?.aws) { case AuthMode.SERVICE_TOKEN: {
requestContext.set("identityAuthInfo", { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token);
identityId: identity.identityId, requestContext.set("orgId", serviceToken.orgId);
aws: token?.identityAuth?.aws req.auth = {
}); orgId: serviceToken.orgId,
authMode: AuthMode.SERVICE_TOKEN as const,
serviceToken,
serviceTokenId: serviceToken.id,
actor,
authMethod: null,
token
};
break;
} }
break; case AuthMode.API_KEY: {
const user = await server.services.apiKey.fnValidateApiKey(token as string);
req.auth = {
authMode: AuthMode.API_KEY as const,
userId: user.id,
actor,
user,
orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon!
authMethod: null,
token: token as string
};
break;
}
case AuthMode.SCIM_TOKEN: {
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
requestContext.set("orgId", orgId);
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null };
break;
}
default:
throw new BadRequestError({ message: "Invalid token strategy provided" });
} }
case AuthMode.SERVICE_TOKEN: { });
const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); }
requestContext.set("orgId", serviceToken.orgId); );
req.auth = {
orgId: serviceToken.orgId,
authMode: AuthMode.SERVICE_TOKEN as const,
serviceToken,
serviceTokenId: serviceToken.id,
actor,
authMethod: null,
token
};
break;
}
case AuthMode.API_KEY: {
const user = await server.services.apiKey.fnValidateApiKey(token as string);
req.auth = {
authMode: AuthMode.API_KEY as const,
userId: user.id,
actor,
user,
orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon!
authMethod: null,
token: token as string
};
break;
}
case AuthMode.SCIM_TOKEN: {
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
requestContext.set("orgId", orgId);
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null };
break;
}
default:
throw new BadRequestError({ message: "Invalid token strategy provided" });
}
});
});
@@ -10,6 +10,10 @@ interface TAuthOptions {
export const verifyAuth = export const verifyAuth =
<T extends FastifyRequest>(authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) => <T extends FastifyRequest>(authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) =>
(req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => { (req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => {
if (req.shouldForwardWritesToPrimaryInstance && req.method !== "GET") {
return done();
}
if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array"); if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array");
if (!req.auth) throw new UnauthorizedError({ message: "Token missing" }); if (!req.auth) throw new UnauthorizedError({ message: "Token missing" });
@@ -0,0 +1,14 @@
import replyFrom from "@fastify/reply-from";
import fp from "fastify-plugin";
export const forwardWritesToPrimary = fp(async (server, opt: { primaryUrl: string }) => {
await server.register(replyFrom, {
base: opt.primaryUrl
});
server.addHook("preValidation", async (request, reply) => {
if (request.url.startsWith("/api") && ["POST", "PUT", "DELETE", "PATCH"].includes(request.method)) {
return reply.from(request.url);
}
});
});
+20 -2
View File
@@ -291,6 +291,8 @@ import { TSmtpService } from "@app/services/smtp/smtp-service";
import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue"; import { invalidateCacheQueueFactory } from "@app/services/super-admin/invalidate-cache-queue";
import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal";
import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal";
import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue"; import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service";
@@ -310,6 +312,7 @@ import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege";
import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectIdentity } from "../plugins/auth/inject-identity";
import { injectPermission } from "../plugins/auth/inject-permission"; import { injectPermission } from "../plugins/auth/inject-permission";
import { injectRateLimits } from "../plugins/inject-rate-limits"; import { injectRateLimits } from "../plugins/inject-rate-limits";
import { forwardWritesToPrimary } from "../plugins/primary-forwarding-mode";
import { registerV1Routes } from "./v1"; import { registerV1Routes } from "./v1";
import { initializeOauthConfigSync } from "./v1/sso-router"; import { initializeOauthConfigSync } from "./v1/sso-router";
import { registerV2Routes } from "./v2"; import { registerV2Routes } from "./v2";
@@ -385,6 +388,7 @@ export const registerRoutes = async (
const reminderRecipientDAL = reminderRecipientDALFactory(db); const reminderRecipientDAL = reminderRecipientDALFactory(db);
const integrationDAL = integrationDALFactory(db); const integrationDAL = integrationDALFactory(db);
const offlineUsageReportDAL = offlineUsageReportDALFactory(db);
const integrationAuthDAL = integrationAuthDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db);
const webhookDAL = webhookDALFactory(db); const webhookDAL = webhookDALFactory(db);
const serviceTokenDAL = serviceTokenDALFactory(db); const serviceTokenDAL = serviceTokenDALFactory(db);
@@ -842,7 +846,14 @@ export const registerRoutes = async (
licenseService, licenseService,
kmsService, kmsService,
microsoftTeamsService, microsoftTeamsService,
invalidateCacheQueue invalidateCacheQueue,
smtpService,
tokenService
});
const offlineUsageReportService = offlineUsageReportServiceFactory({
offlineUsageReportDAL,
licenseService
}); });
const orgAdminService = orgAdminServiceFactory({ const orgAdminService = orgAdminServiceFactory({
@@ -2003,6 +2014,7 @@ export const registerRoutes = async (
apiKey: apiKeyService, apiKey: apiKeyService,
authToken: tokenService, authToken: tokenService,
superAdmin: superAdminService, superAdmin: superAdminService,
offlineUsageReport: offlineUsageReportService,
project: projectService, project: projectService,
projectMembership: projectMembershipService, projectMembership: projectMembershipService,
projectKey: projectKeyService, projectKey: projectKeyService,
@@ -2135,8 +2147,14 @@ export const registerRoutes = async (
user: userDAL, user: userDAL,
kmipClient: kmipClientDAL kmipClient: kmipClientDAL
}); });
const shouldForwardWritesToPrimaryInstance = Boolean(envConfig.INFISICAL_PRIMARY_INSTANCE_URL);
if (shouldForwardWritesToPrimaryInstance) {
logger.info(`Infisical primary instance is configured: ${envConfig.INFISICAL_PRIMARY_INSTANCE_URL}`);
await server.register(injectIdentity, { userDAL, serviceTokenDAL }); await server.register(forwardWritesToPrimary, { primaryUrl: envConfig.INFISICAL_PRIMARY_INSTANCE_URL as string });
}
await server.register(injectIdentity, { shouldForwardWritesToPrimaryInstance });
await server.register(injectAssumePrivilege); await server.register(injectAssumePrivilege);
await server.register(injectPermission); await server.register(injectPermission);
await server.register(injectRateLimits); await server.register(injectRateLimits);
+124 -3
View File
@@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -53,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
defaultAuthOrgAuthMethod: z.string().nullish(), defaultAuthOrgAuthMethod: z.string().nullish(),
isSecretScanningDisabled: z.boolean(), isSecretScanningDisabled: z.boolean(),
kubernetesAutoFetchServiceAccountToken: z.boolean(), kubernetesAutoFetchServiceAccountToken: z.boolean(),
paramsFolderSecretDetectionEnabled: z.boolean() paramsFolderSecretDetectionEnabled: z.boolean(),
isOfflineUsageReportsEnabled: z.boolean()
}) })
}) })
} }
@@ -69,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isMigrationModeOn: serverEnvs.MAINTENANCE_MODE,
isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING,
kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN,
paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED,
isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE
} }
}; };
} }
@@ -215,7 +218,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}), }),
membershipId: z.string(), membershipId: z.string(),
role: z.string(), role: z.string(),
roleId: z.string().nullish() roleId: z.string().nullish(),
status: z.string().nullish()
}) })
.array(), .array(),
projects: z projects: z
@@ -838,4 +842,121 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}; };
} }
}); });
server.route({
method: "POST",
url: "/organization-management/organizations",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
name: GenericResourceNameSchema,
inviteAdminEmails: z.string().email().array().min(1)
}),
response: {
200: z.object({
organization: OrganizationsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organization = await server.services.superAdmin.createOrganization(req.body, req.permission);
return { organization };
}
});
server.route({
method: "POST",
url: "/organization-management/organizations/:organizationId/memberships/:membershipId/resend-invite",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
organizationId: z.string(),
membershipId: z.string()
}),
response: {
200: z.object({
organizationMembership: OrgMembershipsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission);
return { organizationMembership };
}
});
server.route({
method: "POST",
url: "/organization-management/organizations/:organizationId/access",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
organizationId: z.string()
}),
response: {
200: z.object({
organizationMembership: OrgMembershipsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organizationMembership = await server.services.superAdmin.joinOrganization(
req.params.organizationId,
req.permission
);
return { organizationMembership };
}
});
server.route({
method: "POST",
url: "/usage-report/generate",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
csvContent: z.string(),
signature: z.string(),
filename: z.string()
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async () => {
const result = await server.services.offlineUsageReport.generateUsageReportCSV();
return {
csvContent: result.csvContent,
signature: result.signature,
filename: result.filename
};
}
});
}; };
@@ -2,10 +2,13 @@ import fastifyMultipart from "@fastify/multipart";
import { z } from "zod"; import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { VaultMappingType } from "@app/services/external-migration/external-migration-types"; import {
ExternalMigrationProviders,
VaultMappingType
} from "@app/services/external-migration/external-migration-types";
const MB25_IN_BYTES = 26214400; const MB25_IN_BYTES = 26214400;
@@ -81,4 +84,33 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
}); });
} }
}); });
server.route({
method: "GET",
url: "/custom-migration-enabled/:provider",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
provider: z.nativeEnum(ExternalMigrationProviders)
}),
response: {
200: z.object({
enabled: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const enabled = await server.services.migration.hasCustomVaultMigration({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
provider: req.params.provider
});
return { enabled };
}
});
}; };
@@ -56,7 +56,7 @@ const getConnectionConfig = ({
? { ? {
rejectUnauthorized: sslRejectUnauthorized, rejectUnauthorized: sslRejectUnauthorized,
ca: sslCertificate, ca: sslCertificate,
servername: host serverName: host
} }
: false : false
}; };
@@ -90,7 +90,7 @@ export const getSqlConnectionClient = async (appConnection: Pick<TSqlConnection,
connection: { connection: {
database, database,
port, port,
host, host: app === AppConnection.Postgres ? host : baseHost,
user: username, user: username,
password, password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
@@ -135,7 +135,7 @@ export const executeWithPotentialGateway = async <T>(
}, },
{ {
protocol: GatewayProxyProtocol.Tcp, protocol: GatewayProxyProtocol.Tcp,
targetHost, targetHost: app === AppConnection.Postgres ? targetHost : credentials.host,
targetPort: credentials.port, targetPort: credentials.port,
relayHost, relayHost,
relayPort: Number(relayPort), relayPort: Number(relayPort),
@@ -1190,7 +1190,9 @@ export const internalCertificateAuthorityServiceFactory = ({
}); });
} }
collectionId = certificateTemplate.pkiCollectionId as string; if (!collectionId) {
collectionId = certificateTemplate.pkiCollectionId as string;
}
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId);
} }
@@ -408,19 +408,123 @@ export const transformToInfisicalFormatNamespaceToProjects = (
}; };
}; };
export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => {
const projects: Array<{ name: string; id: string }> = [];
const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = [];
const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = [];
const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = [];
// track created entities to avoid duplicates
const projectMap = new Map<string, string>(); // team name -> projectId
const environmentMap = new Map<string, string>(); // team-name:envName -> environmentId
const folderMap = new Map<string, string>(); // team-name:envName:folderPath -> folderId
for (const data of vaultData) {
const { path, secretData } = data;
const pathParts = path.split("/").filter(Boolean);
if (pathParts.length < 2) {
// eslint-disable-next-line no-continue
continue;
}
// first level: environment (dev, prod, staging, etc.)
const environmentName = pathParts[0];
// second level: team name (team1, team2, etc.)
const teamName = pathParts[1];
// remaining parts: folder structure
const folderParts = pathParts.slice(2);
// create project (team) if if doesn't exist
if (!projectMap.has(teamName)) {
const projectId = uuidv4();
projectMap.set(teamName, projectId);
projects.push({
name: teamName,
id: projectId
});
}
const projectId = projectMap.get(teamName)!;
// create environment (dev, prod, etc.) for team
const envKey = `${teamName}:${environmentName}`;
if (!environmentMap.has(envKey)) {
const environmentId = uuidv4();
environmentMap.set(envKey, environmentId);
environments.push({
name: environmentName,
id: environmentId,
projectId
});
}
const environmentId = environmentMap.get(envKey)!;
// create folder structure for path segments
let currentFolderId: string | undefined;
let currentPath = "";
for (const folderName of folderParts) {
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
const folderKey = `${teamName}:${environmentName}:${currentPath}`;
if (!folderMap.has(folderKey)) {
const folderId = uuidv4();
folderMap.set(folderKey, folderId);
folders.push({
id: folderId,
name: folderName,
environmentId,
parentFolderId: currentFolderId || environmentId
});
currentFolderId = folderId;
} else {
currentFolderId = folderMap.get(folderKey)!;
}
}
for (const [key, value] of Object.entries(secretData)) {
secrets.push({
id: uuidv4(),
name: key,
environmentId,
value: String(value),
folderId: currentFolderId
});
}
}
return {
projects,
environments,
folders,
secrets
};
};
// refer to internal doc for more details on which ID's belong to which orgs.
// when its a custom migration, then it doesn't matter which mapping type is used (as of now).
export const vaultMigrationTransformMappings: Record<
string,
(vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData
> = {
"68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1
} as const;
export const importVaultDataFn = async ( export const importVaultDataFn = async (
{ {
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType, mappingType,
gatewayId gatewayId,
orgId
}: { }: {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
vaultUrl: string; vaultUrl: string;
mappingType: VaultMappingType; mappingType: VaultMappingType;
gatewayId?: string; gatewayId?: string;
orgId: string;
}, },
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> } { gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => { ) => {
@@ -432,6 +536,25 @@ export const importVaultDataFn = async (
}); });
} }
let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData;
if (mappingType === VaultMappingType.Custom) {
transformFn = vaultMigrationTransformMappings[orgId];
if (!transformFn) {
throw new BadRequestError({
message: "Please contact our sales team to enable custom vault migrations."
});
}
} else {
transformFn = transformToInfisicalFormatNamespaceToProjects;
}
logger.info(
{ orgId, mappingType },
`[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform`
);
const vaultApi = vaultFactory(gatewayService); const vaultApi = vaultFactory(gatewayService);
const vaultData = await vaultApi.collectVaultData({ const vaultData = await vaultApi.collectVaultData({
@@ -441,7 +564,5 @@ export const importVaultDataFn = async (
gatewayId gatewayId
}); });
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); return transformFn(vaultData, mappingType);
return infisicalData;
}; };
@@ -5,9 +5,20 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns"; import {
decryptEnvKeyDataFn,
importVaultDataFn,
parseEnvKeyDataFn,
vaultMigrationTransformMappings
} from "./external-migration-fns";
import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { TExternalMigrationQueueFactory } from "./external-migration-queue";
import { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; import {
ExternalMigrationProviders,
ExternalPlatforms,
THasCustomVaultMigrationDTO,
TImportEnvKeyDataDTO,
TImportVaultDataDTO
} from "./external-migration-types";
type TExternalMigrationServiceFactoryDep = { type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
@@ -101,7 +112,8 @@ export const externalMigrationServiceFactory = ({
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType, mappingType,
gatewayId gatewayId,
orgId: actorOrgId
}, },
{ {
gatewayService gatewayService
@@ -127,8 +139,37 @@ export const externalMigrationServiceFactory = ({
}); });
}; };
const hasCustomVaultMigration = async ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
provider
}: THasCustomVaultMigrationDTO) => {
const { membership } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
if (membership.role !== OrgMembershipRole.Admin) {
throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" });
}
if (provider !== ExternalMigrationProviders.Vault) {
throw new BadRequestError({
message: "Invalid provider. Vault is the only supported provider for custom migrations."
});
}
return actorOrgId in vaultMigrationTransformMappings;
};
return { return {
importEnvKeyData, importEnvKeyData,
importVaultData importVaultData,
hasCustomVaultMigration
}; };
}; };
@@ -4,7 +4,8 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export enum VaultMappingType { export enum VaultMappingType {
Namespace = "namespace", Namespace = "namespace",
KeyVault = "key-vault" KeyVault = "key-vault",
Custom = "custom"
} }
export type InfisicalImportData = { export type InfisicalImportData = {
@@ -26,6 +27,10 @@ export type TImportEnvKeyDataDTO = {
encryptedJson: { nonce: string; data: string }; encryptedJson: { nonce: string; data: string };
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type THasCustomVaultMigrationDTO = {
provider: ExternalMigrationProviders;
} & Omit<TOrgPermission, "orgId">;
export type TImportVaultDataDTO = { export type TImportVaultDataDTO = {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
@@ -111,3 +116,8 @@ export enum ExternalPlatforms {
EnvKey = "EnvKey", EnvKey = "EnvKey",
Vault = "Vault" Vault = "Vault"
} }
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
@@ -0,0 +1,208 @@
import { TDbClient } from "@app/db";
import { ProjectType, TableName } from "@app/db/schemas";
export type TOfflineUsageReportDALFactory = ReturnType<typeof offlineUsageReportDALFactory>;
export const offlineUsageReportDALFactory = (db: TDbClient) => {
const getUserMetrics = async () => {
// Get total users and admin users
const userMetrics = (await db
.from(TableName.Users)
.select(
db.raw(
`
COUNT(*) as total_users,
COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users
`
)
)
.where({ isGhost: false })
.first()) as { total_users: string; admin_users: string } | undefined;
// Get users by auth method
const authMethodStats = (await db
.from(TableName.Users)
.select(
db.raw(`
unnest("authMethods") as auth_method,
COUNT(*) as count
`)
)
.where({ isGhost: false })
.whereNotNull("authMethods")
.groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>;
const usersByAuthMethod = authMethodStats.reduce(
(acc: Record<string, number>, row: { auth_method: string; count: string }) => {
acc[row.auth_method] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
return {
totalUsers: parseInt(userMetrics?.total_users || "0", 10),
adminUsers: parseInt(userMetrics?.admin_users || "0", 10),
usersByAuthMethod
};
};
const getMachineIdentityMetrics = async () => {
// Get total machine identities
const identityMetrics = (await db
.from(TableName.Identity)
.select(
db.raw(
`
COUNT(*) as total_identities
`
)
)
.first()) as { total_identities: string } | undefined;
// Get identities by auth method
const authMethodStats = (await db
.from(TableName.Identity)
.select("authMethod")
.count("* as count")
.whereNotNull("authMethod")
.groupBy("authMethod")) as Array<{ authMethod: string; count: string }>;
const machineIdentitiesByAuthMethod = authMethodStats.reduce(
(acc: Record<string, number>, row: { authMethod: string; count: string }) => {
acc[row.authMethod] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
return {
totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10),
machineIdentitiesByAuthMethod
};
};
const getProjectMetrics = async () => {
// Get total projects and projects by type
const projectMetrics = (await db
.from(TableName.Project)
.select("type")
.count("* as count")
.groupBy("type")) as Array<{ type: string; count: string }>;
const totalProjects = projectMetrics.reduce(
(sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10),
0
);
const projectsByType = projectMetrics.reduce(
(acc: Record<string, number>, row: { type: string; count: string }) => {
acc[row.type] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
// Calculate average secrets per project
const secretsPerProject = (await db
.from(`${TableName.SecretV2} as s`)
.select("p.id as projectId")
.count("s.id as count")
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
.where("p.type", ProjectType.SecretManager)
.groupBy("p.id")
.whereNotNull("p.id")) as Array<{ projectId: string; count: string }>;
const averageSecretsPerProject =
secretsPerProject.length > 0
? secretsPerProject.reduce(
(sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10),
0
) / secretsPerProject.length
: 0;
return {
totalProjects,
projectsByType,
averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100
};
};
const getSecretMetrics = async () => {
// Get total secrets
const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as
| { count: string }
| undefined;
const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10);
// Get secrets by project
const secretsByProject = (await db
.from(`${TableName.SecretV2} as s`)
.select("p.id as projectId", "p.name as projectName")
.count("s.id as secretCount")
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
.where("p.type", ProjectType.SecretManager)
.groupBy("p.id", "p.name")
.whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>;
return {
totalSecrets,
secretsByProject: secretsByProject.map(
(row: { projectId: string; projectName: string; secretCount: string }) => ({
projectId: row.projectId,
projectName: row.projectName,
secretCount: parseInt(row.secretCount, 10)
})
)
};
};
const getSecretSyncMetrics = async () => {
const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as
| { count: string }
| undefined;
return {
totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10)
};
};
const getDynamicSecretMetrics = async () => {
const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as
| { count: string }
| undefined;
return {
totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10)
};
};
const getSecretRotationMetrics = async () => {
// Check both v1 and v2 secret rotation tables
const [v1RotationsResult, v2RotationsResult] = await Promise.all([
db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>,
db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined>
]);
const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10);
const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10);
return {
totalSecretRotations: totalV1Rotations + totalV2Rotations
};
};
return {
getUserMetrics,
getMachineIdentityMetrics,
getProjectMetrics,
getSecretMetrics,
getSecretSyncMetrics,
getDynamicSecretMetrics,
getSecretRotationMetrics
};
};
@@ -0,0 +1,133 @@
import crypto from "crypto";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal";
type TOfflineUsageReportServiceFactoryDep = {
offlineUsageReportDAL: TOfflineUsageReportDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getCustomerId" | "getLicenseId">;
};
export type TOfflineUsageReportServiceFactory = ReturnType<typeof offlineUsageReportServiceFactory>;
export const offlineUsageReportServiceFactory = ({
offlineUsageReportDAL,
licenseService
}: TOfflineUsageReportServiceFactoryDep) => {
const signReportContent = (content: string, licenseId: string): string => {
const contentHash = crypto.createHash("sha256").update(content).digest("hex");
const hmac = crypto.createHmac("sha256", licenseId);
hmac.update(contentHash);
return hmac.digest("hex");
};
const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => {
const expectedSignature = signReportContent(content, licenseId);
return signature === expectedSignature;
};
const generateUsageReportCSV = async () => {
const cfg = getConfig();
if (!cfg.LICENSE_KEY_OFFLINE) {
throw new BadRequestError({
message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured."
});
}
const customerId = licenseService.getCustomerId() as string;
const licenseId = licenseService.getLicenseId();
const [
userMetrics,
machineIdentityMetrics,
projectMetrics,
secretMetrics,
secretSyncMetrics,
dynamicSecretMetrics,
secretRotationMetrics
] = await Promise.all([
offlineUsageReportDAL.getUserMetrics(),
offlineUsageReportDAL.getMachineIdentityMetrics(),
offlineUsageReportDAL.getProjectMetrics(),
offlineUsageReportDAL.getSecretMetrics(),
offlineUsageReportDAL.getSecretSyncMetrics(),
offlineUsageReportDAL.getDynamicSecretMetrics(),
offlineUsageReportDAL.getSecretRotationMetrics()
]);
const headers = [
"Total Users",
"Admin Users",
"Total Identities",
"Total Projects",
"Total Secrets",
"Total Secret Syncs",
"Total Dynamic Secrets",
"Total Secret Rotations",
"Avg Secrets Per Project"
];
const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod);
allUserAuthMethods.forEach((method) => {
headers.push(`Users Auth ${method}`);
});
const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod);
allIdentityAuthMethods.forEach((method) => {
headers.push(`Identities Auth ${method}`);
});
const allProjectTypes = Object.keys(projectMetrics.projectsByType);
allProjectTypes.forEach((type) => {
headers.push(`Projects ${type}`);
});
headers.push("Signature");
const dataRow: (string | number)[] = [
userMetrics.totalUsers,
userMetrics.adminUsers,
machineIdentityMetrics.totalMachineIdentities,
projectMetrics.totalProjects,
secretMetrics.totalSecrets,
secretSyncMetrics.totalSecretSyncs,
dynamicSecretMetrics.totalDynamicSecrets,
secretRotationMetrics.totalSecretRotations,
projectMetrics.averageSecretsPerProject
];
allUserAuthMethods.forEach((method) => {
dataRow.push(userMetrics.usersByAuthMethod[method] || 0);
});
allIdentityAuthMethods.forEach((method) => {
dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0);
});
allProjectTypes.forEach((type) => {
dataRow.push(projectMetrics.projectsByType[type] || 0);
});
const headersWithoutSignature = headers.slice(0, -1);
const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n");
const signature = signReportContent(contentWithoutSignature, licenseId);
dataRow.push(signature);
const csvContent = [headers.join(","), dataRow.join(",")].join("\n");
return {
csvContent,
signature,
filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv`
};
};
return {
generateUsageReportCSV,
verifyReportSignature: (csvContent: string, signature: string, licenseId: string) =>
verifyReportContent(csvContent, signature, licenseId)
};
};
@@ -0,0 +1,42 @@
export interface TUsageMetrics {
// User metrics
totalUsers: number;
usersByAuthMethod: Record<string, number>;
adminUsers: number;
// Machine identity metrics
totalMachineIdentities: number;
machineIdentitiesByAuthMethod: Record<string, number>;
// Project metrics
totalProjects: number;
projectsByType: Record<string, number>;
averageSecretsPerProject: number;
// Secret metrics
totalSecrets: number;
totalSecretSyncs: number;
totalDynamicSecrets: number;
totalSecretRotations: number;
}
export interface TUsageReportMetadata {
generatedAt: string;
instanceId: string;
reportVersion: string;
}
export interface TUsageReport {
metadata: TUsageReportMetadata;
metrics: TUsageMetrics;
signature?: string;
}
export interface TGenerateUsageReportDTO {
includeSignature?: boolean;
}
export interface TVerifyUsageReportDTO {
reportData: string;
signature: string;
}
+14 -1
View File
@@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => {
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
const formattedDocs = sqlNestRelationships({ const formattedDocs = sqlNestRelationships({
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
orgMembershipId, orgMembershipId,
orgMembershipRole, orgMembershipRole,
orgMembershipRoleName, orgMembershipRoleName,
orgMembershipRoleId orgMembershipRoleId,
orgMembershipStatus
}) => ({ }) => ({
user: { user: {
id: userId, id: userId,
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
firstName, firstName,
lastName lastName
}, },
status: orgMembershipStatus,
membershipId: orgMembershipId, membershipId: orgMembershipId,
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
roleId: orgMembershipRoleId roleId: orgMembershipRoleId
@@ -488,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => {
} }
}; };
const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => {
try {
const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*");
return memberships;
} catch (error) {
throw new DatabaseError({ error, name: "Create org memberships" });
}
};
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
try { try {
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*"); const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
findMembership, findMembership,
findMembershipWithScimFilter, findMembershipWithScimFilter,
createMembership, createMembership,
bulkCreateMemberships,
updateMembershipById, updateMembershipById,
deleteMembershipById, deleteMembershipById,
deleteMembershipsById, deleteMembershipsById,
+29 -21
View File
@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
/* /*
* Create organization * Create organization
* */ * */
const createOrganization = async ({ const createOrganization = async (
userId, {
userEmail, userId,
orgName userEmail,
}: { orgName
userId: string; }: {
orgName: string; userId?: string;
userEmail?: string | null; orgName: string;
}) => { userEmail?: string | null;
},
trx?: Knex
) => {
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
const key = crypto.randomBytes(32).toString("base64"); const key = crypto.randomBytes(32).toString("base64");
const { const {
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
const organization = await orgDAL.transaction(async (tx) => {
const createOrg = async (tx: Knex) => {
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy // akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
const org = await orgDAL.create( const org = await orgDAL.create(
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) }, { name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
tx tx
); );
await orgDAL.createMembership( if (userId) {
{ await orgDAL.createMembership(
userId, {
orgId: org.id, userId,
role: OrgMembershipRole.Admin, orgId: org.id,
status: OrgMembershipStatus.Accepted, role: OrgMembershipRole.Admin,
isActive: true status: OrgMembershipStatus.Accepted,
}, isActive: true
tx },
); tx
);
}
await orgBotDAL.create( await orgBotDAL.create(
{ {
name: org.name, name: org.name,
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
tx tx
); );
return org; return org;
}); };
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
await licenseService.updateSubscriptionOrgMemberCount(organization.id); await licenseService.updateSubscriptionOrgMemberCount(organization.id);
return organization; return organization;
@@ -12,7 +12,7 @@ type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>; type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>;
type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>; type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 10; const BATCH_SIZE = 10;
const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => {
@@ -38,7 +38,7 @@ type TAwsSecretsRecord = Record<string, SecretListEntry>;
type TAwsSecretValuesRecord = Record<string, SecretValueEntry>; type TAwsSecretValuesRecord = Record<string, SecretValueEntry>;
type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>; type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => { const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => {
@@ -81,7 +81,7 @@ type TSecretSyncQueueFactoryDep = {
| "invalidateSecretCacheByProjectId" | "invalidateSecretCacheByProjectId"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">; secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById" | "update">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
@@ -104,17 +104,15 @@ type SecretSyncActionJob = Job<
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
>; >;
const JITTER_MS = 10 * 1000;
const REQUEUE_MS = 30 * 1000;
const REQUEUE_LIMIT = 30;
const CONNECTION_CONCURRENCY_LIMIT = 3;
const getRequeueDelay = (failureCount?: number) => { const getRequeueDelay = (failureCount?: number) => {
if (!failureCount) return 0; const jitter = Math.random() * JITTER_MS;
if (!failureCount) return jitter;
const baseDelay = 1000; return REQUEUE_MS + jitter;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
return jitter;
}; };
export const secretSyncQueueFactory = ({ export const secretSyncQueueFactory = ({
@@ -193,6 +191,46 @@ export const secretSyncQueueFactory = ({
folderCommitService folderCommitService
}); });
const $isConnectionConcurrencyLimitReached = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
if (!concurrencyCount) return false;
const count = Number.parseInt(concurrencyCount, 10);
if (Number.isNaN(count)) return false;
return count >= CONNECTION_CONCURRENCY_LIMIT;
};
const $incrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1;
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
incrementedCount
);
};
const $decrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1);
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
decrementedCount
);
};
const $getInfisicalSecrets = async ( const $getInfisicalSecrets = async (
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
includeImports = true includeImports = true
@@ -416,15 +454,11 @@ export const secretSyncQueueFactory = ({
return importedSecretMap; return importedSecretMap;
}; };
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo } data: { syncId, auditLogInfo }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -566,15 +600,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Sync Job with ID %s Completed", job.id); logger.info("SecretSync Sync Job with ID %s Completed", job.id);
}; };
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, importBehavior } data: { syncId, auditLogInfo, importBehavior }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, { await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Running importStatus: SecretSyncStatus.Running
}); });
@@ -683,15 +713,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Import Job with ID %s Completed", job.id); logger.info("SecretSync Import Job with ID %s Completed", job.id);
}; };
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, deleteSyncOnComplete } data: { syncId, auditLogInfo, deleteSyncOnComplete }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -894,6 +920,17 @@ export const secretSyncQueueFactory = ({
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
await secretSyncDAL.update(
{
$in: {
id: secretSyncs.map((sync) => sync.id)
}
},
{
syncStatus: SecretSyncStatus.Pending
}
);
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
}; };
@@ -904,7 +941,7 @@ export const secretSyncQueueFactory = ({
case QueueJobs.SecretSyncSyncSecrets: { case QueueJobs.SecretSyncSyncSecrets: {
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
if (failedToAcquireLockCount < 10) { if (failedToAcquireLockCount < REQUEUE_LIMIT) {
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
return; return;
} }
@@ -974,6 +1011,26 @@ export const secretSyncQueueFactory = ({
| TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO
| TQueueSecretSyncRemoveSecretsByIdDTO; | TQueueSecretSyncRemoveSecretsByIdDTO;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
const { connectionId } = secretSync;
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId);
if (isConcurrentLimitReached) {
logger.info(
`SecretSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]`
);
await $handleAcquireLockFailure(job as SecretSyncActionJob);
return;
}
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>; let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try { try {
@@ -993,20 +1050,26 @@ export const secretSyncQueueFactory = ({
try { try {
switch (job.name) { switch (job.name) {
case QueueJobs.SecretSyncSyncSecrets: case QueueJobs.SecretSyncSyncSecrets: {
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); await $incrementConnectionConcurrencyCount(connectionId);
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO, secretSync);
break; break;
}
case QueueJobs.SecretSyncImportSecrets: case QueueJobs.SecretSyncImportSecrets:
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO, secretSync);
break; break;
case QueueJobs.SecretSyncRemoveSecrets: case QueueJobs.SecretSyncRemoveSecrets:
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO, secretSync);
break; break;
default: default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled Secret Sync Job ${job.name}`); throw new Error(`Unhandled Secret Sync Job ${job.name}`);
} }
} finally { } finally {
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
await $decrementConnectionConcurrencyCount(connectionId);
}
await lock.release(); await lock.release();
} }
}); });
@@ -0,0 +1,69 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface OrganizationAssignmentTemplateProps extends Omit<BaseEmailWrapperProps, "preview" | "title"> {
inviterFirstName?: string;
inviterUsername?: string;
organizationName: string;
callback_url: string;
}
export const OrganizationAssignmentTemplate = ({
organizationName,
inviterFirstName,
inviterUsername,
callback_url,
siteUrl
}: OrganizationAssignmentTemplateProps) => {
return (
<BaseEmailWrapper
title="New Organization"
preview="You've been added to a new organization on Infisical."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
You've been added to the organization
<br />
<strong>{organizationName}</strong> on <strong>Infisical</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border text-center border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
{inviterFirstName && inviterUsername ? (
<>
<strong>{inviterFirstName}</strong> (
<BaseLink href={`mailto:${inviterUsername}`}>{inviterUsername}</BaseLink>) has added you as an
organization admin to <strong>{organizationName}</strong>.
</>
) : (
<>
An instance admin has added you as an organization admin to <strong>{organizationName}</strong>.
</>
)}
</Text>
</Section>
<Section className="text-center">
<BaseButton href={callback_url}>View Dashboard</BaseButton>
</Section>
<Section className="mt-[24px] bg-gray-50 pt-[2px] pb-[16px] border border-solid border-gray-200 px-[24px] rounded-md text-gray-800">
<Text className="mb-[0px]">
<strong>About Infisical:</strong> Infisical is an all-in-one platform to securely manage application secrets,
certificates, SSH keys, and configurations across your team and infrastructure.
</Text>
</Section>
</BaseEmailWrapper>
);
};
export default OrganizationAssignmentTemplate;
OrganizationAssignmentTemplate.PreviewProps = {
organizationName: "Example Organization",
inviterFirstName: "Jane",
inviterUsername: "[email protected]",
siteUrl: "https://infisical.com",
callback_url: "https://app.infisical.com"
} as OrganizationAssignmentTemplateProps;
@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
export * from "./NewDeviceLoginTemplate"; export * from "./NewDeviceLoginTemplate";
export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminBreakglassAccessTemplate";
export * from "./OrgAdminProjectGrantAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate";
export * from "./OrganizationAssignmentTemplate";
export * from "./OrganizationInvitationTemplate"; export * from "./OrganizationInvitationTemplate";
export * from "./PasswordResetTemplate"; export * from "./PasswordResetTemplate";
export * from "./PasswordSetupTemplate"; export * from "./PasswordSetupTemplate";
@@ -18,6 +18,7 @@ import {
NewDeviceLoginTemplate, NewDeviceLoginTemplate,
OrgAdminBreakglassAccessTemplate, OrgAdminBreakglassAccessTemplate,
OrgAdminProjectGrantAccessTemplate, OrgAdminProjectGrantAccessTemplate,
OrganizationAssignmentTemplate,
OrganizationInvitationTemplate, OrganizationInvitationTemplate,
PasswordResetTemplate, PasswordResetTemplate,
PasswordSetupTemplate, PasswordSetupTemplate,
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
NewDeviceJoin = "newDevice", NewDeviceJoin = "newDevice",
OrgInvite = "organizationInvitation", OrgInvite = "organizationInvitation",
OrgAssignment = "organizationAssignment",
ResetPassword = "passwordReset", ResetPassword = "passwordReset",
SetupPassword = "passwordSetup", SetupPassword = "passwordSetup",
SecretLeakIncident = "secretLeakIncident", SecretLeakIncident = "secretLeakIncident",
@@ -94,6 +96,7 @@ export enum SmtpHost {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = { const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate,
@@ -1,6 +1,13 @@
import { CronJob } from "cron"; import { CronJob } from "cron";
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import {
IdentityAuthMethod,
OrgMembershipRole,
OrgMembershipStatus,
TSuperAdmin,
TSuperAdminUpdate,
TUsers
} from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { import {
@@ -13,7 +20,12 @@ import {
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { isDisposableEmail } from "@app/lib/validator";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAuthLoginFactory } from "../auth/auth-login-service"; import { TAuthLoginFactory } from "../auth/auth-login-service";
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
@@ -43,7 +55,9 @@ import {
TAdminGetUsersDTO, TAdminGetUsersDTO,
TAdminIntegrationConfig, TAdminIntegrationConfig,
TAdminSignUpDTO, TAdminSignUpDTO,
TGetOrganizationsDTO TCreateOrganizationDTO,
TGetOrganizationsDTO,
TResendOrgInviteDTO
} from "./super-admin-types"; } from "./super-admin-types";
type TSuperAdminServiceFactoryDep = { type TSuperAdminServiceFactoryDep = {
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
authService: Pick<TAuthLoginFactory, "generateUserTokens">; authService: Pick<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">; kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
kmsRootConfigDAL: TKmsRootConfigDALFactory; kmsRootConfigDAL: TKmsRootConfigDALFactory;
orgService: Pick<TOrgServiceFactory, "createOrganization">; orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">; keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">; licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "updateSubscriptionOrgMemberCount">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">; microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
invalidateCacheQueue: TInvalidateCacheQueueFactory; invalidateCacheQueue: TInvalidateCacheQueueFactory;
smtpService: Pick<TSmtpService, "sendMail">;
tokenService: TAuthTokenServiceFactory;
}; };
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>; export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
identityTokenAuthDAL, identityTokenAuthDAL,
identityOrgMembershipDAL, identityOrgMembershipDAL,
microsoftTeamsService, microsoftTeamsService,
invalidateCacheQueue invalidateCacheQueue,
smtpService,
tokenService
}: TSuperAdminServiceFactoryDep) => { }: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => { const initServerCfg = async () => {
// TODO(akhilmhdh): bad pattern time less change this later to me itself // TODO(akhilmhdh): bad pattern time less change this later to me itself
@@ -732,6 +750,159 @@ export const superAdminServiceFactory = ({
return organizations; return organizations;
}; };
const createOrganization = async (
{ name, inviteAdminEmails: emails }: TCreateOrganizationDTO,
actor: OrgServiceActor
) => {
const appCfg = getConfig();
const inviteAdminEmails = [...new Set(emails)];
if (!appCfg.isDevelopmentMode && appCfg.isCloud)
throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" });
const serverAdmin = await userDAL.findById(actor.id);
const plan = licenseService.onPremFeatures;
const isEmailInvalid = await isDisposableEmail(inviteAdminEmails);
if (isEmailInvalid) {
throw new BadRequestError({
message: "Disposable emails are not allowed",
name: "InviteUser"
});
}
const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => {
const org = await orgService.createOrganization(
{
orgName: name,
userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this
},
tx
);
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username" | "isAccepted">[] = [];
for await (const inviteeEmail of inviteAdminEmails) {
const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
let inviteeUser =
usersByUsername?.length > 1
? usersByUsername.find((el) => el.username === inviteeEmail)
: usersByUsername?.[0];
// if the user doesn't exist we create the user with the email
if (!inviteeUser) {
// TODO(carlos): will be removed once the function receives usernames instead of emails
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
if (usersByEmail?.length === 1) {
[inviteeUser] = usersByEmail;
} else {
inviteeUser = await userDAL.create(
{
isAccepted: false,
email: inviteeEmail,
username: inviteeEmail,
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
}
}
const inviteeUserId = inviteeUser?.id;
const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx);
// when user is missing the encrytion keys
// this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp
// So what we do is we generate a random secure password and then encrypt it with a random pub-private key
// Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password
if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) {
await userDAL.createUserEncryption(
{
userId: inviteeUserId,
encryptionVersion: 2
},
tx
);
}
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
name: "InviteUser",
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
await orgDAL.createMembership(
{
userId: inviteeUser.id,
inviteEmail: inviteeEmail,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
);
users.push(inviteeUser);
}
return { organization: org, users };
});
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
await Promise.allSettled(
usersToEmail.map(async (user) => {
if (!user.email) return;
if (user.isAccepted) {
return smtpService.sendMail({
template: SmtpTemplates.OrgAssignment,
subjectLine: "You've been added to an Infisical organization",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}`
}
});
}
// new user, send regular invite
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: user.id,
orgId: organization.id
});
return smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
})
);
return organization;
};
const deleteOrganization = async (organizationId: string) => { const deleteOrganization = async (organizationId: string) => {
const organization = await orgDAL.deleteById(organizationId); const organization = await orgDAL.deleteById(organizationId);
return organization; return organization;
@@ -763,6 +934,86 @@ export const superAdminServiceFactory = ({
return organizationMembership; return organizationMembership;
}; };
const joinOrganization = async (orgId: string, actor: OrgServiceActor) => {
const serverAdmin = await userDAL.findById(actor.id);
if (!serverAdmin) {
throw new NotFoundError({ message: "Could not find server admin user" });
}
const org = await orgDAL.findById(orgId);
if (!org) {
throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` });
}
const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId });
if (existingOrgMembership) {
throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` });
}
const orgMembership = await orgDAL.createMembership({
userId: serverAdmin.id,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
});
return orgMembership;
};
const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => {
const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId });
if (!orgMembership) {
throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" });
}
if (orgMembership.status === OrgMembershipStatus.Accepted) {
throw new BadRequestError({
message: "This user has already accepted their invitation."
});
}
if (!orgMembership.userId) {
throw new NotFoundError({ message: "Cannot find user associated with Org Membership." });
}
if (!orgMembership.inviteEmail) {
throw new BadRequestError({ message: "No invite email associated with user." });
}
const org = await orgDAL.findOrgById(orgMembership.orgId);
const appCfg = getConfig();
const serverAdmin = await userDAL.findById(actor.id);
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: orgMembership.userId,
orgId: orgMembership.orgId
});
await smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [orgMembership.inviteEmail],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: org?.name,
email: orgMembership.inviteEmail,
organizationId: orgMembership.orgId,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
return orgMembership;
};
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
const identities = await identityDAL.getIdentitiesByFilter({ const identities = await identityDAL.getIdentitiesByFilter({
limit, limit,
@@ -901,6 +1152,9 @@ export const superAdminServiceFactory = ({
initializeEnvConfigSync, initializeEnvConfigSync,
getEnvOverrides, getEnvOverrides,
getEnvOverridesOrganized, getEnvOverridesOrganized,
deleteUsers deleteUsers,
createOrganization,
joinOrganization,
resendOrgInvite
}; };
}; };
@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
searchTerm: string; searchTerm: string;
}; };
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
export enum LoginMethod { export enum LoginMethod {
EMAIL = "email", EMAIL = "email",
GOOGLE = "google", GOOGLE = "google",
+40
View File
@@ -0,0 +1,40 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
],
parser: '@babel/eslint-parser',
parserOptions: {
ecmaVersion: 2021,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
requireConfigFile: false,
babelOptions: {
presets: ['@babel/preset-react'],
},
},
plugins: ['react'],
rules: {
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
},
settings: {
react: {
version: 'detect',
},
},
ignorePatterns: [
'node_modules/',
'dist/',
'build/',
'*.config.js',
],
};
+8
View File
@@ -98,6 +98,7 @@
{ {
"group": "App Connections", "group": "App Connections",
"pages": [ "pages": [
"integrations/app-connections",
"integrations/app-connections/overview", "integrations/app-connections/overview",
{ {
"group": "Connections", "group": "Connections",
@@ -184,6 +185,7 @@
{ {
"group": "User Authentication", "group": "User Authentication",
"pages": [ "pages": [
"integrations/user-authentication",
"documentation/platform/auth-methods/email-password", "documentation/platform/auth-methods/email-password",
{ {
"group": "SSO", "group": "SSO",
@@ -243,6 +245,7 @@
{ {
"group": "Machine Identities", "group": "Machine Identities",
"pages": [ "pages": [
"integrations/machine-authentication",
"documentation/platform/identities/alicloud-auth", "documentation/platform/identities/alicloud-auth",
"documentation/platform/identities/aws-auth", "documentation/platform/identities/aws-auth",
"documentation/platform/identities/azure-auth", "documentation/platform/identities/azure-auth",
@@ -417,6 +420,7 @@
{ {
"group": "Secret Rotation", "group": "Secret Rotation",
"pages": [ "pages": [
"integrations/secret-rotations",
"documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/overview",
"documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/auth0-client-secret",
"documentation/platform/secret-rotation/aws-iam-user-secret", "documentation/platform/secret-rotation/aws-iam-user-secret",
@@ -432,6 +436,7 @@
{ {
"group": "Dynamic Secrets", "group": "Dynamic Secrets",
"pages": [ "pages": [
"integrations/dynamic-secrets",
"documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/overview",
"documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-elasticache",
"documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/aws-iam",
@@ -502,6 +507,7 @@
{ {
"group": "Secret Syncs", "group": "Secret Syncs",
"pages": [ "pages": [
"integrations/secret-syncs",
"integrations/secret-syncs/overview", "integrations/secret-syncs/overview",
{ {
"group": "Syncs", "group": "Syncs",
@@ -607,6 +613,7 @@
{ {
"group": "Framework Integrations", "group": "Framework Integrations",
"pages": [ "pages": [
"integrations/framework-integrations",
"integrations/frameworks/spring-boot-maven", "integrations/frameworks/spring-boot-maven",
"integrations/frameworks/react", "integrations/frameworks/react",
"integrations/frameworks/vue", "integrations/frameworks/vue",
@@ -2448,6 +2455,7 @@
"sdks/languages/cpp", "sdks/languages/cpp",
"sdks/languages/rust", "sdks/languages/rust",
"sdks/languages/go", "sdks/languages/go",
"sdks/languages/php",
"sdks/languages/ruby" "sdks/languages/ruby"
] ]
} }
@@ -8,6 +8,7 @@ Every time a secret change is performed, a new version of the same secret is cre
Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read) Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read)
by specifying the `version` query parameter. by specifying the `version` query parameter.
![secret versioning overview](../../images/platform/secret-versioning-overview.png)
![secret versioning](../../images/platform/secret-versioning.png) ![secret versioning](../../images/platform/secret-versioning.png)
The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical. The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical.
Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 572 KiB

+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg height="383.5975" id="svg3430" version="1.1" viewBox="0 0 711.20123 383.5975" width="711.20123" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg">
<title id="title3510">Official PHP Logo</title>
<metadata id="metadata3436">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title>Official PHP Logo</dc:title>
<dc:creator>
<cc:Agent>
<dc:title>Colin Viebrock</dc:title>
</cc:Agent>
</dc:creator>
<dc:description/>
<dc:contributor>
<cc:Agent>
<dc:title/>
</cc:Agent>
</dc:contributor>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
<dc:rights>
<cc:Agent>
<dc:title>Copyright Colin Viebrock 1997 - All rights reserved.</dc:title>
</cc:Agent>
</dc:rights>
<dc:date>1997</dc:date>
</cc:Work>
<cc:License rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#Notice"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"/>
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
<cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike"/>
</cc:License>
</rdf:RDF>
</metadata>
<defs id="defs3434">
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath3444">
<path d="M 11.52,162 C 11.52,81.677 135.307,16.561 288,16.561 l 0,0 c 152.693,0 276.481,65.116 276.481,145.439 l 0,0 c 0,80.322 -123.788,145.439 -276.481,145.439 l 0,0 C 135.307,307.439 11.52,242.322 11.52,162" id="path3446"/>
</clipPath>
<radialGradient cx="0" cy="0" fx="0" fy="0" gradientTransform="matrix(363.05789,0,0,-363.05789,177.52002,256.30713)" gradientUnits="userSpaceOnUse" id="radialGradient3452" r="1" spreadMethod="pad">
<stop id="stop3454" offset="0" style="stop-opacity:1;stop-color:#aeb2d5"/>
<stop id="stop3456" offset="0.3" style="stop-opacity:1;stop-color:#aeb2d5"/>
<stop id="stop3458" offset="0.75" style="stop-opacity:1;stop-color:#484c89"/>
<stop id="stop3460" offset="1" style="stop-opacity:1;stop-color:#484c89"/>
</radialGradient>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath3468">
<path d="M 0,324 576,324 576,0 0,0 0,324 Z" id="path3470"/>
</clipPath>
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath3480">
<path d="M 0,324 576,324 576,0 0,0 0,324 Z" id="path3482"/>
</clipPath>
</defs>
<g id="g3438" transform="matrix(1.25,0,0,-1.25,-4.4,394.29875)">
<g id="g3440">
<g clip-path="url(#clipPath3444)" id="g3442">
<g id="g3448">
<g id="g3450">
<path d="M 11.52,162 C 11.52,81.677 135.307,16.561 288,16.561 l 0,0 c 152.693,0 276.481,65.116 276.481,145.439 l 0,0 c 0,80.322 -123.788,145.439 -276.481,145.439 l 0,0 C 135.307,307.439 11.52,242.322 11.52,162" id="path3462" style="fill:url(#radialGradient3452);stroke:none"/>
</g>
</g>
</g>
</g>
<g id="g3464">
<g clip-path="url(#clipPath3468)" id="g3466">
<g id="g3472" transform="translate(288,27.3594)">
<path d="M 0,0 C 146.729,0 265.68,60.281 265.68,134.641 265.68,209 146.729,269.282 0,269.282 -146.729,269.282 -265.68,209 -265.68,134.641 -265.68,60.281 -146.729,0 0,0" id="path3474" style="fill:#777bb3;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
</g>
</g>
<g id="g3476">
<g clip-path="url(#clipPath3480)" id="g3478">
<g id="g3484" transform="translate(161.7344,145.3066)">
<path d="m 0,0 c 12.065,0 21.072,2.225 26.771,6.611 5.638,4.341 9.532,11.862 11.573,22.353 1.903,9.806 1.178,16.653 -2.154,20.348 C 32.783,53.086 25.417,55 14.297,55 L -4.984,55 -15.673,0 0,0 Z m -63.063,-67.75 c -0.895,0 -1.745,0.4 -2.314,1.092 -0.57,0.691 -0.801,1.601 -0.63,2.48 L -37.679,81.573 C -37.405,82.982 -36.17,84 -34.734,84 L 26.32,84 C 45.508,84 59.79,78.79 68.767,68.513 77.792,58.182 80.579,43.741 77.05,25.592 75.614,18.198 73.144,11.331 69.709,5.183 66.27,-0.972 61.725,-6.667 56.198,-11.747 49.582,-17.939 42.094,-22.429 33.962,-25.071 25.959,-27.678 15.681,-29 3.414,-29 l -24.722,0 -7.06,-36.322 c -0.274,-1.41 -1.508,-2.428 -2.944,-2.428 l -31.751,0 z" id="path3486" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
<g id="g3488" transform="translate(159.2236,197.3071)">
<path d="m 0,0 16.808,0 c 13.421,0 18.083,-2.945 19.667,-4.7 2.628,-2.914 3.124,-9.058 1.435,-17.767 C 36.012,-32.217 32.494,-39.13 27.452,-43.012 22.29,-46.986 13.898,-49 2.511,-49 L -9.523,-49 0,0 Z m 28.831,35 -61.055,0 c -2.872,0 -5.341,-2.036 -5.889,-4.855 l -28.328,-145.751 c -0.342,-1.759 0.12,-3.578 1.259,-4.961 1.14,-1.383 2.838,-2.183 4.63,-2.183 l 31.75,0 c 2.873,0 5.342,2.036 5.89,4.855 l 6.588,33.895 22.249,0 c 12.582,0 23.174,1.372 31.479,4.077 8.541,2.775 16.399,7.48 23.354,13.984 5.752,5.292 10.49,11.232 14.08,17.657 3.591,6.427 6.171,13.594 7.668,21.302 3.715,19.104 0.697,34.402 -8.969,45.466 C 63.965,29.444 48.923,35 28.831,35 m -45.633,-90 19.313,0 c 12.801,0 22.336,2.411 28.601,7.234 6.266,4.824 10.492,12.875 12.688,24.157 2.101,10.832 1.144,18.476 -2.871,22.929 C 36.909,3.773 28.87,6 16.808,6 L -4.946,6 -16.802,-55 M 28.831,29 C 47.198,29 60.597,24.18 69.019,14.539 77.44,4.898 79.976,-8.559 76.616,-25.836 75.233,-32.953 72.894,-39.46 69.601,-45.355 66.304,-51.254 61.999,-56.648 56.679,-61.539 50.339,-67.472 43.296,-71.7 35.546,-74.218 27.796,-76.743 17.925,-78 5.925,-78 l -27.196,0 -7.531,-38.75 -31.75,0 28.328,145.75 61.055,0" id="path3490" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
<g id="g3492" transform="translate(311.583,116.3066)">
<path d="m 0,0 c -0.896,0 -1.745,0.4 -2.314,1.092 -0.571,0.691 -0.802,1.6 -0.631,2.48 L 9.586,68.061 C 10.778,74.194 10.484,78.596 8.759,80.456 7.703,81.593 4.531,83.5 -4.848,83.5 L -27.55,83.5 -43.305,2.428 C -43.579,1.018 -44.814,0 -46.25,0 l -31.5,0 c -0.896,0 -1.745,0.4 -2.315,1.092 -0.57,0.691 -0.801,1.601 -0.63,2.48 l 28.328,145.751 c 0.274,1.409 1.509,2.427 2.945,2.427 l 31.5,0 c 0.896,0 1.745,-0.4 2.315,-1.091 0.57,-0.692 0.801,-1.601 0.63,-2.481 L -21.813,113 2.609,113 c 18.605,0 31.221,-3.28 38.569,-10.028 7.49,-6.884 9.827,-17.891 6.947,-32.719 L 34.945,2.428 C 34.671,1.018 33.437,0 32,0 L 0,0 Z" id="path3494" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
<g id="g3496" transform="translate(293.6611,271.0571)">
<path d="m 0,0 -31.5,0 c -2.873,0 -5.342,-2.036 -5.89,-4.855 l -28.328,-145.751 c -0.342,-1.759 0.12,-3.578 1.26,-4.961 1.14,-1.383 2.838,-2.183 4.63,-2.183 l 31.5,0 c 2.872,0 5.342,2.036 5.89,4.855 l 15.283,78.645 20.229,0 c 9.363,0 11.328,-2 11.407,-2.086 0.568,-0.611 1.315,-3.441 0.082,-9.781 l -12.531,-64.489 c -0.342,-1.759 0.12,-3.578 1.26,-4.961 1.14,-1.383 2.838,-2.183 4.63,-2.183 l 32,0 c 2.872,0 5.342,2.036 5.89,4.855 l 13.179,67.825 c 3.093,15.921 0.447,27.864 -7.861,35.5 -7.928,7.281 -21.208,10.82 -40.599,10.82 l -20.784,0 6.143,31.605 C 6.231,-5.386 5.77,-3.566 4.63,-2.184 3.49,-0.801 1.792,0 0,0 m 0,-6 -7.531,-38.75 28.062,0 c 17.657,0 29.836,-3.082 36.539,-9.238 6.703,-6.16 8.711,-16.141 6.032,-29.938 l -13.18,-67.824 -32,0 12.531,64.488 c 1.426,7.336 0.902,12.34 -1.574,15.008 -2.477,2.668 -7.746,4.004 -15.805,4.004 l -25.176,0 -16.226,-83.5 -31.5,0 L -31.5,-6 0,-6" id="path3498" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
<g id="g3500" transform="translate(409.5498,145.3066)">
<path d="m 0,0 c 12.065,0 21.072,2.225 26.771,6.611 5.638,4.34 9.532,11.861 11.574,22.353 1.903,9.806 1.178,16.653 -2.155,20.348 C 32.783,53.086 25.417,55 14.297,55 L -4.984,55 -15.673,0 0,0 Z m -63.062,-67.75 c -0.895,0 -1.745,0.4 -2.314,1.092 -0.57,0.691 -0.802,1.601 -0.631,2.48 L -37.679,81.573 C -37.404,82.982 -36.17,84 -34.733,84 L 26.32,84 C 45.509,84 59.79,78.79 68.768,68.513 77.793,58.183 80.579,43.742 77.051,25.592 75.613,18.198 73.144,11.331 69.709,5.183 66.27,-0.972 61.725,-6.667 56.198,-11.747 49.582,-17.939 42.094,-22.429 33.962,-25.071 25.959,-27.678 15.681,-29 3.414,-29 l -24.723,0 -7.057,-36.322 c -0.275,-1.41 -1.509,-2.428 -2.946,-2.428 l -31.75,0 z" id="path3502" style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
<g id="g3504" transform="translate(407.0391,197.3071)">
<path d="M 0,0 16.808,0 C 30.229,0 34.891,-2.945 36.475,-4.7 39.104,-7.614 39.6,-13.758 37.91,-22.466 36.012,-32.217 32.493,-39.13 27.452,-43.012 22.29,-46.986 13.898,-49 2.511,-49 L -9.522,-49 0,0 Z m 28.831,35 -61.054,0 c -2.872,0 -5.341,-2.036 -5.889,-4.855 L -66.44,-115.606 c -0.342,-1.759 0.12,-3.578 1.259,-4.961 1.14,-1.383 2.838,-2.183 4.63,-2.183 l 31.75,0 c 2.872,0 5.342,2.036 5.89,4.855 l 6.587,33.895 22.249,0 c 12.582,0 23.174,1.372 31.479,4.077 8.541,2.775 16.401,7.481 23.356,13.986 5.752,5.291 10.488,11.23 14.078,17.655 3.591,6.427 6.171,13.594 7.668,21.302 3.715,19.105 0.697,34.403 -8.969,45.467 C 63.965,29.444 48.924,35 28.831,35 m -45.632,-90 19.312,0 c 12.801,0 22.336,2.411 28.601,7.234 6.267,4.824 10.492,12.875 12.688,24.157 2.102,10.832 1.145,18.476 -2.871,22.929 C 36.909,3.773 28.87,6 16.808,6 L -4.946,6 -16.801,-55 M 28.831,29 C 47.198,29 60.597,24.18 69.019,14.539 77.441,4.898 79.976,-8.559 76.616,-25.836 75.233,-32.953 72.894,-39.46 69.601,-45.355 66.304,-51.254 61.999,-56.648 56.679,-61.539 50.339,-67.472 43.296,-71.7 35.546,-74.218 27.796,-76.743 17.925,-78 5.925,-78 l -27.196,0 -7.53,-38.75 -31.75,0 28.328,145.75 61.054,0" id="path3506" style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+8
View File
@@ -0,0 +1,8 @@
---
sidebarTitle: "Explore Options"
description: "Browse and search through all available app connections for Infisical."
---
import { AppConnectionsBrowser } from "/snippets/AppConnectionsBrowser.jsx";
<AppConnectionsBrowser />
+9
View File
@@ -0,0 +1,9 @@
---
title: "Dynamic Secrets"
sidebarTitle: "Explore Options"
description: "Browse and search through all available dynamic secrets for Infisical."
---
import { DynamicSecretsBrowser } from "/snippets/DynamicSecretsBrowser.jsx";
<DynamicSecretsBrowser />
@@ -0,0 +1,9 @@
---
title: "Framework Integrations"
sidebarTitle: "Explore Options"
description: "Browse and search through all available framework integrations for Infisical."
---
import { FrameworkIntegrationsBrowser } from "/snippets/FrameworkIntegrationsBrowser.jsx";
<FrameworkIntegrationsBrowser />
@@ -0,0 +1,9 @@
---
title: "Machine Authentication"
sidebarTitle: "Explore Options"
description: "Browse and search through all available machine authentication methods for Infisical."
---
import { MachineAuthenticationBrowser } from "/snippets/MachineAuthenticationBrowser.jsx";
<MachineAuthenticationBrowser />
+8
View File
@@ -0,0 +1,8 @@
---
sidebarTitle: "Explore Options"
description: "Browse and search through all available secret rotations for Infisical."
---
import { RotationsBrowser } from "/snippets/RotationsBrowser.jsx";
<RotationsBrowser />
+8
View File
@@ -0,0 +1,8 @@
---
sidebarTitle: "Explore Options"
description: "Browse and search through all available secret syncs for Infisical."
---
import { SecretSyncsBrowser } from "/snippets/SecretSyncsBrowser.jsx";
<SecretSyncsBrowser />
@@ -0,0 +1,9 @@
---
title: "User Authentication"
sidebarTitle: "Explore Options"
description: "Browse and search through all available user authentication methods for Infisical."
---
import { UserAuthenticationBrowser } from "/snippets/UserAuthenticationBrowser.jsx";
<UserAuthenticationBrowser />
+204
View File
@@ -0,0 +1,204 @@
---
title: "Infisical PHP SDK"
sidebarTitle: "PHP"
icon: "/images/sdks/languages/php.svg"
---
If you're working with PHP, the official Infisical PHP SDK package is the easiest way to fetch and work with secrets for your application.
## Installation
```bash
composer require infisical/php-sdk
```
## Getting Started
```php
<?php
use Infisical\SDK\InfisicalSDK;
$sdk = new InfisicalSDK('https://app.infisical.com');
// Authenticate with Infisical
$response = $sdk->auth()->universalAuth()->login(
"your-machine-identity-client-id",
"your-machine-identity-client-secret"
);
// List secrets
$params = new \Infisical\SDK\Models\ListSecretsParameters(
environment: "dev",
secretPath: "/",
projectId: "your-project-id"
);
$secrets = $sdk->secrets()->list($params);
echo "Fetched secrets: " . count($secrets) . "\n";
```
## Core Methods
The SDK methods are organized into the following high-level categories:
1. `auth`: Handles authentication methods.
2. `secrets`: Manages CRUD operations for secrets.
### `Auth`
The `auth` component provides methods for authentication:
#### Universal Auth
**Authenticating**
```php
$response = $sdk->auth()->universal_auth()->login(
"your-machine-identity-client-id",
"your-machine-identity-client-secret"
);
```
**Parameters:**
- `clientId` (string): The client ID of your Machine Identity.
- `clientSecret` (string): The client secret of your Machine Identity.
<Warning>
We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/overview). Setting them as environment variables would be best.
</Warning>
### `Secrets`
This sub-class handles operations related to secrets:
#### List Secrets
```php
use Infisical\SDK\Models\ListSecretsParameters;
$params = new ListSecretsParameters(
environment: "dev",
secretPath: "/",
projectId: "your-project-id",
tagSlugs: ["tag1", "tag2"], // Optional
recursive: true, // Optional
expandSecretReferences: true, // Optional
attachToProcessEnv: false, // Optional
skipUniqueValidation: false // Optional
);
$secrets = $sdk->secrets()->list($params);
```
**Parameters:**
- `environment` (string): The environment in which to list secrets (e.g., "dev").
- `projectId` (string): The ID of your project.
- `secretPath` (string, optional): The path to the secrets.
- `tagSlugs` (array, optional): Tags to filter secrets.
- `recursive` (bool, optional): Whether to list secrets recursively.
- `expandSecretReferences` (bool, optional): Whether to expand secret references.
- `attachToProcessEnv` (bool, optional): Whether to attach secrets to process environment variables.
- `skipUniqueValidation` (bool, optional): Whether to skip unique validation.
**Returns:**
- `Secret[]`: An array of secret objects.
#### Create Secret
```php
use Infisical\SDK\Models\CreateSecretParameters;
$params = new CreateSecretParameters(
secretKey: "SECRET_NAME",
secretValue: "SECRET_VALUE",
environment: "dev",
secretPath: "/",
projectId: "your-project-id"
);
$createdSecret = $sdk->secrets()->create($params);
```
**Parameters:**
- `secretKey` (string): The name of the secret to create.
- `secretValue` (string): The value of the secret.
- `environment` (string): The environment in which to create the secret.
- `projectId` (string): The ID of your project.
- `secretPath` (string, optional): The path to the secret.
**Returns:**
- `Secret`: The created secret object.
#### Get Secret
```php
use Infisical\SDK\Models\GetSecretParameters;
$params = new GetSecretParameters(
secretKey: "SECRET_NAME",
environment: "dev",
secretPath: "/",
projectId: "your-project-id"
);
$secret = $sdk->secrets()->get($params);
```
**Parameters:**
- `secretKey` (string): The name of the secret to retrieve.
- `environment` (string): The environment in which to retrieve the secret.
- `projectId` (string): The ID of your project.
- `secretPath` (string, optional): The path to the secret.
**Returns:**
- `Secret`: The retrieved secret object.
#### Update Secret
```php
use Infisical\SDK\Models\UpdateSecretParameters;
$params = new UpdateSecretParameters(
secretKey: "SECRET_NAME",
newSecretValue: "UPDATED_SECRET_VALUE",
environment: "dev",
secretPath: "/",
projectId: "your-project-id"
);
$updatedSecret = $sdk->secrets()->update($params);
```
**Parameters:**
- `secretKey` (string): The name of the secret to update.
- `newSecretValue` (string): The new value of the secret.
- `environment` (string): The environment in which to update the secret.
- `projectId` (string): The ID of your project.
- `secretPath` (string, optional): The path to the secret.
**Returns:**
- `Secret`: The updated secret object.
#### Delete Secret
```php
use Infisical\SDK\Models\DeleteSecretParameters;
$params = new DeleteSecretParameters(
secretKey: "SECRET_NAME",
environment: "dev",
secretPath: "/",
projectId: "your-project-id"
);
$deletedSecret = $sdk->secrets()->delete($params);
```
**Parameters:**
- `secretKey` (string): The name of the secret to delete.
- `environment` (string): The environment in which to delete the secret.
- `projectId` (string): The ID of your project.
- `secretPath` (string, optional): The path to the secret.
**Returns:**
- `Secret`: The deleted secret object.
+3
View File
@@ -32,6 +32,9 @@ From local development to production, Infisical SDKs provide the easiest way for
</Card> </Card>
<Card href="/sdks/languages/go" title="Go" icon="/images/sdks/languages/go.svg"> <Card href="/sdks/languages/go" title="Go" icon="/images/sdks/languages/go.svg">
Manage secrets for your Go application on demand Manage secrets for your Go application on demand
</Card>
<Card href="/sdks/languages/php" title="PHP" icon="/images/sdks/languages/php.svg">
Manage secrets for your PHP application on demand
</Card> </Card>
<Card href="/sdks/languages/ruby" title="Ruby" icon="/images/sdks/languages/ruby.svg"> <Card href="/sdks/languages/ruby" title="Ruby" icon="/images/sdks/languages/ruby.svg">
Manage secrets for your Ruby application on demand Manage secrets for your Ruby application on demand
+24 -1
View File
@@ -142,7 +142,7 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}]
Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup.
<Tabs> <Tabs>
<Tab title="Redis standalone"> <Tab title="Redis Standalone">
<ParamField query="REDIS_URL" type="string" default="none" required> <ParamField query="REDIS_URL" type="string" default="none" required>
Redis connection string. Redis connection string.
</ParamField> </ParamField>
@@ -173,6 +173,29 @@ Redis is used for caching and background tasks. You can use either a standalone
<ParamField query="REDIS_SENTINEL_PASSWORD" type="string" default="none"> <ParamField query="REDIS_SENTINEL_PASSWORD" type="string" default="none">
Authentication password for Redis Sentinel Authentication password for Redis Sentinel
</ParamField> </ParamField>
<ParamField query="REDIS_USERNAME" type="string" default="none">
Authentication username for Redis Node
</ParamField>
<ParamField query="REDIS_PASSWORD" type="string" default="none">
Authentication password for Redis Node
</ParamField>
</Tab>
<Tab title="Redis Cluster">
<ParamField
query="REDIS_CLUSTER_HOSTS"
type="string"
default="none"
required
>
Comma-separated list of Redis Cluster host:port pairs. ```
192.168.65.254:26379,192.168.65.254:26380 ```
</ParamField>
<ParamField query="REDIS_USERNAME" type="string" default="none">
Authentication username for Redis Node
</ParamField>
<ParamField query="REDIS_PASSWORD" type="string" default="none">
Authentication password for Redis Node
</ParamField>
</Tab> </Tab>
</Tabs> </Tabs>
+155
View File
@@ -0,0 +1,155 @@
import React, { useState, useMemo } from 'react';
export const AppConnectionsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security'];
const connections = [
{"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"},
{"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"},
{"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"},
{"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"},
{"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"},
{"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"},
{"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"},
{"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"},
{"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"},
{"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"},
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"},
{"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"},
{"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"},
{"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"},
{"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"},
{"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"},
{"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"},
{"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"},
{"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredConnections = useMemo(() => {
let filtered = connections;
if (selectedCategory !== 'All') {
filtered = filtered.filter(connection => connection.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(connection =>
connection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
connection.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
connection.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search app connections..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredConnections.length} app connection{filteredConnections.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Connections List */}
{filteredConnections.length > 0 ? (
<div className="space-y-4">
{filteredConnections.map((connection, index) => (
<a
key={connection.slug}
href={connection.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{connection.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{connection.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{connection.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No app connections found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useMemo } from 'react';
export const DynamicSecretsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches', 'Directory Services', 'CI/CD', 'Container Orchestration', 'Authentication'];
const dynamicSecrets = [
{"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"},
{"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"},
{"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"},
{"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"},
{"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"},
{"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"},
{"name": "MongoDB", "slug": "mongodb", "path": "/documentation/platform/dynamic-secrets/mongo-db", "description": "Learn how to generate dynamic MongoDB database credentials on-demand.", "category": "Databases"},
{"name": "MongoDB Atlas", "slug": "mongodb-atlas", "path": "/documentation/platform/dynamic-secrets/mongo-atlas", "description": "Learn how to generate dynamic MongoDB Atlas database credentials on-demand.", "category": "Databases"},
{"name": "MySQL", "slug": "mysql", "path": "/documentation/platform/dynamic-secrets/mysql", "description": "Learn how to generate dynamic MySQL database credentials on-demand.", "category": "Databases"},
{"name": "PostgreSQL", "slug": "postgresql", "path": "/documentation/platform/dynamic-secrets/postgresql", "description": "Learn how to generate dynamic PostgreSQL database credentials on-demand.", "category": "Databases"},
{"name": "Microsoft SQL Server", "slug": "mssql", "path": "/documentation/platform/dynamic-secrets/mssql", "description": "Learn how to generate dynamic Microsoft SQL Server credentials on-demand.", "category": "Databases"},
{"name": "Oracle Database", "slug": "oracle", "path": "/documentation/platform/dynamic-secrets/oracle", "description": "Learn how to generate dynamic Oracle Database credentials on-demand.", "category": "Databases"},
{"name": "SAP ASE", "slug": "sap-ase", "path": "/documentation/platform/dynamic-secrets/sap-ase", "description": "Learn how to generate dynamic SAP ASE database credentials on-demand.", "category": "Databases"},
{"name": "SAP HANA", "slug": "sap-hana", "path": "/documentation/platform/dynamic-secrets/sap-hana", "description": "Learn how to generate dynamic SAP HANA database credentials on-demand.", "category": "Databases"},
{"name": "Snowflake", "slug": "snowflake", "path": "/documentation/platform/dynamic-secrets/snowflake", "description": "Learn how to generate dynamic Snowflake database credentials on-demand.", "category": "Databases"},
{"name": "Vertica", "slug": "vertica", "path": "/documentation/platform/dynamic-secrets/vertica", "description": "Learn how to generate dynamic Vertica database credentials on-demand.", "category": "Databases"},
{"name": "Redis", "slug": "redis", "path": "/documentation/platform/dynamic-secrets/redis", "description": "Learn how to generate dynamic Redis credentials on-demand.", "category": "Caches"},
{"name": "ElasticSearch", "slug": "elasticsearch", "path": "/documentation/platform/dynamic-secrets/elastic-search", "description": "Learn how to generate dynamic ElasticSearch credentials on-demand.", "category": "Databases"},
{"name": "RabbitMQ", "slug": "rabbitmq", "path": "/documentation/platform/dynamic-secrets/rabbit-mq", "description": "Learn how to generate dynamic RabbitMQ credentials on-demand.", "category": "Message Queues"},
{"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/dynamic-secrets/ldap", "description": "Learn how to generate dynamic LDAP credentials on-demand.", "category": "Directory Services"},
{"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "CI/CD"},
{"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Container Orchestration"},
{"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Authentication"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredDynamicSecrets = useMemo(() => {
let filtered = dynamicSecrets;
if (selectedCategory !== 'All') {
filtered = filtered.filter(secret => secret.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(secret =>
secret.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
secret.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
secret.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search dynamic secrets..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredDynamicSecrets.length} dynamic secret{filteredDynamicSecrets.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Dynamic Secrets List */}
{filteredDynamicSecrets.length > 0 ? (
<div className="space-y-4">
{filteredDynamicSecrets.map((secret, index) => (
<a
key={secret.slug}
href={secret.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{secret.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{secret.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{secret.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No dynamic secrets found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,101 @@
import React, { useState, useMemo } from 'react';
export const FrameworkIntegrationsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const integrations = [
{"name": "React", "slug": "react", "path": "/integrations/frameworks/react", "description": "Learn how to integrate Infisical with React applications for secure secret management.", "category": "Web Frameworks"},
{"name": "Next.js", "slug": "nextjs", "path": "/integrations/frameworks/nextjs", "description": "Learn how to integrate Infisical with Next.js applications.", "category": "Web Frameworks"},
{"name": "Vue", "slug": "vuejs", "path": "/integrations/frameworks/vue", "description": "Learn how to integrate Infisical with Vue.js applications.", "category": "Web Frameworks"},
{"name": "Nuxt", "slug": "nuxtjs", "path": "/integrations/frameworks/nuxt", "description": "Learn how to integrate Infisical with Nuxt.js applications.", "category": "Web Frameworks"},
{"name": "SvelteKit", "slug": "sveltekit", "path": "/integrations/frameworks/sveltekit", "description": "Learn how to integrate Infisical with SvelteKit applications.", "category": "Web Frameworks"},
{"name": "Express, Fastify, Koa", "slug": "express", "path": "/integrations/frameworks/express", "description": "Learn how to integrate Infisical with Express.js backend applications.", "category": "Web Frameworks"},
{"name": "NestJS", "slug": "nestjs", "path": "/integrations/frameworks/nestjs", "description": "Learn how to integrate Infisical with NestJS applications.", "category": "Web Frameworks"},
{"name": "Django", "slug": "django", "path": "/integrations/frameworks/django", "description": "Learn how to integrate Infisical with Django applications.", "category": "Web Frameworks"},
{"name": "Flask", "slug": "flask", "path": "/integrations/frameworks/flask", "description": "Learn how to integrate Infisical with Flask applications.", "category": "Web Frameworks"},
{"name": "Ruby on Rails", "slug": "rails", "path": "/integrations/frameworks/rails", "description": "Learn how to integrate Infisical with Ruby on Rails applications.", "category": "Web Frameworks"},
{"name": "Spring Boot", "slug": "spring-boot-maven", "path": "/integrations/frameworks/spring-boot-maven", "description": "Learn how to integrate Infisical with Spring Boot applications.", "category": "Web Frameworks"},
{"name": "Laravel", "slug": "laravel", "path": "/integrations/frameworks/laravel", "description": "Learn how to integrate Infisical with Laravel applications.", "category": "Web Frameworks"},
{"name": ".NET", "slug": "dotnet", "path": "/integrations/frameworks/dotnet", "description": "Learn how to integrate Infisical with .NET applications.", "category": "Web Frameworks"},
{"name": "Fiber", "slug": "fiber", "path": "/integrations/frameworks/fiber", "description": "Learn how to integrate Infisical with Fiber (Go) framework.", "category": "Web Frameworks"},
{"name": "Gatsby", "slug": "gatsby", "path": "/integrations/frameworks/gatsby", "description": "Learn how to integrate Infisical with Gatsby applications.", "category": "Web Frameworks"},
{"name": "Remix", "slug": "remix", "path": "/integrations/frameworks/remix", "description": "Learn how to integrate Infisical with Remix applications.", "category": "Web Frameworks"},
{"name": "Vite", "slug": "vite", "path": "/integrations/frameworks/vite", "description": "Learn how to integrate Infisical with Vite applications.", "category": "Web Frameworks"},
{"name": "AB Initio", "slug": "ab-initio", "path": "/integrations/frameworks/ab-initio", "description": "Learn how to integrate Infisical with AB Initio applications.", "category": "Web Frameworks"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredIntegrations = useMemo(() => {
if (searchTerm) {
return integrations.filter(integration =>
integration.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
integration.description.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return integrations;
}, [searchTerm]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search framework integrations..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredIntegrations.length} framework integration{filteredIntegrations.length !== 1 ? 's' : ''} found
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Integrations List */}
{filteredIntegrations.length > 0 ? (
<div className="space-y-4">
{filteredIntegrations.map((integration, index) => (
<a
key={integration.slug}
href={integration.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{integration.name}
</h3>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{integration.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No framework integrations found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,136 @@
import React, { useState, useMemo } from 'react';
export const MachineAuthenticationBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Token-based', 'Cloud Provider', 'Kubernetes', 'Certificate-based', 'Directory-based'];
const authMethods = [
{"name": "Universal Auth", "slug": "universal-auth", "path": "/documentation/platform/identities/universal-auth", "description": "Learn how to authenticate machines using Universal Auth tokens with client ID and secret.", "category": "Token-based"},
{"name": "Token Auth", "slug": "token-auth", "path": "/documentation/platform/identities/token-auth", "description": "Learn how to authenticate machines using long-lived access tokens.", "category": "Token-based"},
{"name": "JWT Auth", "slug": "jwt-auth", "path": "/documentation/platform/identities/jwt-auth", "description": "Learn how to authenticate machines using JSON Web Tokens (JWT).", "category": "Token-based"},
{"name": "AWS Auth", "slug": "aws-iam-auth", "path": "/documentation/platform/identities/aws-auth", "description": "Learn how to authenticate AWS services and resources using IAM roles.", "category": "Cloud Provider"},
{"name": "Azure Auth", "slug": "azure-auth", "path": "/documentation/platform/identities/azure-auth", "description": "Learn how to authenticate Azure services using managed identities.", "category": "Cloud Provider"},
{"name": "GCP Auth", "slug": "gcp-auth", "path": "/documentation/platform/identities/gcp-auth", "description": "Learn how to authenticate GCP services using service accounts.", "category": "Cloud Provider"},
{"name": "Alibaba Cloud Auth", "slug": "alicloud-auth", "path": "/documentation/platform/identities/alicloud-auth", "description": "Learn how to authenticate Alibaba Cloud services using RAM roles.", "category": "Cloud Provider"},
{"name": "OCI Auth", "slug": "oci-auth", "path": "/documentation/platform/identities/oci-auth", "description": "Learn how to authenticate Oracle Cloud Infrastructure services.", "category": "Cloud Provider"},
{"name": "Kubernetes Auth", "slug": "kubernetes-auth", "path": "/documentation/platform/identities/kubernetes-auth", "description": "Learn how to authenticate Kubernetes workloads using service account tokens.", "category": "Kubernetes"},
{"name": "OIDC Auth", "slug": "oidc-auth-general", "path": "/documentation/platform/identities/oidc-auth/general", "description": "Learn how to authenticate machines using OpenID Connect (OIDC) providers.", "category": "Token-based"},
{"name": "OIDC Auth for GitHub Actions", "slug": "oidc-auth-github", "path": "/documentation/platform/identities/oidc-auth/github", "description": "Learn how to authenticate GitHub Actions using OIDC.", "category": "Token-based"},
{"name": "OIDC Auth for GitLab CI/CD", "slug": "oidc-auth-gitlab", "path": "/documentation/platform/identities/oidc-auth/gitlab", "description": "Learn how to authenticate GitLab CI/CD using OIDC.", "category": "Token-based"},
{"name": "OIDC Auth for Azure", "slug": "oidc-auth-azure", "path": "/documentation/platform/identities/oidc-auth/azure", "description": "Learn how to authenticate Azure services using OIDC.", "category": "Token-based"},
{"name": "OIDC Auth for CircleCI", "slug": "oidc-auth-circleci", "path": "/documentation/platform/identities/oidc-auth/circleci", "description": "Learn how to authenticate CircleCI workflows using OIDC.", "category": "Token-based"},
{"name": "OIDC Auth for Terraform Cloud", "slug": "oidc-auth-terraform", "path": "/documentation/platform/identities/oidc-auth/terraform-cloud", "description": "Learn how to authenticate Terraform Cloud using OIDC.", "category": "Token-based"},
{"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"},
{"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"},
{"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"},
{"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredAuthMethods = useMemo(() => {
let filtered = authMethods;
if (selectedCategory !== 'All') {
filtered = filtered.filter(method => method.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(method =>
method.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
method.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
method.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search machine authentication methods..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredAuthMethods.length} authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Authentication Methods List */}
{filteredAuthMethods.length > 0 ? (
<div className="space-y-4">
{filteredAuthMethods.map((method, index) => (
<a
key={method.slug}
href={method.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{method.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{method.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{method.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No authentication methods found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
+126
View File
@@ -0,0 +1,126 @@
import React, { useState, useMemo } from 'react';
export const RotationsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Databases', 'Identity & Auth', 'Cloud Providers'];
const rotations = [
{"name": "AWS IAM User", "slug": "aws-iam-user", "path": "/documentation/platform/secret-rotation/aws-iam-user-secret", "description": "Learn how to automatically rotate AWS IAM user access keys.", "category": "Cloud Providers"},
{"name": "Azure Client Secret", "slug": "azure-client-secret", "path": "/documentation/platform/secret-rotation/azure-client-secret", "description": "Learn how to automatically rotate Azure client secrets.", "category": "Cloud Providers"},
{"name": "Auth0 Client Secret", "slug": "auth0-client-secret", "path": "/documentation/platform/secret-rotation/auth0-client-secret", "description": "Learn how to automatically rotate Auth0 client secrets.", "category": "Identity & Auth"},
{"name": "Okta Client Secret", "slug": "okta-client-secret", "path": "/documentation/platform/secret-rotation/okta-client-secret", "description": "Learn how to automatically rotate Okta client secrets.", "category": "Identity & Auth"},
{"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"},
{"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"},
{"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"},
{"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"},
{"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredRotations = useMemo(() => {
let filtered = rotations;
if (selectedCategory !== 'All') {
filtered = filtered.filter(rotation => rotation.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(rotation =>
rotation.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
rotation.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
rotation.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search secret rotations..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredRotations.length} secret rotation{filteredRotations.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Rotations List */}
{filteredRotations.length > 0 ? (
<div className="space-y-4">
{filteredRotations.map((rotation, index) => (
<a
key={rotation.slug}
href={rotation.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{rotation.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{rotation.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{rotation.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No secret rotations found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
+147
View File
@@ -0,0 +1,147 @@
import React, { useState, useMemo } from 'react';
export const SecretSyncsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security'];
const syncs = [
{"name": "AWS Parameter Store", "slug": "aws-parameter-store", "path": "/integrations/secret-syncs/aws-parameter-store", "description": "Learn how to sync secrets from Infisical to AWS Parameter Store.", "category": "Cloud Providers"},
{"name": "AWS Secrets Manager", "slug": "aws-secrets-manager", "path": "/integrations/secret-syncs/aws-secrets-manager", "description": "Learn how to sync secrets from Infisical to AWS Secrets Manager.", "category": "Cloud Providers"},
{"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/secret-syncs/azure-key-vault", "description": "Learn how to sync secrets from Infisical to Azure Key Vault.", "category": "Cloud Providers"},
{"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/secret-syncs/azure-app-configuration", "description": "Learn how to sync secrets from Infisical to Azure App Configuration.", "category": "Cloud Providers"},
{"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/secret-syncs/azure-devops", "description": "Learn how to sync secrets from Infisical to Azure DevOps.", "category": "CI/CD"},
{"name": "GCP Secret Manager", "slug": "gcp-secret-manager", "path": "/integrations/secret-syncs/gcp-secret-manager", "description": "Learn how to sync secrets from Infisical to GCP Secret Manager.", "category": "Cloud Providers"},
{"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/secret-syncs/hashicorp-vault", "description": "Learn how to sync secrets from Infisical to HashiCorp Vault.", "category": "Security"},
{"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Security"},
{"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Hosting"},
{"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Hosting"},
{"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Hosting"},
{"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Hosting"},
{"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Hosting"},
{"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Hosting"},
{"name": "DigitalOcean App Platform", "slug": "digital-ocean-app-platform", "path": "/integrations/secret-syncs/digital-ocean-app-platform", "description": "Learn how to sync secrets from Infisical to DigitalOcean App Platform.", "category": "Hosting"},
{"name": "Supabase", "slug": "supabase", "path": "/integrations/secret-syncs/supabase", "description": "Learn how to sync secrets from Infisical to Supabase.", "category": "Databases"},
{"name": "Checkly", "slug": "checkly", "path": "/integrations/secret-syncs/checkly", "description": "Learn how to sync secrets from Infisical to Checkly.", "category": "Monitoring"},
{"name": "GitHub", "slug": "github", "path": "/integrations/secret-syncs/github", "description": "Learn how to sync secrets from Infisical to GitHub.", "category": "CI/CD"},
{"name": "GitLab", "slug": "gitlab", "path": "/integrations/secret-syncs/gitlab", "description": "Learn how to sync secrets from Infisical to GitLab.", "category": "CI/CD"},
{"name": "TeamCity", "slug": "teamcity", "path": "/integrations/secret-syncs/teamcity", "description": "Learn how to sync secrets from Infisical to TeamCity.", "category": "CI/CD"},
{"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/secret-syncs/bitbucket", "description": "Learn how to sync secrets from Infisical to Bitbucket.", "category": "CI/CD"},
{"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/secret-syncs/terraform-cloud", "description": "Learn how to sync secrets from Infisical to Terraform Cloud.", "category": "DevOps Tools"},
{"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Hosting"},
{"name": "Cloudflare Workers", "slug": "cloudflare-workers", "path": "/integrations/secret-syncs/cloudflare-workers", "description": "Learn how to sync secrets from Infisical to Cloudflare Workers.", "category": "Cloud Providers"},
{"name": "Databricks", "slug": "databricks", "path": "/integrations/secret-syncs/databricks", "description": "Learn how to sync secrets from Infisical to Databricks.", "category": "Data Analytics"},
{"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "DevOps Tools"},
{"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"},
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"},
{"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"},
{"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredSyncs = useMemo(() => {
let filtered = syncs;
if (selectedCategory !== 'All') {
filtered = filtered.filter(sync => sync.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(sync =>
sync.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
sync.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
sync.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search secret syncs..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredSyncs.length} secret sync{filteredSyncs.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Secret Syncs List */}
{filteredSyncs.length > 0 ? (
<div className="space-y-4">
{filteredSyncs.map((sync, index) => (
<a
key={sync.slug}
href={sync.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{sync.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{sync.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{sync.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No secret syncs found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
+135
View File
@@ -0,0 +1,135 @@
import React, { useState, useMemo } from 'react';
export const UserAuthenticationBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'SSO', 'LDAP', 'SCIM', 'General'];
const authMethods = [
{"name": "Auth0 OIDC", "slug": "auth0-oidc-sso", "path": "/documentation/platform/sso/auth0-oidc", "description": "Learn how to configure Auth0 OIDC SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Auth0 SAML", "slug": "auth0-saml-sso", "path": "/documentation/platform/sso/auth0-saml", "description": "Learn how to configure Auth0 SAML SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Entra ID / Azure AD SAML", "slug": "azure-ad-sso", "path": "/documentation/platform/sso/azure", "description": "Learn how to configure Azure Active Directory (Entra ID) SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Google", "slug": "google-sso", "path": "/documentation/platform/sso/google", "description": "Learn how to configure Google SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Google SAML", "slug": "google-saml-sso", "path": "/documentation/platform/sso/google-saml", "description": "Learn how to configure Google SAML SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "GitHub", "slug": "github-sso", "path": "/documentation/platform/sso/github", "description": "Learn how to configure GitHub SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "GitLab", "slug": "gitlab-sso", "path": "/documentation/platform/sso/gitlab", "description": "Learn how to configure GitLab SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "JumpCloud", "slug": "jumpcloud-sso", "path": "/documentation/platform/sso/jumpcloud", "description": "Learn how to configure JumpCloud SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Keycloak OIDC", "slug": "keycloak-oidc-sso", "path": "/documentation/platform/sso/keycloak-oidc/overview", "description": "Learn how to configure Keycloak OIDC SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Keycloak SAML", "slug": "keycloak-saml-sso", "path": "/documentation/platform/sso/keycloak-saml", "description": "Learn how to configure Keycloak SAML SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Okta", "slug": "okta-oidc-sso", "path": "/documentation/platform/sso/okta", "description": "Learn how to configure Okta OIDC SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "Okta SAML", "slug": "okta-saml-sso", "path": "/documentation/platform/sso/okta-saml", "description": "Learn how to configure Okta SAML SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "OneLogin SAML", "slug": "onelogin-saml-sso", "path": "/documentation/platform/sso/onelogin-saml", "description": "Learn how to configure OneLogin SAML SSO for user authentication in Infisical.", "category": "SSO"},
{"name": "General OIDC", "slug": "general-oidc-sso", "path": "/documentation/platform/sso/general-oidc", "description": "Learn how to configure generic OIDC providers for SSO in Infisical.", "category": "SSO"},
{"name": "General SAML 2.0", "slug": "general-saml-sso", "path": "/documentation/platform/sso/general-saml", "description": "Learn how to configure generic SAML 2.0 providers for SSO in Infisical.", "category": "SSO"},
{"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/ldap/overview", "description": "Learn how to configure LDAP authentication for user login in Infisical.", "category": "LDAP"},
{"name": "SCIM", "slug": "scim", "path": "/documentation/platform/scim/overview", "description": "Learn how to configure SCIM provisioning for automated user management in Infisical.", "category": "SCIM"},
{"name": "Email/Password", "slug": "email-password", "path": "/documentation/getting-started/introduction", "description": "Learn how to use standard email and password authentication in Infisical.", "category": "General"}
].sort(function(a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const filteredAuthMethods = useMemo(() => {
let filtered = authMethods;
if (selectedCategory !== 'All') {
filtered = filtered.filter(method => method.category === selectedCategory);
}
if (searchTerm) {
filtered = filtered.filter(method =>
method.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
method.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
method.category.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
}, [searchTerm, selectedCategory]);
return (
<div className="max-w-none">
{/* Search Bar */}
<div className="mb-6">
<div className="relative w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search user authentication methods..."
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Category Filter */}
<div className="mb-6">
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
selectedCategory === category
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
}`}
>
{category}
</button>
))}
</div>
</div>
{/* Results Count */}
<div className="mb-4">
<p className="text-sm text-gray-600">
{filteredAuthMethods.length} user authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
{searchTerm && ` for "${searchTerm}"`}
</p>
</div>
{/* Authentication Methods List */}
{filteredAuthMethods.length > 0 ? (
<div className="space-y-4">
{filteredAuthMethods.map((method, index) => (
<a
key={method.slug}
href={method.path}
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
>
<div className="w-full">
<div className="flex items-center justify-between mb-0.5">
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
{method.name}
</h3>
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
{method.category}
</span>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{method.description}
</p>
</div>
</a>
))}
</div>
) : (
<div className="text-center py-8">
<div className="flex flex-col items-center space-y-2">
<p className="text-gray-500">No user authentication methods found matching your criteria</p>
{searchTerm && (
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
)}
</div>
</div>
)}
</div>
);
};
@@ -1,6 +1,7 @@
import { import {
faCheck, faCheck,
faExclamationTriangle, faExclamationTriangle,
faHourglass,
faRotate, faRotate,
IconDefinition IconDefinition
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -29,7 +30,11 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
text = "Synced"; text = "Synced";
icon = faCheck; icon = faCheck;
break; break;
case SecretSyncStatus.Pending: // no need to differentiate from user perspective case SecretSyncStatus.Pending:
variant = "primary";
text = "Queued";
icon = faHourglass;
break;
case SecretSyncStatus.Running: case SecretSyncStatus.Running:
default: default:
variant = "primary"; variant = "primary";
@@ -42,11 +47,7 @@ export const SecretSyncStatusBadge = ({ status }: Props) => {
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}> <Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap" variant={variant}>
<FontAwesomeIcon <FontAwesomeIcon
icon={icon} icon={icon}
className={ className={[SecretSyncStatus.Running].includes(status) ? "animate-spin" : ""}
[SecretSyncStatus.Pending, SecretSyncStatus.Running].includes(status)
? "animate-spin"
: ""
}
/> />
<span>{text}</span> <span>{text}</span>
</Badge> </Badge>
@@ -60,7 +60,8 @@ export const CreatableSelect = <T,>({
isSelected && "text-mineshaft-200", isSelected && "text-mineshaft-200",
"px-3 py-2 text-xs hover:cursor-pointer" "px-3 py-2 text-xs hover:cursor-pointer"
), ),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
}} }}
{...props} {...props}
/> />
@@ -91,7 +91,7 @@ export const FilterableSelect = <T,>({
), ),
placeholder: () => placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1", input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`,
valueContainer: () => valueContainer: () =>
`px-1 max-h-[8.2rem] ${ `px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]" isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
@@ -114,7 +114,8 @@ export const FilterableSelect = <T,>({
isSelected && "text-mineshaft-200", isSelected && "text-mineshaft-200",
"rounded px-3 py-2 text-xs hover:cursor-pointer" "rounded px-3 py-2 text-xs hover:cursor-pointer"
), ),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md",
loadingMessage: () => "text-mineshaft-400 p-2 rounded-md"
}} }}
{...props} {...props}
/> />
+12
View File
@@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(blob, filename); FileSaver.saveAs(blob, filename);
}; };
export const downloadFile = (content: string, filename: string, mimeType: string = "text/csv") => {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
+60 -1
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { Organization } from "@app/hooks/api/organization/types";
import { organizationKeys } from "../organization/queries"; import { organizationKeys } from "../organization/queries";
import { User } from "../users/types"; import { User } from "../users/types";
@@ -8,9 +9,12 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
import { import {
RootKeyEncryptionStrategy, RootKeyEncryptionStrategy,
TCreateAdminUserDTO, TCreateAdminUserDTO,
TCreateOrganizationDTO,
TInvalidateCacheDTO, TInvalidateCacheDTO,
TResendOrgInviteDTO,
TServerConfig, TServerConfig,
TUpdateServerConfigDTO TUpdateServerConfigDTO,
TUsageReportResponse
} from "./types"; } from "./types";
export const useCreateAdminUser = () => { export const useCreateAdminUser = () => {
@@ -193,3 +197,58 @@ export const useInvalidateCache = () => {
} }
}); });
}; };
export const useServerAdminCreateOrganization = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (opt: TCreateOrganizationDTO) => {
const { data } = await apiRequest.post<{ organization: Organization }>(
"/api/v1/admin/organization-management/organizations",
opt
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
}
});
};
export const useServerAdminResendOrgInvite = () => {
return useMutation({
mutationFn: async ({ organizationId, membershipId }: TResendOrgInviteDTO) => {
await apiRequest.post(
`/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}/resend-invite`
);
}
});
};
export const useServerAdminAccessOrg = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (orgId: string) => {
const { data } = await apiRequest.post(
`/api/v1/admin/organization-management/organizations/${orgId}/access`
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations });
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getOrganizations() });
}
});
};
export const useGenerateUsageReport = () => {
return useMutation<TUsageReportResponse, object, void>({
mutationFn: async () => {
const { data } = await apiRequest.post<TUsageReportResponse>(
"/api/v1/admin/usage-report/generate"
);
return data;
}
});
};
+24 -5
View File
@@ -1,4 +1,11 @@
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import {
DefaultError,
InfiniteData,
UndefinedInitialDataInfiniteOptions,
useInfiniteQuery,
useQuery,
UseQueryOptions
} from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { Identity } from "@app/hooks/api/identities/types"; import { Identity } from "@app/hooks/api/identities/types";
@@ -25,8 +32,8 @@ export const adminStandaloneKeys = {
export const adminQueryKeys = { export const adminQueryKeys = {
serverConfig: () => ["server-config"] as const, serverConfig: () => ["server-config"] as const,
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
getOrganizations: (filters: AdminGetOrganizationsFilters) => getOrganizations: (filters?: AdminGetOrganizationsFilters) =>
[adminStandaloneKeys.getOrganizations, { filters }] as const, [adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const,
getIdentities: (filters: AdminGetIdentitiesFilters) => getIdentities: (filters: AdminGetIdentitiesFilters) =>
[adminStandaloneKeys.getIdentities, { filters }] as const, [adminStandaloneKeys.getIdentities, { filters }] as const,
getAdminSlackConfig: () => ["admin-slack-config"] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const,
@@ -83,7 +90,18 @@ export const useGetServerConfig = ({
enabled: options?.enabled ?? true enabled: options?.enabled ?? true
}); });
export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { export const useAdminGetUsers = (
filters: AdminGetUsersFilters,
options?: Partial<
UndefinedInitialDataInfiniteOptions<
User[],
DefaultError,
InfiniteData<User[]>,
ReturnType<typeof adminQueryKeys.getUsers>,
number
>
>
) => {
return useInfiniteQuery({ return useInfiniteQuery({
initialPageParam: 0, initialPageParam: 0,
queryKey: adminQueryKeys.getUsers(filters), queryKey: adminQueryKeys.getUsers(filters),
@@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
return data.users; return data.users;
}, },
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined lastPage.length !== 0 ? pages.length * filters.limit : undefined,
...options
}); });
}; };
+20
View File
@@ -1,3 +1,5 @@
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { Organization } from "../types"; import { Organization } from "../types";
export enum LoginMethod { export enum LoginMethod {
@@ -20,6 +22,7 @@ export type OrganizationWithProjects = Organization & {
lastName: string | null; lastName: string | null;
}; };
membershipId: string; membershipId: string;
status: OrgMembershipStatus;
role: string; role: string;
roleId: string | null; roleId: string | null;
}[]; }[];
@@ -53,6 +56,7 @@ export type TServerConfig = {
fipsEnabled: boolean; fipsEnabled: boolean;
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
paramsFolderSecretDetectionEnabled: boolean; paramsFolderSecretDetectionEnabled: boolean;
isOfflineUsageReportsEnabled: boolean;
}; };
export type TUpdateServerConfigDTO = { export type TUpdateServerConfigDTO = {
@@ -142,3 +146,19 @@ export interface TGetEnvOverrides {
fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[]; fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[];
}; };
} }
export type TUsageReportResponse = {
filename: string;
csvContent: string;
signature: string;
};
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
@@ -1 +1,2 @@
export * from "./mutations"; export * from "./mutations";
export * from "./queries";
@@ -0,0 +1,20 @@
import { apiRequest } from "@app/config/request";
import { useQuery } from "@tanstack/react-query";
import { ExternalMigrationProviders } from "./types";
const externalMigrationQueryKeys = {
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
"custom-migration-available",
provider
]
};
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
return useQuery({
queryKey: externalMigrationQueryKeys.customMigrationAvailable(provider),
queryFn: () =>
apiRequest.get<{ enabled: boolean }>(
`/api/v3/external-migration/custom-migration-enabled/${provider}`
)
});
};
@@ -0,0 +1,4 @@
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
@@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy {
Name = "name", Name = "name",
Role = "role" Role = "role"
} }
export enum OrgMembershipStatus {
Invited = "invited",
Accepted = "accepted"
}
+3 -3
View File
@@ -19,10 +19,10 @@ export type User = {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
username: string; username: string;
email?: string; email?: string | null;
superAdmin: boolean; superAdmin: boolean;
firstName?: string; firstName?: string | null;
lastName?: string; lastName?: string | null;
authProvider?: AuthMethod; authProvider?: AuthMethod;
authMethods: AuthMethod[]; authMethods: AuthMethod[];
isMfaEnabled: boolean; isMfaEnabled: boolean;
@@ -1,11 +1,15 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { faMobile } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Outlet, useRouterState } from "@tanstack/react-router"; import { Outlet } from "@tanstack/react-router";
import { Banner } from "@app/components/page-frames/Banner"; import { Banner } from "@app/components/page-frames/Banner";
import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2"; import { useServerConfig, useSubscription } from "@app/context";
import { useServerConfig } from "@app/context"; import { useFetchServerStatus } from "@app/hooks/api";
import { AuditLogBanner } from "@app/layouts/OrganizationLayout/components/AuditLogBanner";
import { Navbar } from "@app/layouts/OrganizationLayout/components/NavBar";
import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBanner";
import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
import { AdminSidebar } from "./Sidebar"; import { AdminSidebar } from "./Sidebar";
@@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar";
export const AdminLayout = () => { export const AdminLayout = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useServerConfig(); const { config } = useServerConfig();
const { data: serverDetails, isLoading } = useFetchServerStatus();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const { subscription } = useSubscription();
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
return ( return (
<> <>
<Banner /> <Banner />
<div className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden md:flex`}> <div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
>
<Navbar />
{!isLoading && !serverDetails?.redisConfigured && <RedisBanner />}
{!isLoading && !serverDetails?.emailConfigured && <SmtpBanner />}
{!isLoading && subscription.auditLogs && <AuditLogBanner />}
{!window.isSecureContext && <InsecureConnectionBanner />} {!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row"> <div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<AdminSidebar /> <AdminSidebar />
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]"> <div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 pt-8 dark:[color-scheme:dark]">
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
<Outlet /> <Outlet />
</main> </div>
</div> </div>
</div> </div>
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden"> <div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
+64 -61
View File
@@ -1,57 +1,62 @@
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faBuilding,
faChevronLeft,
faCog,
faDatabase,
faKey,
faLock,
faPlug,
faUserTie
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useMatchRoute } from "@tanstack/react-router"; import { Link, useMatchRoute } from "@tanstack/react-router";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; import { Menu, MenuGroup, MenuItem } from "@app/components/v2";
const generalTabs = [ const generalTabs = [
{ {
label: "General", label: "General",
icon: "settings-cog", icon: faCog,
link: "/admin/" link: "/admin/"
}, },
{ {
label: "Encryption", label: "Encryption",
icon: "lock-closed", icon: faLock,
link: "/admin/encryption" link: "/admin/encryption"
}, },
{ {
label: "Authentication", label: "Authentication",
icon: "check", icon: faCheckCircle,
link: "/admin/authentication" link: "/admin/authentication"
}, },
{ {
label: "Integrations", label: "Integrations",
icon: "sliding-carousel", icon: faPlug,
link: "/admin/integrations" link: "/admin/integrations"
}, },
{ {
label: "Caching", label: "Caching",
icon: "note", icon: faDatabase,
link: "/admin/caching" link: "/admin/caching"
}, },
{ {
label: "Environment Variables", label: "Environment Variables",
icon: "unlock", icon: faKey,
link: "/admin/environment" link: "/admin/environment"
} }
]; ];
const resourceTabs = [ const othersTabs = [
{ {
label: "Organizations", label: "Access Controls",
icon: "groups", icon: faUserTie,
link: "/admin/resources/organizations" link: "/admin/access-management"
}, },
{ {
label: "User Identities", label: "Resource Overview",
icon: "user", icon: faBuilding,
link: "/admin/resources/user-identities" link: "/admin/resources/overview"
},
{
label: "Machine Identities",
icon: "wrench",
link: "/admin/resources/machine-identities"
} }
]; ];
@@ -61,6 +66,44 @@ export const AdminSidebar = () => {
return ( return (
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"> <aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]"> <nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div className="flex-1">
<Menu>
<MenuGroup title="Configuration">
{generalTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem isSelected={Boolean(isActive)}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={tab.icon} />
</div>
{tab.label}
</div>
</MenuItem>
</Link>
);
})}
</MenuGroup>
<MenuGroup title="Others">
{othersTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem isSelected={Boolean(isActive)}>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={tab.icon} />
</div>
{tab.label}
</div>
</MenuItem>
</Link>
);
})}
</MenuGroup>
</Menu>
</div>
<Menu> <Menu>
<Link to="/organization/projects"> <Link to="/organization/projects">
<MenuItem <MenuItem
@@ -73,50 +116,10 @@ export const AdminSidebar = () => {
/> />
} }
> >
Back to organization Back to Organization
</MenuItem> </MenuItem>
</Link> </Link>
</Menu> </Menu>
<div className="flex-grow">
<Menu>
<MenuGroup title="General">
{generalTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem
className="relative flex items-center gap-2 overflow-hidden rounded-none"
leftIcon={
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
}
isSelected={Boolean(isActive)}
>
{tab.label}
</MenuItem>
</Link>
);
})}
</MenuGroup>
<MenuGroup title="Resources">
{resourceTabs.map((tab) => {
const isActive = matchRoute({ to: tab.link, fuzzy: false });
return (
<Link key={tab.link} to={tab.link}>
<MenuItem
className="relative flex items-center gap-2 overflow-hidden rounded-none"
leftIcon={
<Lottie className="inline-block h-6 w-6 shrink-0" icon={tab.icon} />
}
isSelected={Boolean(isActive)}
>
{tab.label}
</MenuItem>
</Link>
);
})}
</MenuGroup>
</Menu>
</div>
</nav> </nav>
</aside> </aside>
); );
@@ -10,13 +10,14 @@ import {
faEnvelope, faEnvelope,
faInfo, faInfo,
faInfoCircle, faInfoCircle,
faServer,
faSignOut, faSignOut,
faUser, faUser,
faUsers faUsers
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; import { Link, useLocation, useNavigate, useRouter, useRouterState } from "@tanstack/react-router";
import { Mfa } from "@app/components/auth/Mfa"; import { Mfa } from "@app/components/auth/Mfa";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -117,7 +118,7 @@ export const Navbar = () => {
const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
@@ -179,6 +180,8 @@ export const Navbar = () => {
); );
} }
const isServerAdminPanel = location.pathname.startsWith("/admin");
return ( return (
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4"> <div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
<div> <div>
@@ -187,96 +190,117 @@ export const Navbar = () => {
</Link> </Link>
</div> </div>
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p> <p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
<div className="flex items-center"> {isServerAdminPanel ? (
<DropdownMenu modal={false}> <>
<Link to="/organization/projects"> <Link
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"> to="/admin"
<div> className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"
<FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
</div>
<div className="whitespace-nowrap">{currentOrg?.name}</div>
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
{getPlan(subscription)}
</div>
</div>
</Link>
<DropdownMenuTrigger asChild>
<div>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel="switch-org"
className="px-2 py-1"
>
<FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
> >
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">organizations</div> <div>
{orgs?.map((org) => { <FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
return ( </div>
<DropdownMenuItem key={org.id}> <div className="whitespace-nowrap">Server Console</div>
<Button </Link>
onClick={async () => { <p className="pl-3 pr-3 text-lg text-mineshaft-400/70">/</p>
if (currentOrg?.id === org.id) return; {breadcrumbs ? (
// scott: remove /admin as we show server console above
if (org.authEnforced) { <BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
// org has an org-level auth method enabled (e.g. SAML) ) : null}
// -> logout + redirect to SAML SSO </>
) : (
await logout.mutateAsync(); <>
if (org.orgAuthMethod === AuthMethod.OIDC) { <div className="flex items-center">
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`); <DropdownMenu modal={false}>
} else { <Link to="/organization/projects">
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`); <div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
} <div>
window.close(); <FontAwesomeIcon icon={faBuilding} className="text-xs text-bunker-300" />
return; </div>
} <div className="whitespace-nowrap">{currentOrg?.name}</div>
<div className="mr-1 rounded border border-mineshaft-500 px-1 text-xs text-bunker-300 !no-underline">
if (org.googleSsoAuthEnforced) { {getPlan(subscription)}
await logout.mutateAsync(); </div>
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); </div>
window.close(); </Link>
return; <DropdownMenuTrigger asChild>
} <div>
<IconButton
handleOrgChange(org?.id);
}}
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
size="xs" ariaLabel="switch-org"
className="flex w-full items-center justify-start p-0 font-normal" className="px-2 py-1"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
> >
<div className="flex w-full max-w-[150px] items-center justify-between truncate"> <FontAwesomeIcon icon={faCaretDown} className="text-xs text-bunker-300" />
{org.name} </IconButton>
</div> </div>
</Button> </DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1 text-xs capitalize text-mineshaft-400">organizations</div>
{orgs?.map((org) => {
return (
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
if (org.orgAuthMethod === AuthMethod.OIDC) {
window.open(`/api/v1/sso/oidc/login?orgSlug=${org.slug}`);
} else {
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
}
window.close();
return;
}
if (org.googleSsoAuthEnforced) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`);
window.close();
return;
}
handleOrgChange(org?.id);
}}
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
</div>
</Button>
</DropdownMenuItem>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
Log Out
</DropdownMenuItem> </DropdownMenuItem>
); </DropdownMenuContent>
})} </DropdownMenu>
<div className="mt-1 h-1 border-t border-mineshaft-600" /> </div>
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}> <p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
Log Out {breadcrumbs ? (
</DropdownMenuItem> <BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
</DropdownMenuContent> ) : null}
</DropdownMenu> </>
</div> )}
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
<div className="flex-grow" /> <div className="flex-grow" />
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -27,11 +27,11 @@ export const OrgAlertBanner = ({ text, link }: Props) => {
target="_blank" target="_blank"
className="group flex items-center" className="group flex items-center"
> >
<span className="cursor-pointer pl-1 text-yellow-500 underline underline-offset-2 duration-100 group-hover:text-mineshaft-100 group-hover:decoration-mineshaft-100"> <span className="cursor-pointer pl-1 underline underline-offset-2 duration-100 group-hover:text-mineshaft-100 group-hover:decoration-mineshaft-100">
here here
</span> </span>
<FontAwesomeIcon <FontAwesomeIcon
className="ml-0.5 mt-0.5 text-yellow group-hover:text-mineshaft-100" className="ml-1 mt-[0.12rem] group-hover:text-mineshaft-100"
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
size="xs" size="xs"
/> />
@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2"; import { PageHeader } from "@app/components/v2";
import { OrganizationsTable } from "./components"; import { ServerAdminsTable } from "./components";
export const OrganizationResourcesPage = () => { export const AccessManagementPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="h-full bg-bunker-800"> <div className="h-full bg-bunker-800">
<Helmet> <Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title> <title>{t("common.head-title", { title: "Access Control" })}</title>
</Helmet> </Helmet>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <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"> <div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader <PageHeader
title="Organizations" title="Access Control"
description="Manage all organizations within your Infisical instance." description="Manage server admins within your Infisical instance."
/> />
<OrganizationsTable /> <ServerAdminsTable />
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,141 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api";
import { User } from "@app/hooks/api/users/types";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
const getUserLabel = (user: Pick<User, "email" | "firstName" | "lastName" | "username" | "id">) => {
const { firstName, lastName, username, email } = user;
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
const userEmail = email || username;
if (!name) return userEmail;
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
};
const AddServerAdminSchema = z.object({
user: z.object({
id: z.string(),
firstName: z.string().nullish(),
lastName: z.string().nullish(),
email: z.string().nullish(),
username: z.string()
})
});
type FormData = z.infer<typeof AddServerAdminSchema>;
const Content = ({ onClose }: ContentProps) => {
const grantAdmin = useAdminGrantServerAdminAccess();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(AddServerAdminSchema)
});
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers(
{
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
},
{
placeholderData: (prev) => prev
}
);
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
const onSubmit = async ({ user }: FormData) => {
try {
await grantAdmin.mutateAsync(user.id);
createNotification({
type: "success",
text: "Successfully granted server admin status"
});
onClose();
} catch {
createNotification({
text: "Failed to grant server admin status",
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="User">
<FilterableSelect
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
className="w-full"
placeholder="Search users..."
options={users}
getOptionLabel={(user) => getUserLabel(user)}
getOptionValue={(user) => user.id}
value={field.value}
onChange={field.onChange}
onInputChange={(value) => {
setSearchUserFilter(value);
if (!value) setDebouncedSearchTerm("");
}}
/>
</FormControl>
)}
control={control}
name="user"
/>
<div className="flex w-full gap-4 pt-4">
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema="secondary"
>
Grant
</Button>
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</form>
);
};
export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
bodyClassName="overflow-visible"
title="Grant Server Admin"
subTitle="Grant server admin status to a user"
>
<Content onClose={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,476 @@
import { Dispatch, SetStateAction, useState } from "react";
import {
faEllipsisV,
faMagnifyingGlass,
faPlus,
faShieldHalved,
faTrash,
faUsers,
faUserXmark,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { InfiniteData } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
Checkbox,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import {
useAdminBulkDeleteUsers,
useAdminDeleteUser,
useAdminGetUsers,
useRemoveUserServerAdminAccess
} from "@app/hooks/api";
import { User } from "@app/hooks/api/users/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal";
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
const ServerAdminsPanelTable = ({
handlePopUpOpen,
users: usersPages,
isPending,
searchUserFilter,
setSearchUserFilter,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
selectedUsers,
setSelectedUsers
}: {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"]
>,
data?: {
username: string;
id: string;
message?: string;
}
) => void;
isPending: boolean;
users: InfiniteData<User[], unknown> | undefined;
searchUserFilter: string;
setSearchUserFilter: (filter: string) => void;
selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
hasNextPage: boolean;
}) => {
const { subscription } = useSubscription();
const users = usersPages?.pages.flat();
const isEmpty = !isPending && !users?.length;
const selectedUserIds = selectedUsers.map((user) => user.id);
const isPageSelected = users?.length
? users.every((user) => selectedUserIds.includes(user.id))
: false;
// eslint-disable-next-line no-nested-ternary
const isPageIndeterminate = isPageSelected
? false
: users?.length
? users?.some((user) => selectedUserIds.includes(user.id))
: false;
return (
<>
<div className="flex items-center gap-x-2">
<Input
value={searchUserFilter}
onChange={(e) => setSearchUserFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search admins..."
className="flex-1"
/>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addServerAdmin")}
>
Add Admin
</Button>
</div>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5">
<Checkbox
id="member-page-select"
isChecked={isPageSelected || isPageIndeterminate}
isIndeterminate={isPageIndeterminate}
onCheckedChange={() => {
if (isPageSelected) {
setSelectedUsers((prev) =>
prev.filter((u) => !users?.find((user) => user.id === u.id))
);
} else {
setSelectedUsers((prev) => [
...prev,
...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? [])
]);
}
}}
/>
</Th>
<Th className="w-5/12">Name</Th>
<Th className="w-1/2">Username</Th>
<Th className="w-2/12" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="users" />}
{!isPending &&
users?.map((user) => {
const { username, email, firstName, lastName, id } = user;
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
const isSelected = selectedUserIds.includes(id);
return (
<Tr key={`user-${id}`} className="w-full">
<Td>
<Checkbox
id={`select-user-${id}`}
isChecked={isSelected}
onClick={(e) => {
e.stopPropagation();
setSelectedUsers((prev) =>
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
);
}}
/>
</Td>
<Td className="w-5/12 max-w-0">
<p className="truncate">
{name ?? <span className="text-mineshaft-400">Not Set</span>}
</p>
</Td>
<Td className="w-5/12 max-w-0">
<p className="truncate">{username || email}</p>
</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeUser", { username, id });
}}
icon={<FontAwesomeIcon icon={faUserXmark} />}
>
Remove User
</DropdownMenuItem>
<DropdownMenuItem
icon={
<div className="relative">
<FontAwesomeIcon icon={faShieldHalved} />
<FontAwesomeIcon
className="absolute -bottom-[0.01rem] -right-1"
size="2xs"
icon={faXmark}
/>
</div>
}
onClick={(e) => {
e.stopPropagation();
if (!subscription?.instanceUserManagement) {
handlePopUpOpen("upgradePlan", {
username,
id,
message: removeServerAdminUpgradePlanMessage
});
return;
}
handlePopUpOpen("removeServerAdmin", { username, id });
}}
>
Remove Server Admin
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
)}
</div>
</>
);
};
export const ServerAdminsTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeUser",
"upgradePlan",
"addServerAdmin",
"removeServerAdmin",
"removeUsers"
] as const);
const {
user: { id: userId }
} = useUser();
const { mutateAsync: deleteUser } = useAdminDeleteUser();
const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers();
const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess();
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const {
data: users,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage
} = useAdminGetUsers({
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: true
});
const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string };
try {
await deleteUser(id);
createNotification({
type: "success",
text: "Successfully deleted user"
});
} catch {
createNotification({
type: "error",
text: "Error deleting user"
});
}
handlePopUpClose("removeUser");
};
const handleRemoveServerAdminAccess = async () => {
const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string };
try {
await removeAdminAccess(id);
createNotification({
type: "success",
text: "Successfully removed server admin access from user"
});
} catch {
createNotification({
type: "error",
text: "Error removing server admin access from user"
});
}
handlePopUpClose("removeServerAdmin");
};
const handleRemoveUsers = async () => {
try {
await deleteUsers(selectedUsers.map((user) => user.id));
createNotification({
text: "Successfully removed users",
type: "success"
});
setSelectedUsers([]);
handlePopUpClose("removeUsers");
} catch {
createNotification({
text: "Failed to remove users",
type: "error"
});
}
};
return (
<>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
selectedUsers.length > 0 && "h-16"
)}
>
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
<button
type="button"
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
onClick={() => setSelectedUsers([])}
>
Unselect All
</button>
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-2"
onClick={() => {
if (!selectedUsers?.length) return;
handlePopUpOpen("removeUsers");
}}
size="xs"
>
Delete
</Button>
</div>
</div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<ServerAdminsPanelTable
handlePopUpOpen={handlePopUpOpen}
users={users}
selectedUsers={selectedUsers}
setSelectedUsers={setSelectedUsers}
searchUserFilter={searchUserFilter}
setSearchUserFilter={setSearchUserFilter}
isPending={isPending}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
/>
<DeleteActionModal
isOpen={popUp.removeUser.isOpen}
deleteKey="remove"
title={`Are you sure you want to delete User with username ${
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen}
title={`Are you sure you want to remove Server Admin permissions from ${
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
}?`}
subTitle=""
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveServerAdminAccess}
buttonText="Remove Access"
/>
<AddServerAdminModal
isOpen={popUp.addServerAdmin.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addServerAdmin", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
/>
<DeleteActionModal
isOpen={popUp.removeUsers.isOpen}
title="Are you sure you want to delete the following users?"
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => handleRemoveUsers()}
buttonText="Remove"
>
<div className="mt-4 text-sm text-mineshaft-400">
The following users will be deleted:
</div>
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
<ul className="list-disc">
{selectedUsers?.map((user) => {
const email = user.email ?? user.username;
return (
<li key={user.id}>
<div className="flex items-center">
<p>
{user.firstName || user.lastName ? (
<>
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
<span className="break-all">{email}</span>)
</>
) : (
<span className="break-all">{email}</span>
)}{" "}
</p>
{userId === user.id && (
<Tooltip content="Are you sure you want to remove yourself from this instance?">
<div className="inline-block">
<Badge
variant="primary"
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faWarning} />
<span>Deleting Yourself</span>
</Badge>
</div>
</Tooltip>
)}
</div>
</li>
);
})}
</ul>
</div>
</DeleteActionModal>
</div>
</>
);
};
@@ -0,0 +1 @@
export * from "./ServerAdminsTable";
@@ -1,11 +1,11 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage"; import { AccessManagementPage } from "./AccessManagementPage";
export const Route = createFileRoute( export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/user-identities" "/_authenticate/_inject-org-details/admin/_admin-layout/access-management"
)({ )({
component: UserIdentitiesResourcesPage, component: AccessManagementPage,
beforeLoad: async () => { beforeLoad: async () => {
return { return {
breadcrumbs: [ breadcrumbs: [
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
link: linkOptions({ to: "/admin" }) link: linkOptions({ to: "/admin" })
}, },
{ {
label: "User Identities", label: "Access Control",
link: linkOptions({ link: linkOptions({
to: "/admin/resources/user-identities" to: "/admin/access-management"
}) })
} }
] ]
@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2"; import { PageHeader } from "@app/components/v2";
import { useGetServerConfig } from "@app/hooks/api/admin";
import { GeneralPageForm } from "./components"; import { GeneralPageForm, UsageReportSection } from "./components";
export const GeneralPage = () => { export const GeneralPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: serverConfig } = useGetServerConfig();
return ( return (
<div className="h-full bg-bunker-800"> <div className="h-full bg-bunker-800">
@@ -19,7 +21,10 @@ export const GeneralPage = () => {
title="General" title="General"
description="Manage general settings for your Infisical instance." description="Manage general settings for your Infisical instance."
/> />
<GeneralPageForm /> <div className="space-y-6">
<GeneralPageForm />
{serverConfig?.isOfflineUsageReportsEnabled && <UsageReportSection />}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +103,7 @@ export const GeneralPageForm = () => {
return ( return (
<form <form
className="space-y-8 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" className="space-y-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
<div className="flex flex-col justify-start"> <div className="flex flex-col justify-start">
@@ -0,0 +1,53 @@
import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, Card, CardTitle } from "@app/components/v2";
import { downloadFile } from "@app/helpers/download";
import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation";
export const UsageReportSection = () => {
const generateUsageReport = useGenerateUsageReport();
const handleGenerateReport = async () => {
try {
const response = await generateUsageReport.mutateAsync();
const { csvContent, filename } = response;
downloadFile(csvContent, filename, "text/csv");
createNotification({
text: `Usage report downloaded: "${filename}"`,
type: "success"
});
} catch (error) {
console.error("Failed to generate usage report:", error);
createNotification({
text: "Failed to generate usage report. Please try again.",
type: "error"
});
}
};
return (
<Card className="p-6">
<CardTitle className="mb-4 flex items-center gap-3">
<FontAwesomeIcon icon={faFileAlt} />
Offline Usage Reports
</CardTitle>
<div className="mb-4 text-sm text-gray-400">
Generate secure usage reports for offline license compliance and billing verification.
</div>
<Button
onClick={handleGenerateReport}
className="w-fit"
isLoading={generateUsageReport.isPending}
leftIcon={<FontAwesomeIcon icon={generateUsageReport.isPending ? faSpinner : faDownload} />}
>
{generateUsageReport.isPending ? "Generating..." : "Generate Report"}
</Button>
</Card>
);
};
@@ -1 +1,2 @@
export { GeneralPageForm } from "./GeneralPageForm"; export { GeneralPageForm } from "./GeneralPageForm";
export { UsageReportSection } from "./UsageReportSection";
@@ -1,27 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { MachineIdentitiesTable } from "./components";
export const MachineIdentitiesResourcesPage = () => {
const { t } = useTranslation();
return (
<div className="h-full 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="Machine Identities"
description="Manage all machine identities within your Infisical instance."
/>
<MachineIdentitiesTable />
</div>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
@@ -1,25 +0,0 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
)({
component: MachineIdentitiesResourcesPage,
beforeLoad: async () => {
return {
breadcrumbs: [
{
label: "Admin",
link: linkOptions({ to: "/admin" })
},
{
label: "Machine Identities",
link: linkOptions({
to: "/admin/resources/machine-identities"
})
}
]
};
}
});
@@ -1,396 +0,0 @@
import { useState } from "react";
import {
faBuilding,
faCircleQuestion,
faEllipsis,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useDebounce, usePopUp } from "@app/hooks";
import {
useAdminDeleteOrganization,
useAdminDeleteOrganizationMembership,
useAdminDeleteUser,
useAdminGetOrganizations
} from "@app/hooks/api";
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const ViewMembersModalContent = ({
popUp,
handlePopUpOpen
}: {
popUp: UsePopUpState<["viewMembers"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
data?: {
username?: string;
membershipId?: string;
userId?: string;
orgName?: string;
orgId?: string;
organization?: OrganizationWithProjects;
}
) => void;
}) => {
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
return (
<div className="space-y-2">
{organization?.members?.map((member) => (
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
<div>
<div className="flex items-center gap-2">
<p className="text-xs text-mineshaft-100">
<div>
{member.user.firstName ? (
<div>
{member.user.firstName} {member.user.lastName}
</div>
) : (
<p className="text-mineshaft-400">Not set</p>
)}
</div>
<div className="flex gap-2 opacity-80">
<div>{member.user.username || member.user.email}</div>
<Badge variant="primary">
<div className="flex items-center gap-1">
<span className="capitalize">{member.role.replace("-", " ")}</span>
{Boolean(member.roleId) && (
<Tooltip content="This member has a custom role assigned.">
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
</Tooltip>
)}
</div>
</Badge>
</div>
</p>
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<FontAwesomeIcon
icon={faEllipsis}
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={() =>
handlePopUpOpen("deleteOrganizationMembership", {
membershipId: member.membershipId,
orgId: organization.id,
username: member.user.username,
orgName: organization.name
})
}
>
Remove From Organization
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
>
Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
);
};
const ViewMembersModal = ({
isOpen,
onOpenChange,
popUp,
handlePopUpOpen
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
) => void;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
title="Organization Members"
subTitle="View the members of the organization."
>
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
</ModalContent>
</Modal>
);
};
const OrganizationsPanelTable = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: {
popUp: UsePopUpState<
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
>,
data?: {
orgName?: string;
orgId?: string;
message?: string;
organization?: OrganizationWithProjects;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
isOpen?: boolean
) => void;
}) => {
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({
limit: 20,
searchTerm: debouncedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length;
return (
<>
<div className="flex gap-2">
<Input
value={searchOrganizationsFilter}
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search organizations..."
className="flex-1"
/>
</div>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-5/12">Name</Th>
<Th className="w-5/12">Members</Th>
<Th className="w-5/12">Projects</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending &&
data?.pages?.map((orgs) =>
orgs.map((org) => {
return (
<Tr key={`org-${org.id}`} className="w-full">
<Td className="w-5/12">
{org.name ? (
org.name
) : (
<span className="text-mineshaft-400">Not set</span>
)}
</Td>
<Td className="w-5/12">
{org.members.length} {org.members.length === 1 ? "member" : "members"}
<Button
variant="outline_bg"
size="xs"
className="ml-2"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
>
View Members
</Button>
</Td>
<Td className="w-5/12">
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteOrganization", {
orgId: org.id,
orgName: org.name
});
}}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
<ViewMembersModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
isOpen={popUp.viewMembers.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
/>
</>
);
};
export const OrganizationsTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteOrganization",
"deleteOrganizationMembership",
"deleteUser",
"viewMembers"
] as const);
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
const { mutateAsync: deleteUser } = useAdminDeleteUser();
const handleDeleteOrganization = async () => {
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
await deleteOrganization(orgId);
createNotification({
type: "success",
text: "Successfully deleted organization"
});
handlePopUpClose("deleteOrganization");
};
const handleDeleteOrganizationMembership = async () => {
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
orgId: string;
membershipId: string;
};
if (!orgId || !membershipId) {
return;
}
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
createNotification({
type: "success",
text: "Successfully removed user from organization"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteOrganizationMembership");
};
const handleDeleteUser = async () => {
const { userId } = popUp?.deleteUser?.data as { userId: string };
if (!userId) {
return;
}
await deleteUser(userId);
createNotification({
type: "success",
text: "Successfully deleted user"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteUser");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<OrganizationsPanelTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganization.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete organization ${
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
onDeleteApproved={handleDeleteOrganization}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganizationMembership.isOpen}
deleteKey="delete"
title={`Are you sure you want to remove ${
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
} from organization ${
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
onDeleteApproved={handleDeleteOrganizationMembership}
/>
<DeleteActionModal
isOpen={popUp.deleteUser.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete user ${
(popUp?.deleteUser?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
onDeleteApproved={handleDeleteUser}
/>
</div>
);
};
@@ -1 +0,0 @@
export { OrganizationsTable } from "./OrganizationsTable";
@@ -0,0 +1,42 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components";
export const ResourceOverviewPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Resource Overview" })}</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="Resource Overview"
description="Manage resources within your Infisical instance."
/>
<Tabs defaultValue="tab-organizations">
<TabList>
<Tab value="tab-organizations">Organizations</Tab>
<Tab value="tab-users">Users</Tab>
<Tab value="tab-identities">Identities</Tab>
</TabList>
<TabPanel value="tab-organizations">
<OrganizationsTable />
</TabPanel>
<TabPanel value="tab-users">
<UserIdentitiesTable />
</TabPanel>
<TabPanel value="tab-identities">
<MachineIdentitiesTable />
</TabPanel>
</Tabs>
</div>
</div>
</div>
);
};
@@ -0,0 +1,203 @@
import { useMemo, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { useDebounce } from "@app/hooks";
import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api";
import { User } from "@app/hooks/api/users/types";
import { GenericResourceNameSchema } from "@app/lib/schemas";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
type Invitee = Pick<User, "email" | "firstName" | "lastName" | "username" | "id">;
type NewOption = { label: string; value: string };
const getUserLabel = (user: Invitee | NewOption) => {
if (Object.prototype.hasOwnProperty.call(user, "value")) {
return (user as NewOption).label;
}
const { firstName, lastName, username, email } = user as Invitee;
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
const userEmail = email || username;
if (!name) return userEmail;
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
};
const AddOrgSchema = z.object({
name: GenericResourceNameSchema.nonempty("Organization name required"),
invitees: z
.object({
id: z.string(),
firstName: z.string().nullish(),
lastName: z.string().nullish(),
email: z.string().nullish(),
username: z.string().nullish()
})
.array()
.min(1, "At least one admin is required")
});
type FormData = z.infer<typeof AddOrgSchema>;
const Content = ({ onClose }: ContentProps) => {
const createOrg = useServerAdminCreateOrganization();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({
defaultValues: {
name: "",
invitees: []
},
resolver: zodResolver(AddOrgSchema)
});
const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers(
{
limit: 20,
searchTerm: debouncedSearchTerm,
adminsOnly: false
},
{
placeholderData: (prev) => prev
}
);
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
const onSubmit = async ({ name, invitees }: FormData) => {
try {
await createOrg.mutateAsync({
name,
inviteAdminEmails: invitees
.filter((user) => Boolean(user.email))
.map((user) => user.email) as string[]
});
createNotification({
type: "success",
text: "Successfully created organization"
});
onClose();
} catch {
createNotification({
text: "Failed to create organization",
type: "error"
});
}
};
const { append } = useFieldArray<FormData>({ control, name: "invitees" });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Name">
<Input autoFocus value={value} onChange={onChange} placeholder="My Organization" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Assign Organization Admins"
>
<CreatableSelect
/* eslint-disable-next-line react/no-unstable-nested-components */
noOptionsMessage={() => (
<p>Invite new users to this organization by typing out their email address.</p>
)}
onCreateOption={(inputValue) =>
append({ id: `${inputValue}_${Math.random()}`, email: inputValue })
}
formatCreateLabel={(inputValue) => `Invite "${inputValue}"`}
isValidNewOption={(input) =>
Boolean(input) &&
z.string().email().safeParse(input).success &&
!users
?.flatMap((user) => {
const emails: string[] = [];
if (user.email) {
emails.push(user.email);
}
if (user.username) {
emails.push(user.username);
}
return emails;
})
.includes(input)
}
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
className="w-full"
placeholder="Search users or invite new ones..."
isMulti
name="members"
options={users}
getOptionLabel={(user) => getUserLabel(user)}
getOptionValue={(user) => user.id}
value={field.value}
onChange={field.onChange}
onInputChange={(value) => {
setSearchUserFilter(value);
if (!value) setDebouncedSearchTerm("");
}}
/>
</FormControl>
)}
control={control}
name="invitees"
/>
<div className="flex w-full gap-4 pt-4">
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema="secondary"
>
Add Organization
</Button>
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</form>
);
};
export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent bodyClassName="overflow-visible" title="Add Organization">
<Content onClose={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -2,8 +2,8 @@ import { useState } from "react";
import { import {
faEllipsisV, faEllipsisV,
faMagnifyingGlass, faMagnifyingGlass,
faServer,
faShieldHalved, faShieldHalved,
faWrench,
faXmark faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
)} )}
</TBody> </TBody>
</Table> </Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />} {!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isEmpty && (
<Button <Button
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xl font-semibold text-mineshaft-100">Machine Identities</p>
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
</div>
</div>
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} /> <IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen} isOpen={popUp.removeServerAdmin.isOpen}
@@ -0,0 +1,752 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faBuilding,
faCircleQuestion,
faEllipsisV,
faEnvelope,
faEye,
faMagnifyingGlass,
faPlus,
faTrash,
faUserCheck,
faUserMinus,
faUserPlus,
faUsers,
faUserXmark,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useUser } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useAdminDeleteOrganization,
useAdminDeleteOrganizationMembership,
useAdminDeleteUser,
useAdminGetOrganizations,
useServerAdminAccessOrg,
useServerAdminResendOrgInvite
} from "@app/hooks/api";
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { AddOrganizationModal } from "@app/pages/admin/ResourceOverviewPage/components/AddOrganizationModal";
enum MembersOrderBy {
Name = "firstName",
Email = "email"
}
const ORG_MEMBERS_TABLE_LIMIT = 15;
const ViewMembersModalContent = ({
popUp,
handlePopUpOpen
}: {
popUp: UsePopUpState<["viewMembers"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
data?: {
username?: string;
membershipId?: string;
userId?: string;
orgName?: string;
orgId?: string;
organization?: OrganizationWithProjects;
}
) => void;
}) => {
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
const members = organization?.members ?? [];
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
initPerPage: ORG_MEMBERS_TABLE_LIMIT
});
const filteredMembers = useMemo(
() =>
members
?.filter(
({ user: u }) =>
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string | null;
let valueTwo: string | null;
switch (orderBy) {
case MembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.user.username;
valueTwo = memberTwo.user.email || memberTwo.user.username;
break;
case MembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName ?? memberOne.user.lastName;
valueTwo = memberTwo.user.firstName ?? memberTwo.user.lastName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderBy, orderDirection]
);
const handleSort = (column: MembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredMembers.length,
offset,
setPage
});
const resendOrgInvite = useServerAdminResendOrgInvite();
const onResendInvite = async (membershipId: string) => {
setResendInviteId(membershipId);
try {
await resendOrgInvite.mutateAsync({
membershipId,
organizationId: organization.id
});
createNotification({
text: "Successfully resent org invitation",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to resend org invitation",
type: "error"
});
} finally {
setResendInviteId(null);
}
};
return (
<>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer
className={twMerge(
"mt-4 flex flex-1 flex-col border border-mineshaft-500 bg-mineshaft-700",
Boolean(filteredMembers.length) && "rounded-b-none"
)}
>
<Table className="overflow-y-auto bg-mineshaft-700">
<THead className="sticky top-0 z-50">
<Tr>
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3 border-none bg-mineshaft-700 p-0">
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/4 border-none bg-mineshaft-700 p-0">
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5">
Role
</div>
</Th>
<Th className="w-5 border-none bg-mineshaft-700 p-0">
<div className="flex h-12 w-full items-center border-b-2 border-mineshaft-500 px-3 py-2.5" />
</Th>
</Tr>
</THead>
<TBody>
{filteredMembers.slice(offset, perPage * page).map((member) => {
const { username, email, firstName, lastName, id } = member.user;
const { role, status } = member;
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
return (
<Tr key={`user-${id}`} className="w-full">
<Td className="max-w-0">
<div className="flex items-center">
<p className="truncate">
{name ?? <span className="text-mineshaft-400">Not Set</span>}
</p>
</div>
</Td>
<Td className="max-w-0">
<div className="flex items-center">
<p className="truncate">{username || email}</p>
{role === OrgMembershipRole.Admin &&
status !== OrgMembershipStatus.Accepted && (
<Button
isDisabled={resendOrgInvite.isPending}
className="ml-2 h-7 border-mineshaft-600 bg-mineshaft-800/50 font-normal"
colorSchema="primary"
variant="outline_bg"
size="xs"
isLoading={
resendOrgInvite.isPending && resendInviteId === member.membershipId
}
leftIcon={<FontAwesomeIcon icon={faEnvelope} />}
onClick={(e) => {
onResendInvite(member.membershipId);
e.stopPropagation();
}}
>
Resend Invite
</Button>
)}
</div>
</Td>
<Td className="max-w-0">
<Badge className="flex w-fit max-w-full items-center gap-x-1 whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
<p className="truncate capitalize">{member.role.replace("-", " ")}</p>
{Boolean(member.roleId) && (
<Tooltip content="This member has a custom role assigned.">
<FontAwesomeIcon icon={faCircleQuestion} className="w-3" />
</Tooltip>
)}
</Badge>
</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserMinus} />}
onClick={() =>
handlePopUpOpen("deleteOrganizationMembership", {
membershipId: member.membershipId,
orgId: organization.id,
username: member.user.username,
orgName: organization.name
})
}
>
Remove From Organization
</DropdownMenuItem>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserXmark} />}
onClick={() =>
handlePopUpOpen("deleteUser", { userId: member.user.id })
}
>
Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!filteredMembers.length && (
<EmptyState
className="my-auto bg-mineshaft-700"
title={
members.length
? "No organization members match search..."
: "No organization members found"
}
icon={faUsers}
/>
)}
</TableContainer>
{Boolean(filteredMembers.length) && (
<Pagination
className="rounded-b-md border border-t-0 bg-mineshaft-700"
count={filteredMembers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
perPageList={[ORG_MEMBERS_TABLE_LIMIT]}
/>
)}
</>
);
};
const ViewMembersModal = ({
isOpen,
onOpenChange,
popUp,
handlePopUpOpen
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
) => void;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
title="Organization Members"
subTitle="View the members of the organization."
className="h-full max-w-4xl"
bodyClassName="flex flex-col h-full"
>
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
</ModalContent>
</Modal>
);
};
const OrganizationsPanelTable = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: {
popUp: UsePopUpState<
[
"deleteOrganization",
"viewMembers",
"deleteOrganizationMembership",
"deleteUser",
"createOrganization"
]
>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<
[
"deleteOrganization",
"viewMembers",
"deleteOrganizationMembership",
"deleteUser",
"createOrganization"
]
>,
data?: {
orgName?: string;
orgId?: string;
message?: string;
organization?: OrganizationWithProjects;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
isOpen?: boolean
) => void;
}) => {
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
const { user } = useUser();
const navigate = useNavigate();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({
limit: 20,
searchTerm: debouncedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length;
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
const handleAccessOrg = async (orgId: string) => {
try {
await accessOrganization(orgId);
navigate({
to: "/login/select-organization",
search: {
org_id: orgId
}
});
createNotification({
text: "Successfully joined organization",
type: "success"
});
} catch {
createNotification({
text: "Failed to join organization",
type: "error"
});
}
};
return (
<>
<Input
value={searchOrganizationsFilter}
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search organizations..."
className="flex-1"
/>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/2">Name</Th>
<Th className="w-1/3">Members</Th>
<Th className="w-1/3">Projects</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending &&
data?.pages?.map((orgs) =>
orgs.map((org) => {
const isMember = org.members.find((member) => member.user.id === user.id);
return (
<Tr key={`org-${org.id}`} className="w-full">
<Td className="w-1/2 max-w-0">
<div className="flex items-center gap-x-1.5">
{org.name ? (
<p className="truncate">{org.name}</p>
) : (
<span className="text-mineshaft-400">Not Set</span>
)}
</div>
</Td>
<Td className="w-1/3">
<button
type="button"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
className="flex items-center hover:underline"
>
<Tooltip className="text-center" content="View Members">
<FontAwesomeIcon
icon={faEye}
className="mr-1.5 text-mineshaft-300"
size="sm"
/>
</Tooltip>
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
{!org.members.some(
(member) =>
member.role === OrgMembershipRole.Admin &&
member.status === OrgMembershipStatus.Accepted
) && (
<Tooltip content="No admins have accepted their invitations.">
<div className="ml-1.5">
<FontAwesomeIcon className="text-yellow" icon={faWarning} />
</div>
</Tooltip>
)}
</button>
</Td>
<Td className="w-1/3">
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
</Td>
<Td>
<div className="flex justify-end gap-x-1">
{isMember && (
<Tooltip
className="text-center"
content="You are a member of this organization"
>
<div>
<FontAwesomeIcon
className="text-mineshaft-400"
icon={faUserCheck}
size="sm"
/>
</div>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
{!isMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleAccessOrg(org.id);
}}
icon={<FontAwesomeIcon icon={faUserPlus} />}
>
Join Organization
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteOrganization", {
orgId: org.id,
orgName: org.name
});
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
<ViewMembersModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
isOpen={popUp.viewMembers.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
/>
</>
);
};
export const OrganizationsTable = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteOrganization",
"deleteOrganizationMembership",
"deleteUser",
"viewMembers",
"createOrganization"
] as const);
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
const { mutateAsync: deleteUser } = useAdminDeleteUser();
const handleDeleteOrganization = async () => {
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
await deleteOrganization(orgId);
createNotification({
type: "success",
text: "Successfully deleted organization"
});
handlePopUpClose("deleteOrganization");
};
const handleDeleteOrganizationMembership = async () => {
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
orgId: string;
membershipId: string;
};
if (!orgId || !membershipId) {
return;
}
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
createNotification({
type: "success",
text: "Successfully removed user from organization"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteOrganizationMembership");
};
const handleDeleteUser = async () => {
const { userId } = popUp?.deleteUser?.data as { userId: string };
if (!userId) {
return;
}
await deleteUser(userId);
createNotification({
type: "success",
text: "Successfully deleted user"
});
handlePopUpClose("viewMembers");
handlePopUpClose("deleteUser");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xl font-semibold text-mineshaft-100">Organizations</p>
<p className="text-sm text-bunker-300">
Manage, join and view organizations across your instance.
</p>
</div>
<Button
colorSchema="secondary"
onClick={() => handlePopUpOpen("createOrganization")}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Organization
</Button>
</div>
<OrganizationsPanelTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganization.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete organization ${
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
onDeleteApproved={handleDeleteOrganization}
/>
<DeleteActionModal
isOpen={popUp.deleteOrganizationMembership.isOpen}
deleteKey="delete"
title={`Are you sure you want to remove ${
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
} from organization ${
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
onDeleteApproved={handleDeleteOrganizationMembership}
/>
<DeleteActionModal
isOpen={popUp.deleteUser.isOpen}
deleteKey="delete"
title={`Are you sure you want to delete user ${
(popUp?.deleteUser?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
onDeleteApproved={handleDeleteUser}
/>
<AddOrganizationModal
isOpen={popUp.createOrganization.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createOrganization", isOpen)}
/>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More