mirror of
https://github.com/Infisical/infisical.git
synced 2025-09-07 10:22:29 +00:00
Compare commits
46 Commits
daniel/php
...
feat/prima
Author | SHA1 | Date | |
---|---|---|---|
|
0c26fcbb0f | ||
|
035156bcc3 | ||
|
c116eb9ed2 | ||
|
839b27d5bf | ||
|
1909fae076 | ||
|
735ddc1138 | ||
|
3b235e3668 | ||
|
5c2dc32ded | ||
|
d84572532a | ||
|
93341ef6e5 | ||
|
3d78984320 | ||
|
3dae165710 | ||
|
a94635e5be | ||
|
912cd5d20a | ||
|
e29a0e487e | ||
|
8aa270545d | ||
|
3c24132e97 | ||
|
38a7cb896b | ||
|
6abd58ee21 | ||
|
c8275f41a3 | ||
|
a6d8ca5a6b | ||
|
c6b1af5737 | ||
|
8467286aa3 | ||
|
cea43d497d | ||
|
3700597ba7 | ||
|
65f0597bd8 | ||
|
5b3cae7255 | ||
|
a4ff6340f8 | ||
|
bfb2486204 | ||
|
c29b5e37f3 | ||
|
2b1a36a96d | ||
|
e666409026 | ||
|
ecfc8b5f87 | ||
|
435bcd03d3 | ||
|
4d6e12d6b2 | ||
|
88155576a2 | ||
|
394538769b | ||
|
f7828ed458 | ||
|
b40bb72643 | ||
|
4f1cd69bcc | ||
|
4d4b4c13c3 | ||
|
c8bf9049de | ||
|
ab91863c77 | ||
|
14473c742c | ||
|
4063cf5294 | ||
|
a7f33d669f |
44
backend/package-lock.json
generated
44
backend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
@@ -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",
|
||||
|
3
backend/src/@types/fastify.d.ts
vendored
3
backend/src/@types/fastify.d.ts
vendored
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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()),
|
||||
|
121
backend/src/lib/ip/index.test.ts
Normal file
121
backend/src/lib/ip/index.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { extractIPDetails, IPType, isValidCidr, isValidIp, isValidIpOrCidr } from "./index";
|
||||
|
||||
describe("IP Validation", () => {
|
||||
describe("isValidIp", () => {
|
||||
test("should validate IPv4 addresses with ports", () => {
|
||||
expect(isValidIp("192.168.1.1:8080")).toBe(true);
|
||||
expect(isValidIp("10.0.0.1:1234")).toBe(true);
|
||||
expect(isValidIp("172.16.0.1:80")).toBe(true);
|
||||
});
|
||||
|
||||
test("should validate IPv6 addresses with ports", () => {
|
||||
expect(isValidIp("[2001:db8::1]:8080")).toBe(true);
|
||||
expect(isValidIp("[fe80::1ff:fe23:4567:890a]:1234")).toBe(true);
|
||||
expect(isValidIp("[::1]:80")).toBe(true);
|
||||
});
|
||||
|
||||
test("should validate regular IPv4 addresses", () => {
|
||||
expect(isValidIp("192.168.1.1")).toBe(true);
|
||||
expect(isValidIp("10.0.0.1")).toBe(true);
|
||||
expect(isValidIp("172.16.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should validate regular IPv6 addresses", () => {
|
||||
expect(isValidIp("2001:db8::1")).toBe(true);
|
||||
expect(isValidIp("fe80::1ff:fe23:4567:890a")).toBe(true);
|
||||
expect(isValidIp("::1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject invalid IP addresses", () => {
|
||||
expect(isValidIp("256.256.256.256")).toBe(false);
|
||||
expect(isValidIp("192.168.1")).toBe(false);
|
||||
expect(isValidIp("192.168.1.1.1")).toBe(false);
|
||||
expect(isValidIp("2001:db8::1::1")).toBe(false);
|
||||
expect(isValidIp("invalid")).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject malformed IP addresses with ports", () => {
|
||||
expect(isValidIp("192.168.1.1:")).toBe(false);
|
||||
expect(isValidIp("192.168.1.1:abc")).toBe(false);
|
||||
expect(isValidIp("[2001:db8::1]")).toBe(false);
|
||||
expect(isValidIp("[2001:db8::1]:")).toBe(false);
|
||||
expect(isValidIp("[2001:db8::1]:abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidCidr", () => {
|
||||
test("should validate IPv4 CIDR blocks", () => {
|
||||
expect(isValidCidr("192.168.1.0/24")).toBe(true);
|
||||
expect(isValidCidr("10.0.0.0/8")).toBe(true);
|
||||
expect(isValidCidr("172.16.0.0/16")).toBe(true);
|
||||
});
|
||||
|
||||
test("should validate IPv6 CIDR blocks", () => {
|
||||
expect(isValidCidr("2001:db8::/32")).toBe(true);
|
||||
expect(isValidCidr("fe80::/10")).toBe(true);
|
||||
expect(isValidCidr("::/0")).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject invalid CIDR blocks", () => {
|
||||
expect(isValidCidr("192.168.1.0/33")).toBe(false);
|
||||
expect(isValidCidr("2001:db8::/129")).toBe(false);
|
||||
expect(isValidCidr("192.168.1.0/abc")).toBe(false);
|
||||
expect(isValidCidr("invalid/24")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidIpOrCidr", () => {
|
||||
test("should validate both IP addresses and CIDR blocks", () => {
|
||||
expect(isValidIpOrCidr("192.168.1.1")).toBe(true);
|
||||
expect(isValidIpOrCidr("2001:db8::1")).toBe(true);
|
||||
expect(isValidIpOrCidr("192.168.1.0/24")).toBe(true);
|
||||
expect(isValidIpOrCidr("2001:db8::/32")).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject invalid inputs", () => {
|
||||
expect(isValidIpOrCidr("invalid")).toBe(false);
|
||||
expect(isValidIpOrCidr("192.168.1.0/33")).toBe(false);
|
||||
expect(isValidIpOrCidr("2001:db8::/129")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIPDetails", () => {
|
||||
test("should extract IPv4 address details", () => {
|
||||
const result = extractIPDetails("192.168.1.1");
|
||||
expect(result).toEqual({
|
||||
ipAddress: "192.168.1.1",
|
||||
type: IPType.IPV4
|
||||
});
|
||||
});
|
||||
|
||||
test("should extract IPv6 address details", () => {
|
||||
const result = extractIPDetails("2001:db8::1");
|
||||
expect(result).toEqual({
|
||||
ipAddress: "2001:db8::1",
|
||||
type: IPType.IPV6
|
||||
});
|
||||
});
|
||||
|
||||
test("should extract IPv4 CIDR details", () => {
|
||||
const result = extractIPDetails("192.168.1.0/24");
|
||||
expect(result).toEqual({
|
||||
ipAddress: "192.168.1.0",
|
||||
type: IPType.IPV4,
|
||||
prefix: 24
|
||||
});
|
||||
});
|
||||
|
||||
test("should extract IPv6 CIDR details", () => {
|
||||
const result = extractIPDetails("2001:db8::/32");
|
||||
expect(result).toEqual({
|
||||
ipAddress: "2001:db8::",
|
||||
type: IPType.IPV6,
|
||||
prefix: 32
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid IP", () => {
|
||||
expect(() => extractIPDetails("invalid")).toThrow("Failed to extract IP details");
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,5 +1,7 @@
|
||||
import net from "node:net";
|
||||
|
||||
import RE2 from "re2";
|
||||
|
||||
import { ForbiddenRequestError } from "../errors";
|
||||
|
||||
export enum IPType {
|
||||
@@ -7,25 +9,55 @@ export enum IPType {
|
||||
IPV6 = "ipv6"
|
||||
}
|
||||
|
||||
const PORT_REGEX = new RE2(/^\d+$/);
|
||||
|
||||
/**
|
||||
* Strips port from IP address if present.
|
||||
* Handles both IPv4 (e.g. 1.2.3.4:1234) and IPv6 (e.g. [2001:db8::1]:8080) formats.
|
||||
* Returns the IP address without port and a boolean indicating if a port was present.
|
||||
*/
|
||||
const stripPort = (ip: string): { ipAddress: string } => {
|
||||
// Handle IPv6 with port (e.g. [2001:db8::1]:8080)
|
||||
if (ip.startsWith("[") && ip.includes("]:")) {
|
||||
const endBracketIndex = ip.indexOf("]");
|
||||
if (endBracketIndex === -1) return { ipAddress: ip };
|
||||
const ipPart = ip.slice(1, endBracketIndex);
|
||||
const portPart = ip.slice(endBracketIndex + 2);
|
||||
if (!portPart || !PORT_REGEX.test(portPart)) return { ipAddress: ip };
|
||||
return { ipAddress: ipPart };
|
||||
}
|
||||
|
||||
// Handle IPv4 with port (e.g. 1.2.3.4:1234)
|
||||
if (ip.includes(":")) {
|
||||
const [ipPart, portPart] = ip.split(":");
|
||||
if (!portPart || !PORT_REGEX.test(portPart)) return { ipAddress: ip };
|
||||
return { ipAddress: ipPart };
|
||||
}
|
||||
|
||||
return { ipAddress: ip };
|
||||
};
|
||||
|
||||
/**
|
||||
* Return details of IP [ip]:
|
||||
* - If [ip] is a specific IP address then return the IPv4/IPv6 address
|
||||
* - If [ip] is a subnet then return the network IPv4/IPv6 address and prefix
|
||||
*/
|
||||
export const extractIPDetails = (ip: string) => {
|
||||
if (net.isIPv4(ip))
|
||||
const { ipAddress } = stripPort(ip);
|
||||
|
||||
if (net.isIPv4(ipAddress))
|
||||
return {
|
||||
ipAddress: ip,
|
||||
ipAddress,
|
||||
type: IPType.IPV4
|
||||
};
|
||||
|
||||
if (net.isIPv6(ip))
|
||||
if (net.isIPv6(ipAddress))
|
||||
return {
|
||||
ipAddress: ip,
|
||||
ipAddress,
|
||||
type: IPType.IPV6
|
||||
};
|
||||
|
||||
const [ipNet, prefix] = ip.split("/");
|
||||
const [ipNet, prefix] = ipAddress.split("/");
|
||||
|
||||
let type;
|
||||
switch (net.isIP(ipNet)) {
|
||||
@@ -57,7 +89,8 @@ export const extractIPDetails = (ip: string) => {
|
||||
*
|
||||
*/
|
||||
export const isValidCidr = (cidr: string): boolean => {
|
||||
const [ip, prefix] = cidr.split("/");
|
||||
const { ipAddress } = stripPort(cidr);
|
||||
const [ip, prefix] = ipAddress.split("/");
|
||||
|
||||
const prefixNum = parseInt(prefix, 10);
|
||||
|
||||
@@ -90,13 +123,15 @@ export const isValidCidr = (cidr: string): boolean => {
|
||||
*
|
||||
*/
|
||||
export const isValidIpOrCidr = (ip: string): boolean => {
|
||||
const { ipAddress } = stripPort(ip);
|
||||
|
||||
// if the string contains a slash, treat it as a CIDR block
|
||||
if (ip.includes("/")) {
|
||||
return isValidCidr(ip);
|
||||
if (ipAddress.includes("/")) {
|
||||
return isValidCidr(ipAddress);
|
||||
}
|
||||
|
||||
// otherwise, treat it as a standalone IP address
|
||||
if (net.isIPv4(ip) || net.isIPv6(ip)) {
|
||||
if (net.isIPv4(ipAddress) || net.isIPv6(ipAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -104,7 +139,8 @@ export const isValidIpOrCidr = (ip: string): boolean => {
|
||||
};
|
||||
|
||||
export const isValidIp = (ip: string) => {
|
||||
return net.isIPv4(ip) || net.isIPv6(ip);
|
||||
const { ipAddress } = stripPort(ip);
|
||||
return net.isIPv4(ipAddress) || net.isIPv6(ipAddress);
|
||||
};
|
||||
|
||||
export type TIp = {
|
||||
@@ -112,6 +148,7 @@ export type TIp = {
|
||||
type: IPType;
|
||||
prefix: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the IP address [ipAddress] against the trusted IPs [trustedIps].
|
||||
*/
|
||||
@@ -126,8 +163,9 @@ export const checkIPAgainstBlocklist = ({ ipAddress, trustedIps }: { ipAddress:
|
||||
}
|
||||
}
|
||||
|
||||
const { type } = extractIPDetails(ipAddress);
|
||||
const check = blockList.check(ipAddress, type);
|
||||
const { type, ipAddress: cleanIpAddress } = extractIPDetails(ipAddress);
|
||||
|
||||
const check = blockList.check(cleanIpAddress, type);
|
||||
|
||||
if (!check)
|
||||
throw new ForbiddenRequestError({
|
||||
|
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -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" });
|
||||
|
||||
|
14
backend/src/server/plugins/primary-forwarding-mode.ts
Normal file
14
backend/src/server/plugins/primary-forwarding-mode.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
@@ -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);
|
||||
|
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -600,7 +600,7 @@ export const appConnectionServiceFactory = ({
|
||||
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
hcvault: hcVaultConnectionService(connectAppConnectionById),
|
||||
hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService),
|
||||
windmill: windmillConnectionService(connectAppConnectionById),
|
||||
teamcity: teamcityConnectionService(connectAppConnectionById),
|
||||
oci: ociConnectionService(connectAppConnectionById, licenseService),
|
||||
|
@@ -91,7 +91,7 @@ export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
throw new BadRequestError({
|
||||
message: (e as Error).message ?? `Unable to validate connection: verify credentials`
|
||||
message: (e as Error).message ?? "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@@ -70,7 +70,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
|
||||
tokenError = e;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
tokenError = e;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -204,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev
|
||||
tokenError = e;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -186,7 +186,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
|
||||
tokenError = e;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -447,7 +447,7 @@ export const validateGitHubConnectionCredentials = async (
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,18 +1,18 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
|
||||
import {
|
||||
THCVaultConnection,
|
||||
THCVaultConnectionConfig,
|
||||
THCVaultMountResponse,
|
||||
TValidateHCVaultConnectionCredentials
|
||||
} from "./hc-vault-connection-types";
|
||||
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
|
||||
|
||||
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
|
||||
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
|
||||
@@ -37,7 +37,78 @@ type TokenRespData = {
|
||||
};
|
||||
};
|
||||
|
||||
export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => {
|
||||
export const requestWithHCVaultGateway = async <T>(
|
||||
appConnection: { gatewayId?: string | null },
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
|
||||
requestConfig: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const { gatewayId } = appConnection;
|
||||
|
||||
// If gateway isn't set up, don't proxy request
|
||||
if (!gatewayId) {
|
||||
return request.request(requestConfig);
|
||||
}
|
||||
|
||||
const url = new URL(requestConfig.url as string);
|
||||
|
||||
await blockLocalAndPrivateIpAddresses(url.toString());
|
||||
|
||||
const [targetHost] = await verifyHostInputValidity(url.hostname, true);
|
||||
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId);
|
||||
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
|
||||
|
||||
return withGatewayProxy(
|
||||
async (proxyPort) => {
|
||||
const httpsAgent = new https.Agent({
|
||||
servername: targetHost
|
||||
});
|
||||
|
||||
url.protocol = "https:";
|
||||
url.host = `localhost:${proxyPort}`;
|
||||
|
||||
const finalRequestConfig: AxiosRequestConfig = {
|
||||
...requestConfig,
|
||||
url: url.toString(),
|
||||
httpsAgent,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
Host: targetHost
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return await request.request(finalRequestConfig);
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
logger.error(
|
||||
{ message: error.message, data: (error.response as undefined | { data: unknown })?.data },
|
||||
"Error during HashiCorp Vault gateway request:"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
protocol: GatewayProxyProtocol.Tcp,
|
||||
targetHost,
|
||||
targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated
|
||||
relayHost,
|
||||
relayPort: Number(relayPort),
|
||||
identityId: relayDetails.identityId,
|
||||
orgId: relayDetails.orgId,
|
||||
tlsOptions: {
|
||||
ca: relayDetails.certChain,
|
||||
cert: relayDetails.certificate,
|
||||
key: relayDetails.privateKey.toString()
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const getHCVaultAccessToken = async (
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
// Return access token directly if not using AppRole method
|
||||
if (connection.method !== HCVaultConnectionMethod.AppRole) {
|
||||
return connection.credentials.accessToken;
|
||||
@@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
|
||||
// Generate temporary token for AppRole method
|
||||
try {
|
||||
const { instanceUrl, roleId, secretId } = connection.credentials;
|
||||
const tokenResp = await request.post<TokenRespData>(
|
||||
`${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
|
||||
{ role_id: roleId, secret_id: secretId },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const tokenResp = await requestWithHCVaultGateway<TokenRespData>(connection, gatewayService, {
|
||||
url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
||||
},
|
||||
data: { role_id: roleId, secret_id: secretId }
|
||||
});
|
||||
|
||||
if (tokenResp.status !== 200) {
|
||||
throw new BadRequestError({
|
||||
@@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
|
||||
}
|
||||
};
|
||||
|
||||
export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(config);
|
||||
export const validateHCVaultConnectionCredentials = async (
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
|
||||
try {
|
||||
const accessToken = await getHCVaultAccessToken(config);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
// Verify token
|
||||
await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, {
|
||||
await requestWithHCVaultGateway(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/auth/token/lookup-self`,
|
||||
method: "GET",
|
||||
headers: { "X-Vault-Token": accessToken }
|
||||
});
|
||||
|
||||
return config.credentials;
|
||||
return connection.credentials;
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, "Unable to verify HC Vault connection");
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listHCVaultMounts = async (appConnection: THCVaultConnection) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(appConnection);
|
||||
const accessToken = await getHCVaultAccessToken(appConnection);
|
||||
export const listHCVaultMounts = async (
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
|
||||
const { data } = await request.get<THCVaultMountResponse>(`${instanceUrl}/v1/sys/mounts`, {
|
||||
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/sys/mounts`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {})
|
||||
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection(
|
||||
export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [
|
||||
BaseHCVaultConnectionSchema.extend({
|
||||
method: z.literal(HCVaultConnectionMethod.AccessToken),
|
||||
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({})
|
||||
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({
|
||||
namespace: true,
|
||||
instanceUrl: true
|
||||
})
|
||||
}),
|
||||
BaseHCVaultConnectionSchema.extend({
|
||||
method: z.literal(HCVaultConnectionMethod.AppRole),
|
||||
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({})
|
||||
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({
|
||||
namespace: true,
|
||||
instanceUrl: true,
|
||||
roleId: true
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("
|
||||
]);
|
||||
|
||||
export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and(
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault)
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })
|
||||
);
|
||||
|
||||
export const UpdateHCVaultConnectionSchema = z
|
||||
@@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
|
||||
})
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault));
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }));
|
||||
|
||||
export const HCVaultConnectionListItemSchema = z.object({
|
||||
name: z.literal("HCVault"),
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
@@ -11,12 +12,15 @@ type TGetAppConnectionFunc = (
|
||||
actor: OrgServiceActor
|
||||
) => Promise<THCVaultConnection>;
|
||||
|
||||
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
||||
export const hcVaultConnectionService = (
|
||||
getAppConnection: TGetAppConnectionFunc,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
|
||||
|
||||
try {
|
||||
const mounts = await listHCVaultMounts(appConnection);
|
||||
const mounts = await listHCVaultMounts(appConnection, gatewayService);
|
||||
return mounts;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to establish connection with Hashicorp Vault");
|
||||
|
@@ -453,23 +453,24 @@ export const authLoginServiceFactory = ({
|
||||
|
||||
const selectedOrg = await orgDAL.findById(organizationId);
|
||||
|
||||
if (!selectedOrgMembership) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if authEnforced is true and the current auth method is not an enforced method
|
||||
if (
|
||||
selectedOrg.authEnforced &&
|
||||
!isAuthMethodSaml(decodedToken.authMethod) &&
|
||||
decodedToken.authMethod !== AuthMethod.OIDC
|
||||
decodedToken.authMethod !== AuthMethod.OIDC &&
|
||||
!(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin)
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Login with the auth method required by your organization."
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedOrgMembership) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
|
||||
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
|
||||
|
||||
|
@@ -408,19 +408,123 @@ export const transformToInfisicalFormatNamespaceToProjects = (
|
||||
};
|
||||
};
|
||||
|
||||
export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => {
|
||||
const projects: Array<{ name: string; id: string }> = [];
|
||||
const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = [];
|
||||
const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = [];
|
||||
const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = [];
|
||||
|
||||
// track created entities to avoid duplicates
|
||||
const projectMap = new Map<string, string>(); // team name -> projectId
|
||||
const environmentMap = new Map<string, string>(); // team-name:envName -> environmentId
|
||||
const folderMap = new Map<string, string>(); // team-name:envName:folderPath -> folderId
|
||||
|
||||
for (const data of vaultData) {
|
||||
const { path, secretData } = data;
|
||||
|
||||
const pathParts = path.split("/").filter(Boolean);
|
||||
if (pathParts.length < 2) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// first level: environment (dev, prod, staging, etc.)
|
||||
const environmentName = pathParts[0];
|
||||
// second level: team name (team1, team2, etc.)
|
||||
const teamName = pathParts[1];
|
||||
// remaining parts: folder structure
|
||||
const folderParts = pathParts.slice(2);
|
||||
|
||||
// create project (team) if if doesn't exist
|
||||
if (!projectMap.has(teamName)) {
|
||||
const projectId = uuidv4();
|
||||
projectMap.set(teamName, projectId);
|
||||
projects.push({
|
||||
name: teamName,
|
||||
id: projectId
|
||||
});
|
||||
}
|
||||
const projectId = projectMap.get(teamName)!;
|
||||
|
||||
// create environment (dev, prod, etc.) for team
|
||||
const envKey = `${teamName}:${environmentName}`;
|
||||
if (!environmentMap.has(envKey)) {
|
||||
const environmentId = uuidv4();
|
||||
environmentMap.set(envKey, environmentId);
|
||||
environments.push({
|
||||
name: environmentName,
|
||||
id: environmentId,
|
||||
projectId
|
||||
});
|
||||
}
|
||||
const environmentId = environmentMap.get(envKey)!;
|
||||
|
||||
// create folder structure for path segments
|
||||
let currentFolderId: string | undefined;
|
||||
let currentPath = "";
|
||||
|
||||
for (const folderName of folderParts) {
|
||||
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
|
||||
const folderKey = `${teamName}:${environmentName}:${currentPath}`;
|
||||
|
||||
if (!folderMap.has(folderKey)) {
|
||||
const folderId = uuidv4();
|
||||
folderMap.set(folderKey, folderId);
|
||||
folders.push({
|
||||
id: folderId,
|
||||
name: folderName,
|
||||
environmentId,
|
||||
parentFolderId: currentFolderId || environmentId
|
||||
});
|
||||
currentFolderId = folderId;
|
||||
} else {
|
||||
currentFolderId = folderMap.get(folderKey)!;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(secretData)) {
|
||||
secrets.push({
|
||||
id: uuidv4(),
|
||||
name: key,
|
||||
environmentId,
|
||||
value: String(value),
|
||||
folderId: currentFolderId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
environments,
|
||||
folders,
|
||||
secrets
|
||||
};
|
||||
};
|
||||
|
||||
// refer to internal doc for more details on which ID's belong to which orgs.
|
||||
// when its a custom migration, then it doesn't matter which mapping type is used (as of now).
|
||||
export const vaultMigrationTransformMappings: Record<
|
||||
string,
|
||||
(vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData
|
||||
> = {
|
||||
"68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1
|
||||
} as const;
|
||||
|
||||
export const importVaultDataFn = async (
|
||||
{
|
||||
vaultAccessToken,
|
||||
vaultNamespace,
|
||||
vaultUrl,
|
||||
mappingType,
|
||||
gatewayId
|
||||
gatewayId,
|
||||
orgId
|
||||
}: {
|
||||
vaultAccessToken: string;
|
||||
vaultNamespace?: string;
|
||||
vaultUrl: string;
|
||||
mappingType: VaultMappingType;
|
||||
gatewayId?: string;
|
||||
orgId: string;
|
||||
},
|
||||
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
|
||||
) => {
|
||||
@@ -432,6 +536,25 @@ export const importVaultDataFn = async (
|
||||
});
|
||||
}
|
||||
|
||||
let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData;
|
||||
|
||||
if (mappingType === VaultMappingType.Custom) {
|
||||
transformFn = vaultMigrationTransformMappings[orgId];
|
||||
|
||||
if (!transformFn) {
|
||||
throw new BadRequestError({
|
||||
message: "Please contact our sales team to enable custom vault migrations."
|
||||
});
|
||||
}
|
||||
} else {
|
||||
transformFn = transformToInfisicalFormatNamespaceToProjects;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ orgId, mappingType },
|
||||
`[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform`
|
||||
);
|
||||
|
||||
const vaultApi = vaultFactory(gatewayService);
|
||||
|
||||
const vaultData = await vaultApi.collectVaultData({
|
||||
@@ -441,7 +564,5 @@ export const importVaultDataFn = async (
|
||||
gatewayId
|
||||
});
|
||||
|
||||
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
|
||||
|
||||
return infisicalData;
|
||||
return transformFn(vaultData, mappingType);
|
||||
};
|
||||
|
@@ -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;
|
||||
@@ -101,7 +112,8 @@ export const externalMigrationServiceFactory = ({
|
||||
vaultNamespace,
|
||||
vaultUrl,
|
||||
mappingType,
|
||||
gatewayId
|
||||
gatewayId,
|
||||
orgId: actorOrgId
|
||||
},
|
||||
{
|
||||
gatewayService
|
||||
@@ -127,8 +139,37 @@ export const externalMigrationServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const hasCustomVaultMigration = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
provider
|
||||
}: THasCustomVaultMigrationDTO) => {
|
||||
const { membership } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
if (membership.role !== OrgMembershipRole.Admin) {
|
||||
throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" });
|
||||
}
|
||||
|
||||
if (provider !== ExternalMigrationProviders.Vault) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid provider. Vault is the only supported provider for custom migrations."
|
||||
});
|
||||
}
|
||||
|
||||
return actorOrgId in vaultMigrationTransformMappings;
|
||||
};
|
||||
|
||||
return {
|
||||
importEnvKeyData,
|
||||
importVaultData
|
||||
importVaultData,
|
||||
hasCustomVaultMigration
|
||||
};
|
||||
};
|
||||
|
@@ -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"
|
||||
}
|
||||
|
@@ -0,0 +1,208 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { ProjectType, TableName } from "@app/db/schemas";
|
||||
|
||||
export type TOfflineUsageReportDALFactory = ReturnType<typeof offlineUsageReportDALFactory>;
|
||||
|
||||
export const offlineUsageReportDALFactory = (db: TDbClient) => {
|
||||
const getUserMetrics = async () => {
|
||||
// Get total users and admin users
|
||||
const userMetrics = (await db
|
||||
.from(TableName.Users)
|
||||
.select(
|
||||
db.raw(
|
||||
`
|
||||
COUNT(*) as total_users,
|
||||
COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users
|
||||
`
|
||||
)
|
||||
)
|
||||
.where({ isGhost: false })
|
||||
.first()) as { total_users: string; admin_users: string } | undefined;
|
||||
|
||||
// Get users by auth method
|
||||
const authMethodStats = (await db
|
||||
.from(TableName.Users)
|
||||
.select(
|
||||
db.raw(`
|
||||
unnest("authMethods") as auth_method,
|
||||
COUNT(*) as count
|
||||
`)
|
||||
)
|
||||
.where({ isGhost: false })
|
||||
.whereNotNull("authMethods")
|
||||
.groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>;
|
||||
|
||||
const usersByAuthMethod = authMethodStats.reduce(
|
||||
(acc: Record<string, number>, row: { auth_method: string; count: string }) => {
|
||||
acc[row.auth_method] = parseInt(row.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
totalUsers: parseInt(userMetrics?.total_users || "0", 10),
|
||||
adminUsers: parseInt(userMetrics?.admin_users || "0", 10),
|
||||
usersByAuthMethod
|
||||
};
|
||||
};
|
||||
|
||||
const getMachineIdentityMetrics = async () => {
|
||||
// Get total machine identities
|
||||
const identityMetrics = (await db
|
||||
.from(TableName.Identity)
|
||||
.select(
|
||||
db.raw(
|
||||
`
|
||||
COUNT(*) as total_identities
|
||||
`
|
||||
)
|
||||
)
|
||||
.first()) as { total_identities: string } | undefined;
|
||||
|
||||
// Get identities by auth method
|
||||
const authMethodStats = (await db
|
||||
.from(TableName.Identity)
|
||||
.select("authMethod")
|
||||
.count("* as count")
|
||||
.whereNotNull("authMethod")
|
||||
.groupBy("authMethod")) as Array<{ authMethod: string; count: string }>;
|
||||
|
||||
const machineIdentitiesByAuthMethod = authMethodStats.reduce(
|
||||
(acc: Record<string, number>, row: { authMethod: string; count: string }) => {
|
||||
acc[row.authMethod] = parseInt(row.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
return {
|
||||
totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10),
|
||||
machineIdentitiesByAuthMethod
|
||||
};
|
||||
};
|
||||
|
||||
const getProjectMetrics = async () => {
|
||||
// Get total projects and projects by type
|
||||
const projectMetrics = (await db
|
||||
.from(TableName.Project)
|
||||
.select("type")
|
||||
.count("* as count")
|
||||
.groupBy("type")) as Array<{ type: string; count: string }>;
|
||||
|
||||
const totalProjects = projectMetrics.reduce(
|
||||
(sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10),
|
||||
0
|
||||
);
|
||||
const projectsByType = projectMetrics.reduce(
|
||||
(acc: Record<string, number>, row: { type: string; count: string }) => {
|
||||
acc[row.type] = parseInt(row.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
// Calculate average secrets per project
|
||||
const secretsPerProject = (await db
|
||||
.from(`${TableName.SecretV2} as s`)
|
||||
.select("p.id as projectId")
|
||||
.count("s.id as count")
|
||||
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
|
||||
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
|
||||
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
|
||||
.where("p.type", ProjectType.SecretManager)
|
||||
.groupBy("p.id")
|
||||
.whereNotNull("p.id")) as Array<{ projectId: string; count: string }>;
|
||||
|
||||
const averageSecretsPerProject =
|
||||
secretsPerProject.length > 0
|
||||
? secretsPerProject.reduce(
|
||||
(sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10),
|
||||
0
|
||||
) / secretsPerProject.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalProjects,
|
||||
projectsByType,
|
||||
averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100
|
||||
};
|
||||
};
|
||||
|
||||
const getSecretMetrics = async () => {
|
||||
// Get total secrets
|
||||
const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as
|
||||
| { count: string }
|
||||
| undefined;
|
||||
|
||||
const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10);
|
||||
|
||||
// Get secrets by project
|
||||
const secretsByProject = (await db
|
||||
.from(`${TableName.SecretV2} as s`)
|
||||
.select("p.id as projectId", "p.name as projectName")
|
||||
.count("s.id as secretCount")
|
||||
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
|
||||
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
|
||||
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
|
||||
.where("p.type", ProjectType.SecretManager)
|
||||
.groupBy("p.id", "p.name")
|
||||
.whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>;
|
||||
|
||||
return {
|
||||
totalSecrets,
|
||||
secretsByProject: secretsByProject.map(
|
||||
(row: { projectId: string; projectName: string; secretCount: string }) => ({
|
||||
projectId: row.projectId,
|
||||
projectName: row.projectName,
|
||||
secretCount: parseInt(row.secretCount, 10)
|
||||
})
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
const getSecretSyncMetrics = async () => {
|
||||
const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as
|
||||
| { count: string }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10)
|
||||
};
|
||||
};
|
||||
|
||||
const getDynamicSecretMetrics = async () => {
|
||||
const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as
|
||||
| { count: string }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10)
|
||||
};
|
||||
};
|
||||
|
||||
const getSecretRotationMetrics = async () => {
|
||||
// Check both v1 and v2 secret rotation tables
|
||||
const [v1RotationsResult, v2RotationsResult] = await Promise.all([
|
||||
db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>,
|
||||
db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined>
|
||||
]);
|
||||
|
||||
const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10);
|
||||
const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10);
|
||||
|
||||
return {
|
||||
totalSecretRotations: totalV1Rotations + totalV2Rotations
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getUserMetrics,
|
||||
getMachineIdentityMetrics,
|
||||
getProjectMetrics,
|
||||
getSecretMetrics,
|
||||
getSecretSyncMetrics,
|
||||
getDynamicSecretMetrics,
|
||||
getSecretRotationMetrics
|
||||
};
|
||||
};
|
@@ -0,0 +1,133 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal";
|
||||
|
||||
type TOfflineUsageReportServiceFactoryDep = {
|
||||
offlineUsageReportDAL: TOfflineUsageReportDALFactory;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getCustomerId" | "getLicenseId">;
|
||||
};
|
||||
|
||||
export type TOfflineUsageReportServiceFactory = ReturnType<typeof offlineUsageReportServiceFactory>;
|
||||
|
||||
export const offlineUsageReportServiceFactory = ({
|
||||
offlineUsageReportDAL,
|
||||
licenseService
|
||||
}: TOfflineUsageReportServiceFactoryDep) => {
|
||||
const signReportContent = (content: string, licenseId: string): string => {
|
||||
const contentHash = crypto.createHash("sha256").update(content).digest("hex");
|
||||
const hmac = crypto.createHmac("sha256", licenseId);
|
||||
hmac.update(contentHash);
|
||||
return hmac.digest("hex");
|
||||
};
|
||||
|
||||
const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => {
|
||||
const expectedSignature = signReportContent(content, licenseId);
|
||||
return signature === expectedSignature;
|
||||
};
|
||||
|
||||
const generateUsageReportCSV = async () => {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.LICENSE_KEY_OFFLINE) {
|
||||
throw new BadRequestError({
|
||||
message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured."
|
||||
});
|
||||
}
|
||||
|
||||
const customerId = licenseService.getCustomerId() as string;
|
||||
const licenseId = licenseService.getLicenseId();
|
||||
|
||||
const [
|
||||
userMetrics,
|
||||
machineIdentityMetrics,
|
||||
projectMetrics,
|
||||
secretMetrics,
|
||||
secretSyncMetrics,
|
||||
dynamicSecretMetrics,
|
||||
secretRotationMetrics
|
||||
] = await Promise.all([
|
||||
offlineUsageReportDAL.getUserMetrics(),
|
||||
offlineUsageReportDAL.getMachineIdentityMetrics(),
|
||||
offlineUsageReportDAL.getProjectMetrics(),
|
||||
offlineUsageReportDAL.getSecretMetrics(),
|
||||
offlineUsageReportDAL.getSecretSyncMetrics(),
|
||||
offlineUsageReportDAL.getDynamicSecretMetrics(),
|
||||
offlineUsageReportDAL.getSecretRotationMetrics()
|
||||
]);
|
||||
|
||||
const headers = [
|
||||
"Total Users",
|
||||
"Admin Users",
|
||||
"Total Identities",
|
||||
"Total Projects",
|
||||
"Total Secrets",
|
||||
"Total Secret Syncs",
|
||||
"Total Dynamic Secrets",
|
||||
"Total Secret Rotations",
|
||||
"Avg Secrets Per Project"
|
||||
];
|
||||
|
||||
const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod);
|
||||
allUserAuthMethods.forEach((method) => {
|
||||
headers.push(`Users Auth ${method}`);
|
||||
});
|
||||
|
||||
const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod);
|
||||
allIdentityAuthMethods.forEach((method) => {
|
||||
headers.push(`Identities Auth ${method}`);
|
||||
});
|
||||
|
||||
const allProjectTypes = Object.keys(projectMetrics.projectsByType);
|
||||
allProjectTypes.forEach((type) => {
|
||||
headers.push(`Projects ${type}`);
|
||||
});
|
||||
|
||||
headers.push("Signature");
|
||||
|
||||
const dataRow: (string | number)[] = [
|
||||
userMetrics.totalUsers,
|
||||
userMetrics.adminUsers,
|
||||
machineIdentityMetrics.totalMachineIdentities,
|
||||
projectMetrics.totalProjects,
|
||||
secretMetrics.totalSecrets,
|
||||
secretSyncMetrics.totalSecretSyncs,
|
||||
dynamicSecretMetrics.totalDynamicSecrets,
|
||||
secretRotationMetrics.totalSecretRotations,
|
||||
projectMetrics.averageSecretsPerProject
|
||||
];
|
||||
|
||||
allUserAuthMethods.forEach((method) => {
|
||||
dataRow.push(userMetrics.usersByAuthMethod[method] || 0);
|
||||
});
|
||||
allIdentityAuthMethods.forEach((method) => {
|
||||
dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0);
|
||||
});
|
||||
|
||||
allProjectTypes.forEach((type) => {
|
||||
dataRow.push(projectMetrics.projectsByType[type] || 0);
|
||||
});
|
||||
|
||||
const headersWithoutSignature = headers.slice(0, -1);
|
||||
const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n");
|
||||
|
||||
const signature = signReportContent(contentWithoutSignature, licenseId);
|
||||
dataRow.push(signature);
|
||||
|
||||
const csvContent = [headers.join(","), dataRow.join(",")].join("\n");
|
||||
|
||||
return {
|
||||
csvContent,
|
||||
signature,
|
||||
filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv`
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
generateUsageReportCSV,
|
||||
verifyReportSignature: (csvContent: string, signature: string, licenseId: string) =>
|
||||
verifyReportContent(csvContent, signature, licenseId)
|
||||
};
|
||||
};
|
@@ -0,0 +1,42 @@
|
||||
export interface TUsageMetrics {
|
||||
// User metrics
|
||||
totalUsers: number;
|
||||
usersByAuthMethod: Record<string, number>;
|
||||
adminUsers: number;
|
||||
|
||||
// Machine identity metrics
|
||||
totalMachineIdentities: number;
|
||||
machineIdentitiesByAuthMethod: Record<string, number>;
|
||||
|
||||
// Project metrics
|
||||
totalProjects: number;
|
||||
projectsByType: Record<string, number>;
|
||||
averageSecretsPerProject: number;
|
||||
|
||||
// Secret metrics
|
||||
totalSecrets: number;
|
||||
totalSecretSyncs: number;
|
||||
totalDynamicSecrets: number;
|
||||
totalSecretRotations: number;
|
||||
}
|
||||
|
||||
export interface TUsageReportMetadata {
|
||||
generatedAt: string;
|
||||
instanceId: string;
|
||||
reportVersion: string;
|
||||
}
|
||||
|
||||
export interface TUsageReport {
|
||||
metadata: TUsageReportMetadata;
|
||||
metrics: TUsageMetrics;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface TGenerateUsageReportDTO {
|
||||
includeSignature?: boolean;
|
||||
}
|
||||
|
||||
export interface TVerifyUsageReportDTO {
|
||||
reportData: string;
|
||||
signature: string;
|
||||
}
|
@@ -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,
|
||||
|
@@ -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;
|
||||
|
@@ -1,9 +1,13 @@
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||
import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault";
|
||||
import {
|
||||
getHCVaultAccessToken,
|
||||
getHCVaultInstanceUrl,
|
||||
requestWithHCVaultGateway,
|
||||
THCVaultConnection
|
||||
} from "@app/services/app-connection/hc-vault";
|
||||
import {
|
||||
THCVaultListVariables,
|
||||
THCVaultListVariablesResponse,
|
||||
@@ -14,19 +18,20 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => {
|
||||
await blockLocalAndPrivateIpAddresses(instanceUrl);
|
||||
|
||||
const listHCVaultVariables = async (
|
||||
{ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
try {
|
||||
const { data } = await request.get<THCVaultListVariablesResponse>(
|
||||
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(namespace ? { "X-Vault-Namespace": namespace } : {})
|
||||
}
|
||||
const { data } = await requestWithHCVaultGateway<THCVaultListVariablesResponse>(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(namespace ? { "X-Vault-Namespace": namespace } : {})
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return data.data.data;
|
||||
} catch (error: unknown) {
|
||||
@@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken
|
||||
};
|
||||
|
||||
// Hashicorp Vault updates all variables in one batch. This is to respect their versioning
|
||||
const updateHCVaultVariables = async ({
|
||||
path,
|
||||
instanceUrl,
|
||||
namespace,
|
||||
accessToken,
|
||||
mount,
|
||||
data
|
||||
}: TPostHCVaultVariable) => {
|
||||
await blockLocalAndPrivateIpAddresses(instanceUrl);
|
||||
|
||||
return request.post(
|
||||
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||
{
|
||||
data
|
||||
const updateHCVaultVariables = async (
|
||||
{ path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable,
|
||||
connection: THCVaultConnection,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
return requestWithHCVaultGateway(connection, gatewayService, {
|
||||
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"X-Vault-Token": accessToken,
|
||||
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
data: { data }
|
||||
});
|
||||
};
|
||||
|
||||
export const HCVaultSyncFns = {
|
||||
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
syncSecrets: async (
|
||||
secretSync: THCVaultSyncWithCredentials,
|
||||
secretMap: TSecretMap,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
@@ -74,16 +75,20 @@ export const HCVaultSyncFns = {
|
||||
} = secretSync;
|
||||
|
||||
const { namespace } = connection.credentials;
|
||||
const accessToken = await getHCVaultAccessToken(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
|
||||
const variables = await listHCVaultVariables({
|
||||
instanceUrl,
|
||||
accessToken,
|
||||
namespace,
|
||||
mount,
|
||||
path
|
||||
});
|
||||
const variables = await listHCVaultVariables(
|
||||
{
|
||||
instanceUrl,
|
||||
accessToken,
|
||||
namespace,
|
||||
mount,
|
||||
path
|
||||
},
|
||||
connection,
|
||||
gatewayService
|
||||
);
|
||||
let tainted = false;
|
||||
|
||||
for (const entry of Object.entries(secretMap)) {
|
||||
@@ -110,24 +115,36 @@ export const HCVaultSyncFns = {
|
||||
if (!tainted) return;
|
||||
|
||||
try {
|
||||
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
|
||||
await updateHCVaultVariables(
|
||||
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
|
||||
connection,
|
||||
gatewayService
|
||||
);
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error
|
||||
});
|
||||
}
|
||||
},
|
||||
removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
removeSecrets: async (
|
||||
secretSync: THCVaultSyncWithCredentials,
|
||||
secretMap: TSecretMap,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { mount, path }
|
||||
} = secretSync;
|
||||
|
||||
const { namespace } = connection.credentials;
|
||||
const accessToken = await getHCVaultAccessToken(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
|
||||
const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path });
|
||||
const variables = await listHCVaultVariables(
|
||||
{ instanceUrl, namespace, accessToken, mount, path },
|
||||
connection,
|
||||
gatewayService
|
||||
);
|
||||
|
||||
for await (const [key] of Object.entries(variables)) {
|
||||
if (key in secretMap) {
|
||||
@@ -136,30 +153,41 @@ export const HCVaultSyncFns = {
|
||||
}
|
||||
|
||||
try {
|
||||
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
|
||||
await updateHCVaultVariables(
|
||||
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
|
||||
connection,
|
||||
gatewayService
|
||||
);
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error
|
||||
});
|
||||
}
|
||||
},
|
||||
getSecrets: async (secretSync: THCVaultSyncWithCredentials) => {
|
||||
getSecrets: async (
|
||||
secretSync: THCVaultSyncWithCredentials,
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||
) => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { mount, path }
|
||||
} = secretSync;
|
||||
|
||||
const { namespace } = connection.credentials;
|
||||
const accessToken = await getHCVaultAccessToken(connection);
|
||||
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
|
||||
const instanceUrl = await getHCVaultInstanceUrl(connection);
|
||||
|
||||
const variables = await listHCVaultVariables({
|
||||
instanceUrl,
|
||||
namespace,
|
||||
accessToken,
|
||||
mount,
|
||||
path
|
||||
});
|
||||
const variables = await listHCVaultVariables(
|
||||
{
|
||||
instanceUrl,
|
||||
namespace,
|
||||
accessToken,
|
||||
mount,
|
||||
path
|
||||
},
|
||||
connection,
|
||||
gatewayService
|
||||
);
|
||||
|
||||
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }]));
|
||||
}
|
||||
|
@@ -244,7 +244,7 @@ export const SecretSyncFns = {
|
||||
case SecretSync.Windmill:
|
||||
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.HCVault:
|
||||
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService);
|
||||
case SecretSync.TeamCity:
|
||||
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.OCIVault:
|
||||
@@ -283,7 +283,7 @@ export const SecretSyncFns = {
|
||||
},
|
||||
getSecrets: async (
|
||||
secretSync: TSecretSyncWithCredentials,
|
||||
{ kmsService, appConnectionDAL }: TSyncSecretDeps
|
||||
{ kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps
|
||||
): Promise<TSecretMap> => {
|
||||
let secretMap: TSecretMap;
|
||||
switch (secretSync.destination) {
|
||||
@@ -341,7 +341,7 @@ export const SecretSyncFns = {
|
||||
secretMap = await WindmillSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
case SecretSync.HCVault:
|
||||
secretMap = await HCVaultSyncFns.getSecrets(secretSync);
|
||||
secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService);
|
||||
break;
|
||||
case SecretSync.TeamCity:
|
||||
secretMap = await TeamCitySyncFns.getSecrets(secretSync);
|
||||
@@ -451,7 +451,7 @@ export const SecretSyncFns = {
|
||||
case SecretSync.Windmill:
|
||||
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.HCVault:
|
||||
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService);
|
||||
case SecretSync.TeamCity:
|
||||
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.OCIVault:
|
||||
|
@@ -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;
|
@@ -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";
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
};
|
||||
};
|
||||
|
@@ -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",
|
||||
|
40
docs/.eslintrc.js
Normal file
40
docs/.eslintrc.js
Normal file
@@ -0,0 +1,40 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
],
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react'],
|
||||
},
|
||||
},
|
||||
plugins: ['react'],
|
||||
rules: {
|
||||
'react/jsx-uses-react': 'error',
|
||||
'react/jsx-uses-vars': 'error',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
ignorePatterns: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'build/',
|
||||
'*.config.js',
|
||||
],
|
||||
};
|
@@ -98,6 +98,7 @@
|
||||
{
|
||||
"group": "App Connections",
|
||||
"pages": [
|
||||
"integrations/app-connections",
|
||||
"integrations/app-connections/overview",
|
||||
{
|
||||
"group": "Connections",
|
||||
@@ -184,6 +185,7 @@
|
||||
{
|
||||
"group": "User Authentication",
|
||||
"pages": [
|
||||
"integrations/user-authentication",
|
||||
"documentation/platform/auth-methods/email-password",
|
||||
{
|
||||
"group": "SSO",
|
||||
@@ -243,6 +245,7 @@
|
||||
{
|
||||
"group": "Machine Identities",
|
||||
"pages": [
|
||||
"integrations/machine-authentication",
|
||||
"documentation/platform/identities/alicloud-auth",
|
||||
"documentation/platform/identities/aws-auth",
|
||||
"documentation/platform/identities/azure-auth",
|
||||
@@ -417,6 +420,7 @@
|
||||
{
|
||||
"group": "Secret Rotation",
|
||||
"pages": [
|
||||
"integrations/secret-rotations",
|
||||
"documentation/platform/secret-rotation/overview",
|
||||
"documentation/platform/secret-rotation/auth0-client-secret",
|
||||
"documentation/platform/secret-rotation/aws-iam-user-secret",
|
||||
@@ -432,6 +436,7 @@
|
||||
{
|
||||
"group": "Dynamic Secrets",
|
||||
"pages": [
|
||||
"integrations/dynamic-secrets",
|
||||
"documentation/platform/dynamic-secrets/overview",
|
||||
"documentation/platform/dynamic-secrets/aws-elasticache",
|
||||
"documentation/platform/dynamic-secrets/aws-iam",
|
||||
@@ -502,6 +507,7 @@
|
||||
{
|
||||
"group": "Secret Syncs",
|
||||
"pages": [
|
||||
"integrations/secret-syncs",
|
||||
"integrations/secret-syncs/overview",
|
||||
{
|
||||
"group": "Syncs",
|
||||
@@ -607,6 +613,7 @@
|
||||
{
|
||||
"group": "Framework Integrations",
|
||||
"pages": [
|
||||
"integrations/framework-integrations",
|
||||
"integrations/frameworks/spring-boot-maven",
|
||||
"integrations/frameworks/react",
|
||||
"integrations/frameworks/vue",
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 542 KiB |
8
docs/integrations/app-connections.mdx
Normal file
8
docs/integrations/app-connections.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available app connections for Infisical."
|
||||
---
|
||||
|
||||
import { AppConnectionsBrowser } from "/snippets/AppConnectionsBrowser.jsx";
|
||||
|
||||
<AppConnectionsBrowser />
|
@@ -149,6 +149,7 @@ Infisical supports two methods for connecting to Hashicorp Vault.
|
||||
<Tab title="App Role">
|
||||
- **Name**: The name of the connection being created. Must be slug-friendly.
|
||||
- **Description**: An optional description to provide details about this connection.
|
||||
- **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway.
|
||||
- **Instance URL**: The URL of your Hashicorp Vault instance.
|
||||
- **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces.
|
||||
- **Role ID**: The Role ID generated in the steps above.
|
||||
@@ -157,6 +158,7 @@ Infisical supports two methods for connecting to Hashicorp Vault.
|
||||
<Tab title="Access Token">
|
||||
- **Name**: The name of the connection being created. Must be slug-friendly.
|
||||
- **Description**: An optional description to provide details about this connection.
|
||||
- **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway.
|
||||
- **Instance URL**: The URL of your Hashicorp Vault instance.
|
||||
- **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces.
|
||||
- **Access Token**: The Access Token generated in the steps above.
|
||||
|
@@ -79,4 +79,4 @@ in the UI or by passing the associated `connectionId` when generating resources
|
||||
## Platform Managed Credentials
|
||||
|
||||
Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option,
|
||||
Infisical will modify the credentials to prevent external use of the configured access entity.
|
||||
Infisical will modify the credentials to prevent external use of the configured access entity.
|
||||
|
9
docs/integrations/dynamic-secrets.mdx
Normal file
9
docs/integrations/dynamic-secrets.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Dynamic Secrets"
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available dynamic secrets for Infisical."
|
||||
---
|
||||
|
||||
import { DynamicSecretsBrowser } from "/snippets/DynamicSecretsBrowser.jsx";
|
||||
|
||||
<DynamicSecretsBrowser />
|
9
docs/integrations/framework-integrations.mdx
Normal file
9
docs/integrations/framework-integrations.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Framework Integrations"
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available framework integrations for Infisical."
|
||||
---
|
||||
|
||||
import { FrameworkIntegrationsBrowser } from "/snippets/FrameworkIntegrationsBrowser.jsx";
|
||||
|
||||
<FrameworkIntegrationsBrowser />
|
9
docs/integrations/machine-authentication.mdx
Normal file
9
docs/integrations/machine-authentication.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Machine Authentication"
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available machine authentication methods for Infisical."
|
||||
---
|
||||
|
||||
import { MachineAuthenticationBrowser } from "/snippets/MachineAuthenticationBrowser.jsx";
|
||||
|
||||
<MachineAuthenticationBrowser />
|
8
docs/integrations/secret-rotations.mdx
Normal file
8
docs/integrations/secret-rotations.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available secret rotations for Infisical."
|
||||
---
|
||||
|
||||
import { RotationsBrowser } from "/snippets/RotationsBrowser.jsx";
|
||||
|
||||
<RotationsBrowser />
|
8
docs/integrations/secret-syncs.mdx
Normal file
8
docs/integrations/secret-syncs.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available secret syncs for Infisical."
|
||||
---
|
||||
|
||||
import { SecretSyncsBrowser } from "/snippets/SecretSyncsBrowser.jsx";
|
||||
|
||||
<SecretSyncsBrowser />
|
9
docs/integrations/user-authentication.mdx
Normal file
9
docs/integrations/user-authentication.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "User Authentication"
|
||||
sidebarTitle: "Explore Options"
|
||||
description: "Browse and search through all available user authentication methods for Infisical."
|
||||
---
|
||||
|
||||
import { UserAuthenticationBrowser } from "/snippets/UserAuthenticationBrowser.jsx";
|
||||
|
||||
<UserAuthenticationBrowser />
|
155
docs/snippets/AppConnectionsBrowser.jsx
Normal file
155
docs/snippets/AppConnectionsBrowser.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const AppConnectionsBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security'];
|
||||
|
||||
const connections = [
|
||||
{"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"},
|
||||
{"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"},
|
||||
{"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"},
|
||||
{"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"},
|
||||
{"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"},
|
||||
{"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"},
|
||||
{"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"},
|
||||
{"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"},
|
||||
{"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"},
|
||||
{"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"},
|
||||
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"},
|
||||
{"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"},
|
||||
{"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"},
|
||||
{"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"},
|
||||
{"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"},
|
||||
{"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"},
|
||||
{"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"},
|
||||
{"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"},
|
||||
{"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"},
|
||||
{"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredConnections = useMemo(() => {
|
||||
let filtered = connections;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(connection => connection.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(connection =>
|
||||
connection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
connection.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
connection.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search app connections..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredConnections.length} app connection{filteredConnections.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connections List */}
|
||||
{filteredConnections.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredConnections.map((connection, index) => (
|
||||
<a
|
||||
key={connection.slug}
|
||||
href={connection.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{connection.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{connection.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{connection.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No app connections found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
140
docs/snippets/DynamicSecretsBrowser.jsx
Normal file
140
docs/snippets/DynamicSecretsBrowser.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const DynamicSecretsBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches', 'Directory Services', 'CI/CD', 'Container Orchestration', 'Authentication'];
|
||||
|
||||
const dynamicSecrets = [
|
||||
{"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"},
|
||||
{"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"},
|
||||
{"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"},
|
||||
{"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"},
|
||||
{"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "MongoDB", "slug": "mongodb", "path": "/documentation/platform/dynamic-secrets/mongo-db", "description": "Learn how to generate dynamic MongoDB database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "MongoDB Atlas", "slug": "mongodb-atlas", "path": "/documentation/platform/dynamic-secrets/mongo-atlas", "description": "Learn how to generate dynamic MongoDB Atlas database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "MySQL", "slug": "mysql", "path": "/documentation/platform/dynamic-secrets/mysql", "description": "Learn how to generate dynamic MySQL database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "PostgreSQL", "slug": "postgresql", "path": "/documentation/platform/dynamic-secrets/postgresql", "description": "Learn how to generate dynamic PostgreSQL database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Microsoft SQL Server", "slug": "mssql", "path": "/documentation/platform/dynamic-secrets/mssql", "description": "Learn how to generate dynamic Microsoft SQL Server credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Oracle Database", "slug": "oracle", "path": "/documentation/platform/dynamic-secrets/oracle", "description": "Learn how to generate dynamic Oracle Database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "SAP ASE", "slug": "sap-ase", "path": "/documentation/platform/dynamic-secrets/sap-ase", "description": "Learn how to generate dynamic SAP ASE database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "SAP HANA", "slug": "sap-hana", "path": "/documentation/platform/dynamic-secrets/sap-hana", "description": "Learn how to generate dynamic SAP HANA database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Snowflake", "slug": "snowflake", "path": "/documentation/platform/dynamic-secrets/snowflake", "description": "Learn how to generate dynamic Snowflake database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Vertica", "slug": "vertica", "path": "/documentation/platform/dynamic-secrets/vertica", "description": "Learn how to generate dynamic Vertica database credentials on-demand.", "category": "Databases"},
|
||||
{"name": "Redis", "slug": "redis", "path": "/documentation/platform/dynamic-secrets/redis", "description": "Learn how to generate dynamic Redis credentials on-demand.", "category": "Caches"},
|
||||
{"name": "ElasticSearch", "slug": "elasticsearch", "path": "/documentation/platform/dynamic-secrets/elastic-search", "description": "Learn how to generate dynamic ElasticSearch credentials on-demand.", "category": "Databases"},
|
||||
{"name": "RabbitMQ", "slug": "rabbitmq", "path": "/documentation/platform/dynamic-secrets/rabbit-mq", "description": "Learn how to generate dynamic RabbitMQ credentials on-demand.", "category": "Message Queues"},
|
||||
{"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/dynamic-secrets/ldap", "description": "Learn how to generate dynamic LDAP credentials on-demand.", "category": "Directory Services"},
|
||||
{"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "CI/CD"},
|
||||
{"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Container Orchestration"},
|
||||
{"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Authentication"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredDynamicSecrets = useMemo(() => {
|
||||
let filtered = dynamicSecrets;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(secret => secret.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(secret =>
|
||||
secret.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
secret.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
secret.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search dynamic secrets..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredDynamicSecrets.length} dynamic secret{filteredDynamicSecrets.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Secrets List */}
|
||||
{filteredDynamicSecrets.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredDynamicSecrets.map((secret, index) => (
|
||||
<a
|
||||
key={secret.slug}
|
||||
href={secret.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{secret.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{secret.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{secret.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No dynamic secrets found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
101
docs/snippets/FrameworkIntegrationsBrowser.jsx
Normal file
101
docs/snippets/FrameworkIntegrationsBrowser.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const FrameworkIntegrationsBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const integrations = [
|
||||
{"name": "React", "slug": "react", "path": "/integrations/frameworks/react", "description": "Learn how to integrate Infisical with React applications for secure secret management.", "category": "Web Frameworks"},
|
||||
{"name": "Next.js", "slug": "nextjs", "path": "/integrations/frameworks/nextjs", "description": "Learn how to integrate Infisical with Next.js applications.", "category": "Web Frameworks"},
|
||||
{"name": "Vue", "slug": "vuejs", "path": "/integrations/frameworks/vue", "description": "Learn how to integrate Infisical with Vue.js applications.", "category": "Web Frameworks"},
|
||||
{"name": "Nuxt", "slug": "nuxtjs", "path": "/integrations/frameworks/nuxt", "description": "Learn how to integrate Infisical with Nuxt.js applications.", "category": "Web Frameworks"},
|
||||
{"name": "SvelteKit", "slug": "sveltekit", "path": "/integrations/frameworks/sveltekit", "description": "Learn how to integrate Infisical with SvelteKit applications.", "category": "Web Frameworks"},
|
||||
{"name": "Express, Fastify, Koa", "slug": "express", "path": "/integrations/frameworks/express", "description": "Learn how to integrate Infisical with Express.js backend applications.", "category": "Web Frameworks"},
|
||||
{"name": "NestJS", "slug": "nestjs", "path": "/integrations/frameworks/nestjs", "description": "Learn how to integrate Infisical with NestJS applications.", "category": "Web Frameworks"},
|
||||
{"name": "Django", "slug": "django", "path": "/integrations/frameworks/django", "description": "Learn how to integrate Infisical with Django applications.", "category": "Web Frameworks"},
|
||||
{"name": "Flask", "slug": "flask", "path": "/integrations/frameworks/flask", "description": "Learn how to integrate Infisical with Flask applications.", "category": "Web Frameworks"},
|
||||
{"name": "Ruby on Rails", "slug": "rails", "path": "/integrations/frameworks/rails", "description": "Learn how to integrate Infisical with Ruby on Rails applications.", "category": "Web Frameworks"},
|
||||
{"name": "Spring Boot", "slug": "spring-boot-maven", "path": "/integrations/frameworks/spring-boot-maven", "description": "Learn how to integrate Infisical with Spring Boot applications.", "category": "Web Frameworks"},
|
||||
{"name": "Laravel", "slug": "laravel", "path": "/integrations/frameworks/laravel", "description": "Learn how to integrate Infisical with Laravel applications.", "category": "Web Frameworks"},
|
||||
{"name": ".NET", "slug": "dotnet", "path": "/integrations/frameworks/dotnet", "description": "Learn how to integrate Infisical with .NET applications.", "category": "Web Frameworks"},
|
||||
{"name": "Fiber", "slug": "fiber", "path": "/integrations/frameworks/fiber", "description": "Learn how to integrate Infisical with Fiber (Go) framework.", "category": "Web Frameworks"},
|
||||
{"name": "Gatsby", "slug": "gatsby", "path": "/integrations/frameworks/gatsby", "description": "Learn how to integrate Infisical with Gatsby applications.", "category": "Web Frameworks"},
|
||||
{"name": "Remix", "slug": "remix", "path": "/integrations/frameworks/remix", "description": "Learn how to integrate Infisical with Remix applications.", "category": "Web Frameworks"},
|
||||
{"name": "Vite", "slug": "vite", "path": "/integrations/frameworks/vite", "description": "Learn how to integrate Infisical with Vite applications.", "category": "Web Frameworks"},
|
||||
{"name": "AB Initio", "slug": "ab-initio", "path": "/integrations/frameworks/ab-initio", "description": "Learn how to integrate Infisical with AB Initio applications.", "category": "Web Frameworks"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredIntegrations = useMemo(() => {
|
||||
if (searchTerm) {
|
||||
return integrations.filter(integration =>
|
||||
integration.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
integration.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
return integrations;
|
||||
}, [searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search framework integrations..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredIntegrations.length} framework integration{filteredIntegrations.length !== 1 ? 's' : ''} found
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Integrations List */}
|
||||
{filteredIntegrations.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredIntegrations.map((integration, index) => (
|
||||
<a
|
||||
key={integration.slug}
|
||||
href={integration.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{integration.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No framework integrations found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
136
docs/snippets/MachineAuthenticationBrowser.jsx
Normal file
136
docs/snippets/MachineAuthenticationBrowser.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const MachineAuthenticationBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Token-based', 'Cloud Provider', 'Kubernetes', 'Certificate-based', 'Directory-based'];
|
||||
|
||||
const authMethods = [
|
||||
{"name": "Universal Auth", "slug": "universal-auth", "path": "/documentation/platform/identities/universal-auth", "description": "Learn how to authenticate machines using Universal Auth tokens with client ID and secret.", "category": "Token-based"},
|
||||
{"name": "Token Auth", "slug": "token-auth", "path": "/documentation/platform/identities/token-auth", "description": "Learn how to authenticate machines using long-lived access tokens.", "category": "Token-based"},
|
||||
{"name": "JWT Auth", "slug": "jwt-auth", "path": "/documentation/platform/identities/jwt-auth", "description": "Learn how to authenticate machines using JSON Web Tokens (JWT).", "category": "Token-based"},
|
||||
{"name": "AWS Auth", "slug": "aws-iam-auth", "path": "/documentation/platform/identities/aws-auth", "description": "Learn how to authenticate AWS services and resources using IAM roles.", "category": "Cloud Provider"},
|
||||
{"name": "Azure Auth", "slug": "azure-auth", "path": "/documentation/platform/identities/azure-auth", "description": "Learn how to authenticate Azure services using managed identities.", "category": "Cloud Provider"},
|
||||
{"name": "GCP Auth", "slug": "gcp-auth", "path": "/documentation/platform/identities/gcp-auth", "description": "Learn how to authenticate GCP services using service accounts.", "category": "Cloud Provider"},
|
||||
{"name": "Alibaba Cloud Auth", "slug": "alicloud-auth", "path": "/documentation/platform/identities/alicloud-auth", "description": "Learn how to authenticate Alibaba Cloud services using RAM roles.", "category": "Cloud Provider"},
|
||||
{"name": "OCI Auth", "slug": "oci-auth", "path": "/documentation/platform/identities/oci-auth", "description": "Learn how to authenticate Oracle Cloud Infrastructure services.", "category": "Cloud Provider"},
|
||||
{"name": "Kubernetes Auth", "slug": "kubernetes-auth", "path": "/documentation/platform/identities/kubernetes-auth", "description": "Learn how to authenticate Kubernetes workloads using service account tokens.", "category": "Kubernetes"},
|
||||
{"name": "OIDC Auth", "slug": "oidc-auth-general", "path": "/documentation/platform/identities/oidc-auth/general", "description": "Learn how to authenticate machines using OpenID Connect (OIDC) providers.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for GitHub Actions", "slug": "oidc-auth-github", "path": "/documentation/platform/identities/oidc-auth/github", "description": "Learn how to authenticate GitHub Actions using OIDC.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for GitLab CI/CD", "slug": "oidc-auth-gitlab", "path": "/documentation/platform/identities/oidc-auth/gitlab", "description": "Learn how to authenticate GitLab CI/CD using OIDC.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for Azure", "slug": "oidc-auth-azure", "path": "/documentation/platform/identities/oidc-auth/azure", "description": "Learn how to authenticate Azure services using OIDC.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for CircleCI", "slug": "oidc-auth-circleci", "path": "/documentation/platform/identities/oidc-auth/circleci", "description": "Learn how to authenticate CircleCI workflows using OIDC.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for Terraform Cloud", "slug": "oidc-auth-terraform", "path": "/documentation/platform/identities/oidc-auth/terraform-cloud", "description": "Learn how to authenticate Terraform Cloud using OIDC.", "category": "Token-based"},
|
||||
{"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"},
|
||||
{"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"},
|
||||
{"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"},
|
||||
{"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredAuthMethods = useMemo(() => {
|
||||
let filtered = authMethods;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(method => method.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(method =>
|
||||
method.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
method.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
method.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search machine authentication methods..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredAuthMethods.length} authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication Methods List */}
|
||||
{filteredAuthMethods.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredAuthMethods.map((method, index) => (
|
||||
<a
|
||||
key={method.slug}
|
||||
href={method.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{method.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{method.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{method.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No authentication methods found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
126
docs/snippets/RotationsBrowser.jsx
Normal file
126
docs/snippets/RotationsBrowser.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const RotationsBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Databases', 'Identity & Auth', 'Cloud Providers'];
|
||||
|
||||
const rotations = [
|
||||
{"name": "AWS IAM User", "slug": "aws-iam-user", "path": "/documentation/platform/secret-rotation/aws-iam-user-secret", "description": "Learn how to automatically rotate AWS IAM user access keys.", "category": "Cloud Providers"},
|
||||
{"name": "Azure Client Secret", "slug": "azure-client-secret", "path": "/documentation/platform/secret-rotation/azure-client-secret", "description": "Learn how to automatically rotate Azure client secrets.", "category": "Cloud Providers"},
|
||||
{"name": "Auth0 Client Secret", "slug": "auth0-client-secret", "path": "/documentation/platform/secret-rotation/auth0-client-secret", "description": "Learn how to automatically rotate Auth0 client secrets.", "category": "Identity & Auth"},
|
||||
{"name": "Okta Client Secret", "slug": "okta-client-secret", "path": "/documentation/platform/secret-rotation/okta-client-secret", "description": "Learn how to automatically rotate Okta client secrets.", "category": "Identity & Auth"},
|
||||
{"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"},
|
||||
{"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"},
|
||||
{"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"},
|
||||
{"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"},
|
||||
{"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredRotations = useMemo(() => {
|
||||
let filtered = rotations;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(rotation => rotation.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(rotation =>
|
||||
rotation.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
rotation.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
rotation.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search secret rotations..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredRotations.length} secret rotation{filteredRotations.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rotations List */}
|
||||
{filteredRotations.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredRotations.map((rotation, index) => (
|
||||
<a
|
||||
key={rotation.slug}
|
||||
href={rotation.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{rotation.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{rotation.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{rotation.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No secret rotations found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
147
docs/snippets/SecretSyncsBrowser.jsx
Normal file
147
docs/snippets/SecretSyncsBrowser.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const SecretSyncsBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security'];
|
||||
|
||||
const syncs = [
|
||||
{"name": "AWS Parameter Store", "slug": "aws-parameter-store", "path": "/integrations/secret-syncs/aws-parameter-store", "description": "Learn how to sync secrets from Infisical to AWS Parameter Store.", "category": "Cloud Providers"},
|
||||
{"name": "AWS Secrets Manager", "slug": "aws-secrets-manager", "path": "/integrations/secret-syncs/aws-secrets-manager", "description": "Learn how to sync secrets from Infisical to AWS Secrets Manager.", "category": "Cloud Providers"},
|
||||
{"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/secret-syncs/azure-key-vault", "description": "Learn how to sync secrets from Infisical to Azure Key Vault.", "category": "Cloud Providers"},
|
||||
{"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/secret-syncs/azure-app-configuration", "description": "Learn how to sync secrets from Infisical to Azure App Configuration.", "category": "Cloud Providers"},
|
||||
{"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/secret-syncs/azure-devops", "description": "Learn how to sync secrets from Infisical to Azure DevOps.", "category": "CI/CD"},
|
||||
{"name": "GCP Secret Manager", "slug": "gcp-secret-manager", "path": "/integrations/secret-syncs/gcp-secret-manager", "description": "Learn how to sync secrets from Infisical to GCP Secret Manager.", "category": "Cloud Providers"},
|
||||
{"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/secret-syncs/hashicorp-vault", "description": "Learn how to sync secrets from Infisical to HashiCorp Vault.", "category": "Security"},
|
||||
{"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Security"},
|
||||
{"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Hosting"},
|
||||
{"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Hosting"},
|
||||
{"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Hosting"},
|
||||
{"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Hosting"},
|
||||
{"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Hosting"},
|
||||
{"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Hosting"},
|
||||
{"name": "DigitalOcean App Platform", "slug": "digital-ocean-app-platform", "path": "/integrations/secret-syncs/digital-ocean-app-platform", "description": "Learn how to sync secrets from Infisical to DigitalOcean App Platform.", "category": "Hosting"},
|
||||
{"name": "Supabase", "slug": "supabase", "path": "/integrations/secret-syncs/supabase", "description": "Learn how to sync secrets from Infisical to Supabase.", "category": "Databases"},
|
||||
{"name": "Checkly", "slug": "checkly", "path": "/integrations/secret-syncs/checkly", "description": "Learn how to sync secrets from Infisical to Checkly.", "category": "Monitoring"},
|
||||
{"name": "GitHub", "slug": "github", "path": "/integrations/secret-syncs/github", "description": "Learn how to sync secrets from Infisical to GitHub.", "category": "CI/CD"},
|
||||
{"name": "GitLab", "slug": "gitlab", "path": "/integrations/secret-syncs/gitlab", "description": "Learn how to sync secrets from Infisical to GitLab.", "category": "CI/CD"},
|
||||
{"name": "TeamCity", "slug": "teamcity", "path": "/integrations/secret-syncs/teamcity", "description": "Learn how to sync secrets from Infisical to TeamCity.", "category": "CI/CD"},
|
||||
{"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/secret-syncs/bitbucket", "description": "Learn how to sync secrets from Infisical to Bitbucket.", "category": "CI/CD"},
|
||||
{"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/secret-syncs/terraform-cloud", "description": "Learn how to sync secrets from Infisical to Terraform Cloud.", "category": "DevOps Tools"},
|
||||
{"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Hosting"},
|
||||
{"name": "Cloudflare Workers", "slug": "cloudflare-workers", "path": "/integrations/secret-syncs/cloudflare-workers", "description": "Learn how to sync secrets from Infisical to Cloudflare Workers.", "category": "Cloud Providers"},
|
||||
{"name": "Databricks", "slug": "databricks", "path": "/integrations/secret-syncs/databricks", "description": "Learn how to sync secrets from Infisical to Databricks.", "category": "Data Analytics"},
|
||||
{"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "DevOps Tools"},
|
||||
{"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"},
|
||||
{"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"},
|
||||
{"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"},
|
||||
{"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredSyncs = useMemo(() => {
|
||||
let filtered = syncs;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(sync => sync.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(sync =>
|
||||
sync.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
sync.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
sync.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search secret syncs..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredSyncs.length} secret sync{filteredSyncs.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secret Syncs List */}
|
||||
{filteredSyncs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredSyncs.map((sync, index) => (
|
||||
<a
|
||||
key={sync.slug}
|
||||
href={sync.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{sync.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{sync.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{sync.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No secret syncs found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
135
docs/snippets/UserAuthenticationBrowser.jsx
Normal file
135
docs/snippets/UserAuthenticationBrowser.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
export const UserAuthenticationBrowser = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||
|
||||
const categories = ['All', 'SSO', 'LDAP', 'SCIM', 'General'];
|
||||
|
||||
const authMethods = [
|
||||
{"name": "Auth0 OIDC", "slug": "auth0-oidc-sso", "path": "/documentation/platform/sso/auth0-oidc", "description": "Learn how to configure Auth0 OIDC SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Auth0 SAML", "slug": "auth0-saml-sso", "path": "/documentation/platform/sso/auth0-saml", "description": "Learn how to configure Auth0 SAML SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Entra ID / Azure AD SAML", "slug": "azure-ad-sso", "path": "/documentation/platform/sso/azure", "description": "Learn how to configure Azure Active Directory (Entra ID) SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Google", "slug": "google-sso", "path": "/documentation/platform/sso/google", "description": "Learn how to configure Google SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Google SAML", "slug": "google-saml-sso", "path": "/documentation/platform/sso/google-saml", "description": "Learn how to configure Google SAML SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "GitHub", "slug": "github-sso", "path": "/documentation/platform/sso/github", "description": "Learn how to configure GitHub SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "GitLab", "slug": "gitlab-sso", "path": "/documentation/platform/sso/gitlab", "description": "Learn how to configure GitLab SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "JumpCloud", "slug": "jumpcloud-sso", "path": "/documentation/platform/sso/jumpcloud", "description": "Learn how to configure JumpCloud SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Keycloak OIDC", "slug": "keycloak-oidc-sso", "path": "/documentation/platform/sso/keycloak-oidc/overview", "description": "Learn how to configure Keycloak OIDC SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Keycloak SAML", "slug": "keycloak-saml-sso", "path": "/documentation/platform/sso/keycloak-saml", "description": "Learn how to configure Keycloak SAML SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Okta", "slug": "okta-oidc-sso", "path": "/documentation/platform/sso/okta", "description": "Learn how to configure Okta OIDC SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "Okta SAML", "slug": "okta-saml-sso", "path": "/documentation/platform/sso/okta-saml", "description": "Learn how to configure Okta SAML SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "OneLogin SAML", "slug": "onelogin-saml-sso", "path": "/documentation/platform/sso/onelogin-saml", "description": "Learn how to configure OneLogin SAML SSO for user authentication in Infisical.", "category": "SSO"},
|
||||
{"name": "General OIDC", "slug": "general-oidc-sso", "path": "/documentation/platform/sso/general-oidc", "description": "Learn how to configure generic OIDC providers for SSO in Infisical.", "category": "SSO"},
|
||||
{"name": "General SAML 2.0", "slug": "general-saml-sso", "path": "/documentation/platform/sso/general-saml", "description": "Learn how to configure generic SAML 2.0 providers for SSO in Infisical.", "category": "SSO"},
|
||||
{"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/ldap/overview", "description": "Learn how to configure LDAP authentication for user login in Infisical.", "category": "LDAP"},
|
||||
{"name": "SCIM", "slug": "scim", "path": "/documentation/platform/scim/overview", "description": "Learn how to configure SCIM provisioning for automated user management in Infisical.", "category": "SCIM"},
|
||||
{"name": "Email/Password", "slug": "email-password", "path": "/documentation/getting-started/introduction", "description": "Learn how to use standard email and password authentication in Infisical.", "category": "General"}
|
||||
].sort(function(a, b) {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredAuthMethods = useMemo(() => {
|
||||
let filtered = authMethods;
|
||||
|
||||
if (selectedCategory !== 'All') {
|
||||
filtered = filtered.filter(method => method.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(method =>
|
||||
method.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
method.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
method.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [searchTerm, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="max-w-none">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search user authentication methods..."
|
||||
className="block w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 bg-white shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors shadow-sm ${
|
||||
selectedCategory === category
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-yellow-50 hover:border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{filteredAuthMethods.length} user authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found
|
||||
{selectedCategory !== 'All' && ` in ${selectedCategory}`}
|
||||
{searchTerm && ` for "${searchTerm}"`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication Methods List */}
|
||||
{filteredAuthMethods.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredAuthMethods.map((method, index) => (
|
||||
<a
|
||||
key={method.slug}
|
||||
href={method.path}
|
||||
className="group block px-4 py-3 border border-gray-200 rounded-xl hover:border-yellow-200 hover:bg-yellow-50/50 hover:shadow-sm transition-all duration-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="text-base font-medium text-gray-900 leading-none m-0">
|
||||
{method.name}
|
||||
</h3>
|
||||
<span className="ml-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 flex-shrink-0">
|
||||
{method.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{method.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className="text-gray-500">No user authentication methods found matching your criteria</p>
|
||||
{searchTerm && (
|
||||
<p className="text-gray-400 text-sm">Try adjusting your search terms or filters</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -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}
|
||||
/>
|
||||
|
@@ -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}
|
||||
/>
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -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;
|
||||
};
|
||||
|
@@ -1 +1,2 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
|
20
frontend/src/hooks/api/migration/queries.tsx
Normal file
20
frontend/src/hooks/api/migration/queries.tsx
Normal 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}`
|
||||
)
|
||||
});
|
||||
};
|
4
frontend/src/hooks/api/migration/types.ts
Normal file
4
frontend/src/hooks/api/migration/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ExternalMigrationProviders {
|
||||
Vault = "vault",
|
||||
EnvKey = "env-key"
|
||||
}
|
@@ -159,3 +159,8 @@ export enum OrgIdentityOrderBy {
|
||||
Name = "name",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
export enum OrgMembershipStatus {
|
||||
Invited = "invited",
|
||||
Accepted = "accepted"
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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">
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -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>
|
||||
|
@@ -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"
|
||||
/>
|
||||
|
@@ -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>
|
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useAdminGrantServerAdminAccess } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const getUserLabel = (user: Pick<User, "email" | "firstName" | "lastName" | "username" | "id">) => {
|
||||
const { firstName, lastName, username, email } = user;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddServerAdminSchema = z.object({
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddServerAdminSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const grantAdmin = useAdminGrantServerAdminAccess();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(AddServerAdminSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ user }: FormData) => {
|
||||
try {
|
||||
await grantAdmin.mutateAsync(user.id);
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully granted server admin status"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to grant server admin status",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="User">
|
||||
<FilterableSelect
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users..."
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="user"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddServerAdminModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Grant Server Admin"
|
||||
subTitle="Grant server admin status to a user"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@@ -0,0 +1,476 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faShieldHalved,
|
||||
faTrash,
|
||||
faUsers,
|
||||
faUserXmark,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { InfiniteData } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useUser } from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminBulkDeleteUsers,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetUsers,
|
||||
useRemoveUserServerAdminAccess
|
||||
} from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { AddServerAdminModal } from "@app/pages/admin/AccessManagementPage/components/AddServerAdminModal";
|
||||
|
||||
const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user";
|
||||
|
||||
const ServerAdminsPanelTable = ({
|
||||
handlePopUpOpen,
|
||||
users: usersPages,
|
||||
isPending,
|
||||
searchUserFilter,
|
||||
setSearchUserFilter,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
selectedUsers,
|
||||
setSelectedUsers
|
||||
}: {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["removeUser", "upgradePlan", "addServerAdmin", "removeServerAdmin"]
|
||||
>,
|
||||
data?: {
|
||||
username: string;
|
||||
id: string;
|
||||
message?: string;
|
||||
}
|
||||
) => void;
|
||||
isPending: boolean;
|
||||
users: InfiniteData<User[], unknown> | undefined;
|
||||
searchUserFilter: string;
|
||||
setSearchUserFilter: (filter: string) => void;
|
||||
selectedUsers: User[];
|
||||
setSelectedUsers: Dispatch<SetStateAction<User[]>>;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
}) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const users = usersPages?.pages.flat();
|
||||
|
||||
const isEmpty = !isPending && !users?.length;
|
||||
|
||||
const selectedUserIds = selectedUsers.map((user) => user.id);
|
||||
|
||||
const isPageSelected = users?.length
|
||||
? users.every((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const isPageIndeterminate = isPageSelected
|
||||
? false
|
||||
: users?.length
|
||||
? users?.some((user) => selectedUserIds.includes(user.id))
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search admins..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addServerAdmin")}
|
||||
>
|
||||
Add Admin
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5">
|
||||
<Checkbox
|
||||
id="member-page-select"
|
||||
isChecked={isPageSelected || isPageIndeterminate}
|
||||
isIndeterminate={isPageIndeterminate}
|
||||
onCheckedChange={() => {
|
||||
if (isPageSelected) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.filter((u) => !users?.find((user) => user.id === u.id))
|
||||
);
|
||||
} else {
|
||||
setSelectedUsers((prev) => [
|
||||
...prev,
|
||||
...(users?.filter((u) => !prev.find((user) => user.id === u.id)) ?? [])
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Th>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-1/2">Username</Th>
|
||||
<Th className="w-2/12" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="users" />}
|
||||
{!isPending &&
|
||||
users?.map((user) => {
|
||||
const { username, email, firstName, lastName, id } = user;
|
||||
const name = firstName || lastName ? `${firstName} ${lastName}` : null;
|
||||
|
||||
const isSelected = selectedUserIds.includes(id);
|
||||
return (
|
||||
<Tr key={`user-${id}`} className="w-full">
|
||||
<Td>
|
||||
<Checkbox
|
||||
id={`select-user-${id}`}
|
||||
isChecked={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUsers((prev) =>
|
||||
isSelected ? prev.filter((u) => u.id !== id) : [...prev, user]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
</p>
|
||||
</Td>
|
||||
<Td className="w-5/12 max-w-0">
|
||||
<p className="truncate">{username || email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeUser", { username, id });
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
>
|
||||
Remove User
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
icon={
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon icon={faShieldHalved} />
|
||||
<FontAwesomeIcon
|
||||
className="absolute -bottom-[0.01rem] -right-1"
|
||||
size="2xs"
|
||||
icon={faXmark}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!subscription?.instanceUserManagement) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
username,
|
||||
id,
|
||||
message: removeServerAdminUpgradePlanMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("removeServerAdmin", { username, id });
|
||||
}}
|
||||
>
|
||||
Remove Server Admin
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of List"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServerAdminsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"removeUser",
|
||||
"upgradePlan",
|
||||
"addServerAdmin",
|
||||
"removeServerAdmin",
|
||||
"removeUsers"
|
||||
] as const);
|
||||
|
||||
const {
|
||||
user: { id: userId }
|
||||
} = useUser();
|
||||
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
const { mutateAsync: deleteUsers } = useAdminBulkDeleteUsers();
|
||||
const { mutateAsync: removeAdminAccess } = useRemoveUserServerAdminAccess();
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage
|
||||
} = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: true
|
||||
});
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
const { id } = popUp?.removeUser?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await deleteUser(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error deleting user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeUser");
|
||||
};
|
||||
|
||||
const handleRemoveServerAdminAccess = async () => {
|
||||
const { id } = popUp?.removeServerAdmin?.data as { id: string; username: string };
|
||||
|
||||
try {
|
||||
await removeAdminAccess(id);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed server admin access from user"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Error removing server admin access from user"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("removeServerAdmin");
|
||||
};
|
||||
|
||||
const handleRemoveUsers = async () => {
|
||||
try {
|
||||
await deleteUsers(selectedUsers.map((user) => user.id));
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed users",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
setSelectedUsers([]);
|
||||
handlePopUpClose("removeUsers");
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to remove users",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={twMerge(
|
||||
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||
selectedUsers.length > 0 && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||
<div className="mr-2 text-sm">{selectedUsers.length} Selected</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
|
||||
onClick={() => setSelectedUsers([])}
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
if (!selectedUsers?.length) return;
|
||||
|
||||
handlePopUpOpen("removeUsers");
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<ServerAdminsPanelTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
setSelectedUsers={setSelectedUsers}
|
||||
searchUserFilter={searchUserFilter}
|
||||
setSearchUserFilter={setSearchUserFilter}
|
||||
isPending={isPending}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUser.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Are you sure you want to delete User with username ${
|
||||
(popUp?.removeUser?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUser", isOpen)}
|
||||
onDeleteApproved={handleRemoveUser}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeServerAdmin.isOpen}
|
||||
title={`Are you sure you want to remove Server Admin permissions from ${
|
||||
(popUp?.removeServerAdmin?.data as { id: string; username: string })?.username || ""
|
||||
}?`}
|
||||
subTitle=""
|
||||
onChange={(isOpen) => handlePopUpToggle("removeServerAdmin", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleRemoveServerAdminAccess}
|
||||
buttonText="Remove Access"
|
||||
/>
|
||||
<AddServerAdminModal
|
||||
isOpen={popUp.addServerAdmin.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addServerAdmin", isOpen)}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeUsers.isOpen}
|
||||
title="Are you sure you want to delete the following users?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeUsers", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => handleRemoveUsers()}
|
||||
buttonText="Remove"
|
||||
>
|
||||
<div className="mt-4 text-sm text-mineshaft-400">
|
||||
The following users will be deleted:
|
||||
</div>
|
||||
<div className="mt-2 max-h-[20rem] overflow-y-auto rounded border border-mineshaft-600 bg-red/10 p-4 pl-8 text-sm text-red-200">
|
||||
<ul className="list-disc">
|
||||
{selectedUsers?.map((user) => {
|
||||
const email = user.email ?? user.username;
|
||||
return (
|
||||
<li key={user.id}>
|
||||
<div className="flex items-center">
|
||||
<p>
|
||||
{user.firstName || user.lastName ? (
|
||||
<>
|
||||
{`${`${user.firstName} ${user.lastName}`.trim()} `}(
|
||||
<span className="break-all">{email}</span>)
|
||||
</>
|
||||
) : (
|
||||
<span className="break-all">{email}</span>
|
||||
)}{" "}
|
||||
</p>
|
||||
{userId === user.id && (
|
||||
<Tooltip content="Are you sure you want to remove yourself from this instance?">
|
||||
<div className="inline-block">
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="ml-1 mt-[0.05rem] inline-flex w-min items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} />
|
||||
<span>Deleting Yourself</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</DeleteActionModal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export * from "./ServerAdminsTable";
|
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { 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"
|
||||
})
|
||||
}
|
||||
]
|
@@ -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>
|
||||
|
@@ -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">
|
||||
|
@@ -0,0 +1,53 @@
|
||||
import { faDownload, faFileAlt, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Card, CardTitle } from "@app/components/v2";
|
||||
import { downloadFile } from "@app/helpers/download";
|
||||
import { useGenerateUsageReport } from "@app/hooks/api/admin/mutation";
|
||||
|
||||
export const UsageReportSection = () => {
|
||||
const generateUsageReport = useGenerateUsageReport();
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
try {
|
||||
const response = await generateUsageReport.mutateAsync();
|
||||
const { csvContent, filename } = response;
|
||||
|
||||
downloadFile(csvContent, filename, "text/csv");
|
||||
|
||||
createNotification({
|
||||
text: `Usage report downloaded: "${filename}"`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to generate usage report:", error);
|
||||
createNotification({
|
||||
text: "Failed to generate usage report. Please try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<CardTitle className="mb-4 flex items-center gap-3">
|
||||
<FontAwesomeIcon icon={faFileAlt} />
|
||||
Offline Usage Reports
|
||||
</CardTitle>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-400">
|
||||
Generate secure usage reports for offline license compliance and billing verification.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateReport}
|
||||
className="w-fit"
|
||||
isLoading={generateUsageReport.isPending}
|
||||
leftIcon={<FontAwesomeIcon icon={generateUsageReport.isPending ? faSpinner : faDownload} />}
|
||||
>
|
||||
{generateUsageReport.isPending ? "Generating..." : "Generate Report"}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
@@ -1 +1,2 @@
|
||||
export { GeneralPageForm } from "./GeneralPageForm";
|
||||
export { UsageReportSection } from "./UsageReportSection";
|
||||
|
@@ -1,27 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable } from "./components";
|
||||
|
||||
export const MachineIdentitiesResourcesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Machine Identities"
|
||||
description="Manage all machine identities within your Infisical instance."
|
||||
/>
|
||||
<MachineIdentitiesTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1 +0,0 @@
|
||||
export { MachineIdentitiesTable } from "./MachineIdentitiesTable";
|
@@ -1,25 +0,0 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { MachineIdentitiesResourcesPage } from "./MachineIdentitiesResourcesPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities"
|
||||
)({
|
||||
component: MachineIdentitiesResourcesPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Admin",
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Machine Identities",
|
||||
link: linkOptions({
|
||||
to: "/admin/resources/machine-identities"
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
@@ -1,396 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faBuilding,
|
||||
faCircleQuestion,
|
||||
faEllipsis,
|
||||
faMagnifyingGlass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useAdminDeleteOrganization,
|
||||
useAdminDeleteOrganizationMembership,
|
||||
useAdminDeleteUser,
|
||||
useAdminGetOrganizations
|
||||
} from "@app/hooks/api";
|
||||
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const ViewMembersModalContent = ({
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
popUp: UsePopUpState<["viewMembers"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||
data?: {
|
||||
username?: string;
|
||||
membershipId?: string;
|
||||
userId?: string;
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
}) => {
|
||||
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{organization?.members?.map((member) => (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-mineshaft-100">
|
||||
<div>
|
||||
{member.user.firstName ? (
|
||||
<div>
|
||||
{member.user.firstName} {member.user.lastName}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-mineshaft-400">Not set</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-80">
|
||||
<div>{member.user.username || member.user.email}</div>
|
||||
<Badge variant="primary">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
||||
{Boolean(member.roleId) && (
|
||||
<Tooltip content="This member has a custom role assigned.">
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<FontAwesomeIcon
|
||||
icon={faEllipsis}
|
||||
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteOrganizationMembership", {
|
||||
membershipId: member.membershipId,
|
||||
orgId: organization.id,
|
||||
username: member.user.username,
|
||||
orgName: organization.name
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove From Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewMembersModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
popUp,
|
||||
handlePopUpOpen
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||
) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
title="Organization Members"
|
||||
subTitle="View the members of the organization."
|
||||
>
|
||||
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationsPanelTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: {
|
||||
popUp: UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||
>,
|
||||
data?: {
|
||||
orgName?: string;
|
||||
orgId?: string;
|
||||
message?: string;
|
||||
organization?: OrganizationWithProjects;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||
isOpen?: boolean
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useAdminGetOrganizations({
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchOrganizationsFilter}
|
||||
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search organizations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5/12">Name</Th>
|
||||
<Th className="w-5/12">Members</Th>
|
||||
<Th className="w-5/12">Projects</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((orgs) =>
|
||||
orgs.map((org) => {
|
||||
return (
|
||||
<Tr key={`org-${org.id}`} className="w-full">
|
||||
<Td className="w-5/12">
|
||||
{org.name ? (
|
||||
org.name
|
||||
) : (
|
||||
<span className="text-mineshaft-400">Not set</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.members.length} {org.members.length === 1 ? "member" : "members"}
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="ml-2"
|
||||
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||
>
|
||||
View Members
|
||||
</Button>
|
||||
</Td>
|
||||
<Td className="w-5/12">
|
||||
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteOrganization", {
|
||||
orgId: org.id,
|
||||
orgName: org.name
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete Organization
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ViewMembersModal
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
isOpen={popUp.viewMembers.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("viewMembers", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganizationsTable = () => {
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteOrganization",
|
||||
"deleteOrganizationMembership",
|
||||
"deleteUser",
|
||||
"viewMembers"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization();
|
||||
const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership();
|
||||
const { mutateAsync: deleteUser } = useAdminDeleteUser();
|
||||
|
||||
const handleDeleteOrganization = async () => {
|
||||
const { orgId } = popUp?.deleteOrganization?.data as { orgId: string };
|
||||
|
||||
await deleteOrganization(orgId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrganization");
|
||||
};
|
||||
|
||||
const handleDeleteOrganizationMembership = async () => {
|
||||
const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as {
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
};
|
||||
|
||||
if (!orgId || !membershipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteOrganizationMembership({ organizationId: orgId, membershipId });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed user from organization"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteOrganizationMembership");
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const { userId } = popUp?.deleteUser?.data as { userId: string };
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUser(userId);
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted user"
|
||||
});
|
||||
|
||||
handlePopUpClose("viewMembers");
|
||||
handlePopUpClose("deleteUser");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<OrganizationsPanelTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganization.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete organization ${
|
||||
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganization}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to remove ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||
} from organization ${
|
||||
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteUser.isOpen}
|
||||
deleteKey="delete"
|
||||
title={`Are you sure you want to delete user ${
|
||||
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||
onDeleteApproved={handleDeleteUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1 +0,0 @@
|
||||
export { OrganizationsTable } from "./OrganizationsTable";
|
@@ -0,0 +1,42 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
|
||||
import { MachineIdentitiesTable, OrganizationsTable, UserIdentitiesTable } from "./components";
|
||||
|
||||
export const ResourceOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Resource Overview" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Resource Overview"
|
||||
description="Manage resources within your Infisical instance."
|
||||
/>
|
||||
<Tabs defaultValue="tab-organizations">
|
||||
<TabList>
|
||||
<Tab value="tab-organizations">Organizations</Tab>
|
||||
<Tab value="tab-users">Users</Tab>
|
||||
<Tab value="tab-identities">Identities</Tab>
|
||||
</TabList>
|
||||
<TabPanel value="tab-organizations">
|
||||
<OrganizationsTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-users">
|
||||
<UserIdentitiesTable />
|
||||
</TabPanel>
|
||||
<TabPanel value="tab-identities">
|
||||
<MachineIdentitiesTable />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetUsers, useServerAdminCreateOrganization } from "@app/hooks/api";
|
||||
import { User } from "@app/hooks/api/users/types";
|
||||
import { GenericResourceNameSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Invitee = Pick<User, "email" | "firstName" | "lastName" | "username" | "id">;
|
||||
type NewOption = { label: string; value: string };
|
||||
|
||||
const getUserLabel = (user: Invitee | NewOption) => {
|
||||
if (Object.prototype.hasOwnProperty.call(user, "value")) {
|
||||
return (user as NewOption).label;
|
||||
}
|
||||
|
||||
const { firstName, lastName, username, email } = user as Invitee;
|
||||
|
||||
const name = `${firstName ?? ""} ${lastName ?? ""}`.trim();
|
||||
const userEmail = email || username;
|
||||
|
||||
if (!name) return userEmail;
|
||||
|
||||
return `${name}${userEmail ? ` (${userEmail})` : ""}`;
|
||||
};
|
||||
|
||||
const AddOrgSchema = z.object({
|
||||
name: GenericResourceNameSchema.nonempty("Organization name required"),
|
||||
invitees: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullish(),
|
||||
lastName: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
username: z.string().nullish()
|
||||
})
|
||||
.array()
|
||||
.min(1, "At least one admin is required")
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof AddOrgSchema>;
|
||||
|
||||
const Content = ({ onClose }: ContentProps) => {
|
||||
const createOrg = useServerAdminCreateOrganization();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting }
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
invitees: []
|
||||
},
|
||||
resolver: zodResolver(AddOrgSchema)
|
||||
});
|
||||
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
|
||||
const { data, isFetching } = useAdminGetUsers(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly: false
|
||||
},
|
||||
{
|
||||
placeholderData: (prev) => prev
|
||||
}
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const onSubmit = async ({ name, invitees }: FormData) => {
|
||||
try {
|
||||
await createOrg.mutateAsync({
|
||||
name,
|
||||
inviteAdminEmails: invitees
|
||||
.filter((user) => Boolean(user.email))
|
||||
.map((user) => user.email) as string[]
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created organization"
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to create organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { append } = useFieldArray<FormData>({ control, name: "invitees" });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message} label="Name">
|
||||
<Input autoFocus value={value} onChange={onChange} placeholder="My Organization" />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
<Controller
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Assign Organization Admins"
|
||||
>
|
||||
<CreatableSelect
|
||||
/* eslint-disable-next-line react/no-unstable-nested-components */
|
||||
noOptionsMessage={() => (
|
||||
<p>Invite new users to this organization by typing out their email address.</p>
|
||||
)}
|
||||
onCreateOption={(inputValue) =>
|
||||
append({ id: `${inputValue}_${Math.random()}`, email: inputValue })
|
||||
}
|
||||
formatCreateLabel={(inputValue) => `Invite "${inputValue}"`}
|
||||
isValidNewOption={(input) =>
|
||||
Boolean(input) &&
|
||||
z.string().email().safeParse(input).success &&
|
||||
!users
|
||||
?.flatMap((user) => {
|
||||
const emails: string[] = [];
|
||||
|
||||
if (user.email) {
|
||||
emails.push(user.email);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
emails.push(user.username);
|
||||
}
|
||||
|
||||
return emails;
|
||||
})
|
||||
.includes(input)
|
||||
}
|
||||
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching}
|
||||
className="w-full"
|
||||
placeholder="Search users or invite new ones..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={users}
|
||||
getOptionLabel={(user) => getUserLabel(user)}
|
||||
getOptionValue={(user) => user.id}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onInputChange={(value) => {
|
||||
setSearchUserFilter(value);
|
||||
if (!value) setDebouncedSearchTerm("");
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="invitees"
|
||||
/>
|
||||
<div className="flex w-full gap-4 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Add Organization
|
||||
</Button>
|
||||
<Button onClick={() => onClose()} variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddOrganizationModal = ({ isOpen, onOpenChange }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Organization">
|
||||
<Content onClose={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import {
|
||||
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}
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
@@ -0,0 +1,3 @@
|
||||
export * from "./MachineIdentitiesTable";
|
||||
export * from "./OrganizationsTable";
|
||||
export * from "./UserIdentitiesTable";
|
@@ -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"
|
||||
})
|
||||
}
|
||||
]
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -1 +0,0 @@
|
||||
export { UserIdentitiesTable } from "./UserIdentitiesTable";
|
@@ -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") ||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user