Compare commits

...

31 Commits

Author SHA1 Message Date
Daniel Hougaard
ab2fae1516 docs(secret-versioning): fixed inconsistancies 2025-09-04 21:51:34 +02:00
Maidul Islam
94027239e0 Merge pull request #4476 from Infisical/feat/primary-proxy
feat: primary forwarding mode completed
2025-09-04 13:04:56 -04:00
=
0c26fcbb0f feat: addressed all review comments 2025-09-04 22:29:16 +05:30
=
035156bcc3 feat: primary forwarding mode completed 2025-09-04 22:29:16 +05:30
carlosmonastyrski
c116eb9ed2 Merge pull request #4452 from Infisical/ENG-3546
Add offline reports
2025-09-04 11:58:12 -03:00
Carlos Monastyrski
839b27d5bf Minor improvements on offline usage report 2025-09-04 09:45:44 -03:00
Carlos Monastyrski
1909fae076 Merge remote-tracking branch 'origin/main' into ENG-3546 2025-09-04 09:43:55 -03:00
Daniel Hougaard
735ddc1138 Merge pull request #4461 from Infisical/daniel/php-sdk-docs
docs: php sdk
2025-09-04 06:45:44 +02:00
carlosmonastyrski
3b235e3668 Merge pull request #4472 from Infisical/fix/improveSearchCategories
Improve docs search categories
2025-09-03 23:19:19 -03:00
Carlos Monastyrski
5c2dc32ded Small docs change 2025-09-03 23:17:30 -03:00
Carlos Monastyrski
d84572532a Small docs change 2025-09-03 23:14:19 -03:00
Carlos Monastyrski
93341ef6e5 Improve docs search categories 2025-09-03 22:56:01 -03:00
Scott Wilson
3d78984320 Merge pull request #4456 from Infisical/server-admin-additions
feature(server-admin): Revamp server admin UI and create org additions
2025-09-03 18:45:11 -07:00
Daniel Hougaard
4a55500325 Update php.mdx 2025-09-04 03:09:52 +02:00
Daniel Hougaard
3dae165710 Merge pull request #4470 from Infisical/daniel/custom-vault-migration-ui
feat(vault-migration): custom migrations UI
2025-09-04 03:06:21 +02:00
Daniel Hougaard
a94635e5be Update external-migration-router.ts 2025-09-04 02:57:44 +02:00
Daniel Hougaard
912cd5d20a linting 2025-09-04 02:54:53 +02:00
Daniel Hougaard
e29a0e487e feat(vault-migration): custom migrations UI 2025-09-04 02:35:17 +02:00
Scott Wilson
a6d8ca5a6b chore: format imports 2025-09-03 09:50:50 -07:00
Scott Wilson
c6b1af5737 improvements: address feedback 2025-09-03 09:48:51 -07:00
Daniel Hougaard
c802b4aa3a Update php.mdx 2025-09-03 01:27:42 +02:00
Daniel Hougaard
b7d202c33a Update php.mdx 2025-09-03 01:27:16 +02:00
Daniel Hougaard
2fc9725b24 Update php.mdx 2025-09-03 01:26:09 +02:00
Scott Wilson
2b1a36a96d improvements: address additional feedback 2025-09-02 15:34:45 -07:00
Daniel Hougaard
5a2058d24a docs: php sdk 2025-09-03 00:32:22 +02:00
Scott Wilson
435bcd03d3 feature: add ability to join org as super admin 2025-09-02 13:33:28 -07:00
Scott Wilson
4d6e12d6b2 improvements: address feedback 2025-09-02 12:44:02 -07:00
Carlos Monastyrski
88155576a2 Merge remote-tracking branch 'origin/main' into ENG-3546 2025-09-02 10:04:03 -03:00
Scott Wilson
394538769b feature: revamp server admin UI and create org additions 2025-09-01 22:03:48 -07:00
Carlos Monastyrski
14473c742c Address greptile comments 2025-09-01 21:18:48 -03:00
Carlos Monastyrski
4063cf5294 Add offline reports 2025-09-01 18:50:54 -03:00
78 changed files with 8249 additions and 5664 deletions

View File

@@ -25,6 +25,7 @@
"@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4",
@@ -8044,6 +8045,42 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz",
@@ -29330,9 +29367,10 @@
}
},
"node_modules/toad-cache": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz",
"integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": {
"node": ">=12"
}

View File

@@ -145,6 +145,7 @@
"@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4",

View File

@@ -83,6 +83,7 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-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 { TOrgServiceFactory } from "@app/services/org/org-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
auth: TAuthMode;
shouldForwardWritesToPrimaryInstance: boolean;
permission: {
authMethod: ActorAuthMethod;
type: ActorType;
@@ -303,6 +305,7 @@ declare module "fastify" {
bus: TEventBusService;
sse: TServerSentEventsService;
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@@ -722,6 +722,16 @@ export const licenseServiceFactory = ({
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 {
generateOrgCustomerId,
removeOrgCustomer,
@@ -736,6 +746,8 @@ export const licenseServiceFactory = ({
return onPremFeatures;
},
getPlan,
getCustomerId,
getLicenseId,
invalidateGetPlan,
updateSubscriptionOrgMemberCount,
refreshPlan,

View File

@@ -218,6 +218,8 @@ const envSchema = z
),
PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7),
INFISICAL_PRIMARY_INSTANCE_URL: zpStr(z.string().optional()),
// HSM
HSM_LIB_PATH: zpStr(z.string().optional()),
HSM_PIN: zpStr(z.string().optional()),

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).
export const injectIdentity = fp(async (server: FastifyZodProvider) => {
server.decorateRequest("auth", null);
server.addHook("onRequest", async (req) => {
const appCfg = getConfig();
export const injectIdentity = fp(
async (server: FastifyZodProvider, opt: { shouldForwardWritesToPrimaryInstance?: boolean }) => {
server.decorateRequest("auth", null);
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/")) {
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;
if (opt.shouldForwardWritesToPrimaryInstance && req.method !== "GET") {
return;
}
case AuthMode.IDENTITY_ACCESS_TOKEN: {
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,
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 (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) {
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;
}
if (token?.identityAuth?.kubernetes) {
requestContext.set("identityAuthInfo", {
case AuthMode.IDENTITY_ACCESS_TOKEN: {
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,
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) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
aws: token?.identityAuth?.aws
});
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;
}
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" });
}
});
});
});
}
);

View File

@@ -10,6 +10,10 @@ interface TAuthOptions {
export const verifyAuth =
<T extends FastifyRequest>(authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) =>
(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 (!req.auth) throw new UnauthorizedError({ message: "Token missing" });

View File

@@ -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);
}
});
});

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 { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
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 { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue";
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 { injectPermission } from "../plugins/auth/inject-permission";
import { injectRateLimits } from "../plugins/inject-rate-limits";
import { forwardWritesToPrimary } from "../plugins/primary-forwarding-mode";
import { registerV1Routes } from "./v1";
import { initializeOauthConfigSync } from "./v1/sso-router";
import { registerV2Routes } from "./v2";
@@ -385,6 +388,7 @@ export const registerRoutes = async (
const reminderRecipientDAL = reminderRecipientDALFactory(db);
const integrationDAL = integrationDALFactory(db);
const offlineUsageReportDAL = offlineUsageReportDALFactory(db);
const integrationAuthDAL = integrationAuthDALFactory(db);
const webhookDAL = webhookDALFactory(db);
const serviceTokenDAL = serviceTokenDALFactory(db);
@@ -842,7 +846,14 @@ export const registerRoutes = async (
licenseService,
kmsService,
microsoftTeamsService,
invalidateCacheQueue
invalidateCacheQueue,
smtpService,
tokenService
});
const offlineUsageReportService = offlineUsageReportServiceFactory({
offlineUsageReportDAL,
licenseService
});
const orgAdminService = orgAdminServiceFactory({
@@ -2003,6 +2014,7 @@ export const registerRoutes = async (
apiKey: apiKeyService,
authToken: tokenService,
superAdmin: superAdminService,
offlineUsageReport: offlineUsageReportService,
project: projectService,
projectMembership: projectMembershipService,
projectKey: projectKeyService,
@@ -2135,8 +2147,14 @@ export const registerRoutes = async (
user: userDAL,
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(injectPermission);
await server.register(injectRateLimits);

View File

@@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors";
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -53,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
defaultAuthOrgAuthMethod: z.string().nullish(),
isSecretScanningDisabled: 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,
isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING,
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(),
role: z.string(),
roleId: z.string().nullish()
roleId: z.string().nullish(),
status: z.string().nullish()
})
.array(),
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
};
}
});
};

View File

@@ -2,10 +2,13 @@ import fastifyMultipart from "@fastify/multipart";
import { z } from "zod";
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 { 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;
@@ -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 };
}
});
};

View File

@@ -501,6 +501,15 @@ export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData:
};
};
// 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 (
{
vaultAccessToken,
@@ -527,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 vaultData = await vaultApi.collectVaultData({
@@ -536,27 +564,5 @@ export const importVaultDataFn = async (
gatewayId
});
// 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).
const transformMappings: Record<
string,
(vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData
> = {
"68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1
} as const;
let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData;
if (orgId in transformMappings) {
transformFn = transformMappings[orgId];
} else {
transformFn = transformToInfisicalFormatNamespaceToProjects;
}
logger.info(
{ orgId, mappingType },
`[importVaultDataFn]: Running ${orgId in transformMappings ? "custom" : "default"} transform`
);
return transformFn(vaultData, mappingType);
};

View File

@@ -5,9 +5,20 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
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 { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types";
import {
ExternalMigrationProviders,
ExternalPlatforms,
THasCustomVaultMigrationDTO,
TImportEnvKeyDataDTO,
TImportVaultDataDTO
} from "./external-migration-types";
type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
@@ -128,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 {
importEnvKeyData,
importVaultData
importVaultData,
hasCustomVaultMigration
};
};

View File

@@ -4,7 +4,8 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export enum VaultMappingType {
Namespace = "namespace",
KeyVault = "key-vault"
KeyVault = "key-vault",
Custom = "custom"
}
export type InfisicalImportData = {
@@ -26,6 +27,10 @@ export type TImportEnvKeyDataDTO = {
encryptedJson: { nonce: string; data: string };
} & Omit<TOrgPermission, "orgId">;
export type THasCustomVaultMigrationDTO = {
provider: ExternalMigrationProviders;
} & Omit<TOrgPermission, "orgId">;
export type TImportVaultDataDTO = {
vaultAccessToken: string;
vaultNamespace?: string;
@@ -111,3 +116,8 @@ export enum ExternalPlatforms {
EnvKey = "EnvKey",
Vault = "Vault"
}
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}

View File

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

View File

@@ -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)
};
};

View File

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

View File

@@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => {
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.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"));
const formattedDocs = sqlNestRelationships({
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
orgMembershipId,
orgMembershipRole,
orgMembershipRoleName,
orgMembershipRoleId
orgMembershipRoleId,
orgMembershipStatus
}) => ({
user: {
id: userId,
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
firstName,
lastName
},
status: orgMembershipStatus,
membershipId: orgMembershipId,
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
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) => {
try {
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
findMembership,
findMembershipWithScimFilter,
createMembership,
bulkCreateMemberships,
updateMembershipById,
deleteMembershipById,
deleteMembershipsById,

View File

@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
/*
* Create organization
* */
const createOrganization = async ({
userId,
userEmail,
orgName
}: {
userId: string;
orgName: string;
userEmail?: string | null;
}) => {
const createOrganization = async (
{
userId,
userEmail,
orgName
}: {
userId?: string;
orgName: string;
userEmail?: string | null;
},
trx?: Knex
) => {
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
const key = crypto.randomBytes(32).toString("base64");
const {
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
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
const org = await orgDAL.create(
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
tx
);
await orgDAL.createMembership(
{
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
if (userId) {
await orgDAL.createMembership(
{
userId,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
},
tx
);
}
await orgBotDAL.create(
{
name: org.name,
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
tx
);
return org;
});
};
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
return organization;

View File

@@ -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: "jane@infisical.com",
siteUrl: "https://infisical.com",
callback_url: "https://app.infisical.com"
} as OrganizationAssignmentTemplateProps;

View File

@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
export * from "./NewDeviceLoginTemplate";
export * from "./OrgAdminBreakglassAccessTemplate";
export * from "./OrgAdminProjectGrantAccessTemplate";
export * from "./OrganizationAssignmentTemplate";
export * from "./OrganizationInvitationTemplate";
export * from "./PasswordResetTemplate";
export * from "./PasswordSetupTemplate";

View File

@@ -18,6 +18,7 @@ import {
NewDeviceLoginTemplate,
OrgAdminBreakglassAccessTemplate,
OrgAdminProjectGrantAccessTemplate,
OrganizationAssignmentTemplate,
OrganizationInvitationTemplate,
PasswordResetTemplate,
PasswordSetupTemplate,
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
NewDeviceJoin = "newDevice",
OrgInvite = "organizationInvitation",
OrgAssignment = "organizationAssignment",
ResetPassword = "passwordReset",
SetupPassword = "passwordSetup",
SecretLeakIncident = "secretLeakIncident",
@@ -94,6 +96,7 @@ export enum SmtpHost {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,

View File

@@ -1,6 +1,13 @@
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 { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import {
@@ -13,7 +20,12 @@ import {
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
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 { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAuthLoginFactory } from "../auth/auth-login-service";
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
@@ -43,7 +55,9 @@ import {
TAdminGetUsersDTO,
TAdminIntegrationConfig,
TAdminSignUpDTO,
TGetOrganizationsDTO
TCreateOrganizationDTO,
TGetOrganizationsDTO,
TResendOrgInviteDTO
} from "./super-admin-types";
type TSuperAdminServiceFactoryDep = {
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
kmsRootConfigDAL: TKmsRootConfigDALFactory;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "updateSubscriptionOrgMemberCount">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
invalidateCacheQueue: TInvalidateCacheQueueFactory;
smtpService: Pick<TSmtpService, "sendMail">;
tokenService: TAuthTokenServiceFactory;
};
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
identityTokenAuthDAL,
identityOrgMembershipDAL,
microsoftTeamsService,
invalidateCacheQueue
invalidateCacheQueue,
smtpService,
tokenService
}: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => {
// TODO(akhilmhdh): bad pattern time less change this later to me itself
@@ -732,6 +750,159 @@ export const superAdminServiceFactory = ({
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 organization = await orgDAL.deleteById(organizationId);
return organization;
@@ -763,6 +934,86 @@ export const superAdminServiceFactory = ({
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 identities = await identityDAL.getIdentitiesByFilter({
limit,
@@ -901,6 +1152,9 @@ export const superAdminServiceFactory = ({
initializeEnvConfigSync,
getEnvOverrides,
getEnvOverridesOrganized,
deleteUsers
deleteUsers,
createOrganization,
joinOrganization,
resendOrgInvite
};
};

View File

@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
searchTerm: string;
};
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
export enum LoginMethod {
EMAIL = "email",
GOOGLE = "google",

View File

@@ -2455,6 +2455,7 @@
"sdks/languages/cpp",
"sdks/languages/rust",
"sdks/languages/go",
"sdks/languages/php",
"sdks/languages/ruby"
]
}

View File

@@ -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)
by specifying the `version` query parameter.
![secret versioning overview](../../images/platform/secret-versioning-overview.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.

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

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

204
docs/sdks/languages/php.mdx Normal file
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.

View File

@@ -32,6 +32,9 @@ From local development to production, Infisical SDKs provide the easiest way for
</Card>
<Card href="/sdks/languages/go" title="Go" icon="/images/sdks/languages/go.svg">
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 href="/sdks/languages/ruby" title="Ruby" icon="/images/sdks/languages/ruby.svg">
Manage secrets for your Ruby application on demand

View File

@@ -4,7 +4,7 @@ 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'];
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"},
@@ -14,15 +14,15 @@ export const AppConnectionsBrowser = () => {
{"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": "Cloud Providers"},
{"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"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": "Cloud Providers"},
{"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Cloud Providers"},
{"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": "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"},
@@ -30,12 +30,12 @@ export const AppConnectionsBrowser = () => {
{"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": "Cloud Providers"},
{"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": "Cloud Providers"},
{"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "Cloud Providers"},
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec 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"},

View File

@@ -4,7 +4,7 @@ export const DynamicSecretsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches'];
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"},
@@ -26,10 +26,10 @@ export const DynamicSecretsBrowser = () => {
{"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": "Cloud Providers"},
{"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "Cloud Providers"},
{"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Cloud Providers"},
{"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Cloud Providers"}
{"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());
});

View File

@@ -4,7 +4,7 @@ export const SecretSyncsBrowser = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring'];
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"},
@@ -13,28 +13,28 @@ export const SecretSyncsBrowser = () => {
{"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": "Cloud Providers"},
{"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Cloud Providers"},
{"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Cloud Providers"},
{"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Cloud Providers"},
{"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Cloud Providers"},
{"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Cloud Providers"},
{"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Cloud Providers"},
{"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Cloud Providers"},
{"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": "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": "Cloud Providers"},
{"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Cloud Providers"},
{"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": "Cloud Providers"},
{"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "Cloud Providers"},
{"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "Cloud Providers"},
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "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) {

View File

@@ -60,7 +60,8 @@ export const CreatableSelect = <T,>({
isSelected && "text-mineshaft-200",
"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}
/>

View File

@@ -91,7 +91,7 @@ export const FilterableSelect = <T,>({
),
placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1",
input: () => `pl-1 ${isMulti ? "py-[0.22rem]" : ""}`,
valueContainer: () =>
`px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
@@ -114,7 +114,8 @@ export const FilterableSelect = <T,>({
isSelected && "text-mineshaft-200",
"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}
/>

View File

@@ -4,3 +4,15 @@ export const downloadTxtFile = (filename: string, content: string) => {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
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);
};

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { Organization } from "@app/hooks/api/organization/types";
import { organizationKeys } from "../organization/queries";
import { User } from "../users/types";
@@ -8,9 +9,12 @@ import { adminQueryKeys, adminStandaloneKeys } from "./queries";
import {
RootKeyEncryptionStrategy,
TCreateAdminUserDTO,
TCreateOrganizationDTO,
TInvalidateCacheDTO,
TResendOrgInviteDTO,
TServerConfig,
TUpdateServerConfigDTO
TUpdateServerConfigDTO,
TUsageReportResponse
} from "./types";
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;
}
});
};

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 { Identity } from "@app/hooks/api/identities/types";
@@ -25,8 +32,8 @@ export const adminStandaloneKeys = {
export const adminQueryKeys = {
serverConfig: () => ["server-config"] as const,
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
getOrganizations: (filters: AdminGetOrganizationsFilters) =>
[adminStandaloneKeys.getOrganizations, { filters }] as const,
getOrganizations: (filters?: AdminGetOrganizationsFilters) =>
[adminStandaloneKeys.getOrganizations, ...(filters ? [{ filters }] : [])] as const,
getIdentities: (filters: AdminGetIdentitiesFilters) =>
[adminStandaloneKeys.getIdentities, { filters }] as const,
getAdminSlackConfig: () => ["admin-slack-config"] as const,
@@ -83,7 +90,18 @@ export const useGetServerConfig = ({
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({
initialPageParam: 0,
queryKey: adminQueryKeys.getUsers(filters),
@@ -101,7 +119,8 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
return data.users;
},
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * filters.limit : undefined
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
...options
});
};

View File

@@ -1,3 +1,5 @@
import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { Organization } from "../types";
export enum LoginMethod {
@@ -20,6 +22,7 @@ export type OrganizationWithProjects = Organization & {
lastName: string | null;
};
membershipId: string;
status: OrgMembershipStatus;
role: string;
roleId: string | null;
}[];
@@ -53,6 +56,7 @@ export type TServerConfig = {
fipsEnabled: boolean;
envOverrides?: Record<string, string>;
paramsFolderSecretDetectionEnabled: boolean;
isOfflineUsageReportsEnabled: boolean;
};
export type TUpdateServerConfigDTO = {
@@ -142,3 +146,19 @@ export interface TGetEnvOverrides {
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;
};

View File

@@ -1 +1,2 @@
export * from "./mutations";
export * from "./queries";

View File

@@ -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}`
)
});
};

View File

@@ -0,0 +1,4 @@
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}

View File

@@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}
export enum OrgMembershipStatus {
Invited = "invited",
Accepted = "accepted"
}

View File

@@ -19,10 +19,10 @@ export type User = {
createdAt: Date;
updatedAt: Date;
username: string;
email?: string;
email?: string | null;
superAdmin: boolean;
firstName?: string;
lastName?: string;
firstName?: string | null;
lastName?: string | null;
authProvider?: AuthMethod;
authMethods: AuthMethod[];
isMfaEnabled: boolean;

View File

@@ -1,11 +1,15 @@
import { useTranslation } from "react-i18next";
import { faMobile } from "@fortawesome/free-solid-svg-icons";
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 { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useServerConfig, useSubscription } 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 { AdminSidebar } from "./Sidebar";
@@ -13,26 +17,27 @@ import { AdminSidebar } from "./Sidebar";
export const AdminLayout = () => {
const { t } = useTranslation();
const { config } = useServerConfig();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const { data: serverDetails, isLoading } = useFetchServerStatus();
const { subscription } = useSubscription();
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
return (
<>
<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 />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<AdminSidebar />
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 dark:[color-scheme:dark]">
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 px-4 pb-4 pt-8 dark:[color-scheme:dark]">
<Outlet />
</main>
</div>
</div>
</div>
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">

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 { 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 = [
{
label: "General",
icon: "settings-cog",
icon: faCog,
link: "/admin/"
},
{
label: "Encryption",
icon: "lock-closed",
icon: faLock,
link: "/admin/encryption"
},
{
label: "Authentication",
icon: "check",
icon: faCheckCircle,
link: "/admin/authentication"
},
{
label: "Integrations",
icon: "sliding-carousel",
icon: faPlug,
link: "/admin/integrations"
},
{
label: "Caching",
icon: "note",
icon: faDatabase,
link: "/admin/caching"
},
{
label: "Environment Variables",
icon: "unlock",
icon: faKey,
link: "/admin/environment"
}
];
const resourceTabs = [
const othersTabs = [
{
label: "Organizations",
icon: "groups",
link: "/admin/resources/organizations"
label: "Access Controls",
icon: faUserTie,
link: "/admin/access-management"
},
{
label: "User Identities",
icon: "user",
link: "/admin/resources/user-identities"
},
{
label: "Machine Identities",
icon: "wrench",
link: "/admin/resources/machine-identities"
label: "Resource Overview",
icon: faBuilding,
link: "/admin/resources/overview"
}
];
@@ -61,6 +66,44 @@ export const AdminSidebar = () => {
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">
<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>
<Link to="/organization/projects">
<MenuItem
@@ -73,50 +116,10 @@ export const AdminSidebar = () => {
/>
}
>
Back to organization
Back to Organization
</MenuItem>
</Link>
</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>
</aside>
);

View File

@@ -10,13 +10,14 @@ import {
faEnvelope,
faInfo,
faInfoCircle,
faServer,
faSignOut,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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 { createNotification } from "@app/components/notifications";
@@ -117,7 +118,7 @@ export const Navbar = () => {
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
const router = useRouter();
const queryClient = useQueryClient();
const location = useLocation();
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
@@ -179,6 +180,8 @@ export const Navbar = () => {
);
}
const isServerAdminPanel = location.pathname.startsWith("/admin");
return (
<div className="z-10 flex min-h-12 items-center border-b border-mineshaft-600 bg-mineshaft-800 px-4">
<div>
@@ -187,96 +190,117 @@ export const Navbar = () => {
</Link>
</div>
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
<div className="flex items-center">
<DropdownMenu modal={false}>
<Link to="/organization/projects">
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
<div>
<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" }}
{isServerAdminPanel ? (
<>
<Link
to="/admin"
className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary"
>
<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);
}}
<div>
<FontAwesomeIcon icon={faServer} className="text-xs text-bunker-300" />
</div>
<div className="whitespace-nowrap">Server Console</div>
</Link>
<p className="pl-3 pr-3 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
// scott: remove /admin as we show server console above
<BreadcrumbContainer breadcrumbs={breadcrumbs.slice(1) as TBreadcrumbFormat[]} />
) : null}
</>
) : (
<>
<div className="flex items-center">
<DropdownMenu modal={false}>
<Link to="/organization/projects">
<div className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary">
<div>
<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"
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" />
)
}
ariaLabel="switch-org"
className="px-2 py-1"
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
</div>
</Button>
<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>
{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>
);
})}
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem icon={<FontAwesomeIcon icon={faSignOut} />} onClick={logOutUser}>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="pl-1 pr-3 text-lg text-mineshaft-400/70">/</p>
{breadcrumbs ? (
<BreadcrumbContainer breadcrumbs={breadcrumbs as TBreadcrumbFormat[]} />
) : null}
</DropdownMenuContent>
</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" />
<DropdownMenu modal={false}>
<DropdownMenuTrigger>

View File

@@ -27,11 +27,11 @@ export const OrgAlertBanner = ({ text, link }: Props) => {
target="_blank"
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
</span>
<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}
size="xs"
/>

View File

@@ -3,23 +3,23 @@ import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { OrganizationsTable } from "./components";
import { ServerAdminsTable } from "./components";
export const OrganizationResourcesPage = () => {
export const AccessManagementPage = () => {
const { t } = useTranslation();
return (
<div className="h-full bg-bunker-800">
<Helmet>
<title>{t("common.head-title", { title: "Admin" })}</title>
<title>{t("common.head-title", { title: "Access Control" })}</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="Organizations"
description="Manage all organizations within your Infisical instance."
title="Access Control"
description="Manage server admins within your Infisical instance."
/>
<OrganizationsTable />
<ServerAdminsTable />
</div>
</div>
</div>

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./ServerAdminsTable";

View File

@@ -1,11 +1,11 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { UserIdentitiesResourcesPage } from "./UserIdentitiesResourcesPage";
import { AccessManagementPage } from "./AccessManagementPage";
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 () => {
return {
breadcrumbs: [
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
link: linkOptions({ to: "/admin" })
},
{
label: "User Identities",
label: "Access Control",
link: linkOptions({
to: "/admin/resources/user-identities"
to: "/admin/access-management"
})
}
]

View File

@@ -2,11 +2,13 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
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 = () => {
const { t } = useTranslation();
const { data: serverConfig } = useGetServerConfig();
return (
<div className="h-full bg-bunker-800">
@@ -19,7 +21,10 @@ export const GeneralPage = () => {
title="General"
description="Manage general settings for your Infisical instance."
/>
<GeneralPageForm />
<div className="space-y-6">
<GeneralPageForm />
{serverConfig?.isOfflineUsageReportsEnabled && <UsageReportSection />}
</div>
</div>
</div>
</div>

View File

@@ -103,7 +103,7 @@ export const GeneralPageForm = () => {
return (
<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)}
>
<div className="flex flex-col justify-start">

View File

@@ -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>
);
};

View File

@@ -1 +1,2 @@
export { GeneralPageForm } from "./GeneralPageForm";
export { UsageReportSection } from "./UsageReportSection";

View File

@@ -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>
);
};

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -2,8 +2,8 @@ import { useState } from "react";
import {
faEllipsisV,
faMagnifyingGlass,
faServer,
faShieldHalved,
faWrench,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -136,7 +136,7 @@ const IdentityPanelTable = ({
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
</TableContainer>
{!isEmpty && (
<Button
@@ -183,6 +183,12 @@ export const MachineIdentitiesTable = () => {
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">Machine Identities</p>
<p className="text-sm text-bunker-300">Manage machine identities across your instance.</p>
</div>
</div>
<IdentityPanelTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.removeServerAdmin.isOpen}

View File

@@ -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>
);
};

View File

@@ -218,7 +218,7 @@ const UserPanelTable = ({
</div>
</Td>
<Td className="w-5/12 max-w-0">
<p className="truncate">{email}</p>
<p className="truncate">{username || email}</p>
</Td>
<Td>
<div className="flex justify-end">
@@ -463,6 +463,12 @@ export const UserIdentitiesTable = () => {
</div>
</div>
<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">User Identities</p>
<p className="text-sm text-bunker-300">Manage user identities across your instance.</p>
</div>
</div>
<UserPanelTable
handlePopUpOpen={handlePopUpOpen}
users={users}
@@ -515,14 +521,14 @@ export const UserIdentitiesTable = () => {
/>
<DeleteActionModal
isOpen={popUp.removeUsers.isOpen}
title="Are you sure you want to remove the following users?"
title="Are you sure you want to delete the following users?"
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => handleRemoveUsers()}
buttonText="Remove"
buttonText="Delete"
>
<div className="mt-4 text-sm text-mineshaft-400">
The following members will be removed:
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">
@@ -549,7 +555,7 @@ export const UserIdentitiesTable = () => {
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
>
<FontAwesomeIcon icon={faWarning} />
<span>Removing Yourself</span>
<span>Deleting Yourself</span>
</Badge>
</div>
</Tooltip>

View File

@@ -0,0 +1,3 @@
export * from "./MachineIdentitiesTable";
export * from "./OrganizationsTable";
export * from "./UserIdentitiesTable";

View File

@@ -1,11 +1,11 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { OrganizationResourcesPage } from "./OrganizationResourcesPage";
import { ResourceOverviewPage } from "./ResourceOverviewPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations"
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/overview"
)({
component: OrganizationResourcesPage,
component: ResourceOverviewPage,
beforeLoad: async () => {
return {
breadcrumbs: [
@@ -14,9 +14,9 @@ export const Route = createFileRoute(
link: linkOptions({ to: "/admin" })
},
{
label: "Organizations",
label: "Resource Overview",
link: linkOptions({
to: "/admin/resources/organizations"
to: "/admin/resources/overview"
})
}
]

View File

@@ -1,27 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { UserIdentitiesTable } from "./components";
export const UserIdentitiesResourcesPage = () => {
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="User Identities"
description="Manage all user identities within your Infisical instance."
/>
<UserIdentitiesTable />
</div>
</div>
</div>
);
};

View File

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

View File

@@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery";
const QueryParamsSchema = z.object({
callback_port: z.coerce.number().optional().catch(undefined),
force: z.boolean().optional()
force: z.boolean().optional(),
org_id: z.string().optional().catch(undefined)
});
export const AuthConsentWrapper = () => {
@@ -102,6 +103,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({
return;
}
if (search.org_id) {
if (location.pathname.endsWith("select-organization")) return;
throw redirect({ to: "/login/select-organization", search: { org_id: search.org_id } });
}
if (!data.organizationId) {
if (
location.pathname.endsWith("select-organization") ||

View File

@@ -16,6 +16,8 @@ import {
} from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys } from "@app/hooks/api";
import { useImportVault } from "@app/hooks/api/migration/mutations";
import { useHasCustomMigrationAvailable } from "@app/hooks/api/migration";
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
type Props = {
id?: string;
@@ -24,11 +26,13 @@ type Props = {
enum VaultMappingType {
KeyVault = "key-vault",
Namespace = "namespace"
Namespace = "namespace",
Custom = "custom"
}
const MAPPING_TYPE_MENU_ITEMS = [
{
isCustom: false,
value: VaultMappingType.KeyVault,
label: "Key Vaults",
tooltip: (
@@ -48,6 +52,7 @@ const MAPPING_TYPE_MENU_ITEMS = [
)
},
{
isCustom: false,
value: VaultMappingType.Namespace,
label: "Namespaces",
tooltip: (
@@ -63,10 +68,25 @@ const MAPPING_TYPE_MENU_ITEMS = [
</div>
</div>
)
},
{
isCustom: true,
value: VaultMappingType.Custom,
label: "Custom Migration",
tooltip: (
<div>
Custom migrations allow you to shape your Vault migration to your specific needs. Please
contact our sales team to get started with custom migrations.
</div>
)
}
];
export const VaultPlatformModal = ({ onClose }: Props) => {
const { data: isCustomMigrationAvailable } = useHasCustomMigrationAvailable(
ExternalMigrationProviders.Vault
);
const formSchema = z.object({
vaultUrl: z.string().min(1),
gatewayId: z.string().optional(),
@@ -230,31 +250,44 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
errorText={error?.message}
className="flex-1"
>
<div className="mt-2 grid h-full w-full grid-cols-2 gap-4">
<div className="mt-2 grid grid-cols-2 gap-4">
{MAPPING_TYPE_MENU_ITEMS.map((el) => (
<div
key={el.value}
className={twMerge(
"flex w-full cursor-pointer flex-col items-center gap-2 rounded border border-mineshaft-600 p-4 opacity-75 transition-all",
field.value === el.value
? "border-primary-700 border-opacity-70 bg-mineshaft-600 opacity-100"
: "hover:border-primary-700 hover:bg-mineshaft-600"
? "border-primary-700 border-opacity-70 bg-mineshaft-700 opacity-100"
: "hover:border-primary-800/75 hover:bg-mineshaft-600",
el.isCustom && "col-span-2",
el.isCustom &&
!isCustomMigrationAvailable?.data?.enabled &&
"!cursor-not-allowed !border-mineshaft-600 !bg-mineshaft-600 !opacity-40"
)}
onClick={() => field.onChange(el.value)}
onClick={() => {
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
field.onChange(el.value);
}}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
if (el.isCustom && !isCustomMigrationAvailable?.data?.enabled) return;
field.onChange(el.value);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
field.onChange(el.value);
}
}}
>
<div className="flex items-center gap-1">
<div className="text-center text-sm">{el.label}</div>
{el.tooltip && (
<div className="text-center text-sm">
<Tooltip content={el.tooltip} className="max-w-96">
<FontAwesomeIcon className="opacity-60" icon={faQuestionCircle} />
<FontAwesomeIcon
size="sm"
className="text-mineshaft-400"
icon={faQuestionCircle}
/>
</Tooltip>
</div>
)}
@@ -272,7 +305,7 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
isLoading={isLoading}
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
>
Import data
Import Data
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel

View File

@@ -93,7 +93,7 @@ export const AccessApprovalRequest = ({
}) => {
const [selectedRequest, setSelectedRequest] = useState<
| (TAccessApprovalRequest & {
user: { firstName?: string; lastName?: string; email?: string } | null;
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;

View File

@@ -85,7 +85,7 @@ export const ReviewAccessRequestModal = ({
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
request: TAccessApprovalRequest & {
user: { firstName?: string; lastName?: string; email?: string } | null;
user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,8 @@ const adminRoute = route("/admin", [
route("/environment", "admin/EnvironmentPage/route.tsx"),
route("/integrations", "admin/IntegrationsPage/route.tsx"),
route("/caching", "admin/CachingPage/route.tsx"),
route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),
route("/resources/user-identities", "admin/UserIdentitiesResourcesPage/route.tsx"),
route("/resources/machine-identities", "admin/MachineIdentitiesResourcesPage/route.tsx")
route("/resources/overview", "admin/ResourceOverviewPage/route.tsx"),
route("/access-management", "admin/AccessManagementPage/route.tsx")
])
]);