mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-20 01:48:03 +00:00
Compare commits
40 Commits
doc/add-me
...
misc/add-p
Author | SHA1 | Date | |
---|---|---|---|
38c9242e5b | |||
cce2a54265 | |||
d1033cb324 | |||
7134e1dc66 | |||
8aa26b77ed | |||
4b06880320 | |||
124cd9f812 | |||
d531d069d1 | |||
522a5d477d | |||
d2f0db669a | |||
4dd78d745b | |||
4fef5c305d | |||
30f3543850 | |||
114915f913 | |||
b5801af9a8 | |||
20366a8c07 | |||
447e28511c | |||
650ed656e3 | |||
54ac450b63 | |||
3871fa552c | |||
9c72ee7f10 | |||
22e8617661 | |||
2f29a513cc | |||
cb6c28ac26 | |||
d3833c33b3 | |||
978a3e5828 | |||
27bf91e58f | |||
f2c3c76c60 | |||
85023916e4 | |||
3723afe595 | |||
02afd6a8e7 | |||
929eac4350 | |||
58b61a861a | |||
d79a6b8f25 | |||
217a09c97b | |||
a389ede03d | |||
10939fecc0 | |||
48f40ff938 | |||
ed7d709a70 | |||
9af5a66bab |
21
backend/src/db/migrations/20250627010508_env-overrides.ts
Normal file
21
backend/src/db/migrations/20250627010508_env-overrides.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
|
||||
t.binary("encryptedEnvOverrides").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SuperAdmin, "encryptedEnvOverrides");
|
||||
if (hasColumn) {
|
||||
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
|
||||
t.dropColumn("encryptedEnvOverrides");
|
||||
});
|
||||
}
|
||||
}
|
@ -34,7 +34,8 @@ export const SuperAdminSchema = z.object({
|
||||
encryptedGitHubAppConnectionClientSecret: zodBuffer.nullable().optional(),
|
||||
encryptedGitHubAppConnectionSlug: zodBuffer.nullable().optional(),
|
||||
encryptedGitHubAppConnectionId: zodBuffer.nullable().optional(),
|
||||
encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional()
|
||||
encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional(),
|
||||
encryptedEnvOverrides: zodBuffer.nullable().optional()
|
||||
});
|
||||
|
||||
export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;
|
||||
|
@ -350,6 +350,12 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId);
|
||||
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
|
||||
|
||||
// Calculate break glass attempt before sequence checks
|
||||
const isBreakGlassApprovalAttempt =
|
||||
policy.enforcementLevel === EnforcementLevel.Soft &&
|
||||
actorId === accessApprovalRequest.requestedByUserId &&
|
||||
status === ApprovalStatus.APPROVED;
|
||||
|
||||
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
|
||||
// If user is (not an approver OR cant self approve) AND can't bypass policy
|
||||
if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) {
|
||||
@ -409,15 +415,14 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
const isApproverOfTheSequence = policy.approvers.find(
|
||||
(el) => el.sequence === presentSequence.step && el.userId === actorId
|
||||
);
|
||||
if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" });
|
||||
|
||||
// Only throw if actor is not the approver and not bypassing
|
||||
if (!isApproverOfTheSequence && !isBreakGlassApprovalAttempt) {
|
||||
throw new BadRequestError({ message: "You are not a reviewer in this step" });
|
||||
}
|
||||
}
|
||||
|
||||
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
|
||||
const isBreakGlassApprovalAttempt =
|
||||
policy.enforcementLevel === EnforcementLevel.Soft &&
|
||||
actorId === accessApprovalRequest.requestedByUserId &&
|
||||
status === ApprovalStatus.APPROVED;
|
||||
|
||||
let reviewForThisActorProcessing: {
|
||||
id: string;
|
||||
requestId: string;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
@ -2427,7 +2427,8 @@ export const SecretSyncs = {
|
||||
keyOcid: "The OCID (Oracle Cloud Identifier) of the encryption key to use when creating secrets in the vault."
|
||||
},
|
||||
ONEPASS: {
|
||||
vaultId: "The ID of the 1Password vault to sync secrets to."
|
||||
vaultId: "The ID of the 1Password vault to sync secrets to.",
|
||||
valueLabel: "The label of the entry that holds the secret value."
|
||||
},
|
||||
HEROKU: {
|
||||
app: "The ID of the Heroku app to sync secrets to.",
|
||||
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import { QueueWorkerProfile } from "@app/lib/types";
|
||||
|
||||
import { BadRequestError } from "../errors";
|
||||
import { removeTrailingSlash } from "../fn";
|
||||
import { CustomLogger } from "../logger/logger";
|
||||
import { zpStr } from "../zod";
|
||||
@ -341,8 +342,11 @@ const envSchema = z
|
||||
|
||||
export type TEnvConfig = Readonly<z.infer<typeof envSchema>>;
|
||||
let envCfg: TEnvConfig;
|
||||
let originalEnvConfig: TEnvConfig;
|
||||
|
||||
export const getConfig = () => envCfg;
|
||||
export const getOriginalConfig = () => originalEnvConfig;
|
||||
|
||||
// cannot import singleton logger directly as it needs config to load various transport
|
||||
export const initEnvConfig = (logger?: CustomLogger) => {
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
@ -352,10 +356,115 @@ export const initEnvConfig = (logger?: CustomLogger) => {
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
envCfg = Object.freeze(parsedEnv.data);
|
||||
const config = Object.freeze(parsedEnv.data);
|
||||
envCfg = config;
|
||||
|
||||
if (!originalEnvConfig) {
|
||||
originalEnvConfig = config;
|
||||
}
|
||||
|
||||
return envCfg;
|
||||
};
|
||||
|
||||
// A list of environment variables that can be overwritten
|
||||
export const overwriteSchema: {
|
||||
[key: string]: {
|
||||
name: string;
|
||||
fields: { key: keyof TEnvConfig; description?: string }[];
|
||||
};
|
||||
} = {
|
||||
azure: {
|
||||
name: "Azure",
|
||||
fields: [
|
||||
{
|
||||
key: "INF_APP_CONNECTION_AZURE_CLIENT_ID",
|
||||
description: "The Application (Client) ID of your Azure application."
|
||||
},
|
||||
{
|
||||
key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRET",
|
||||
description: "The Client Secret of your Azure application."
|
||||
}
|
||||
]
|
||||
},
|
||||
google_sso: {
|
||||
name: "Google SSO",
|
||||
fields: [
|
||||
{
|
||||
key: "CLIENT_ID_GOOGLE_LOGIN",
|
||||
description: "The Client ID of your GCP OAuth2 application."
|
||||
},
|
||||
{
|
||||
key: "CLIENT_SECRET_GOOGLE_LOGIN",
|
||||
description: "The Client Secret of your GCP OAuth2 application."
|
||||
}
|
||||
]
|
||||
},
|
||||
github_sso: {
|
||||
name: "GitHub SSO",
|
||||
fields: [
|
||||
{
|
||||
key: "CLIENT_ID_GITHUB_LOGIN",
|
||||
description: "The Client ID of your GitHub OAuth application."
|
||||
},
|
||||
{
|
||||
key: "CLIENT_SECRET_GITHUB_LOGIN",
|
||||
description: "The Client Secret of your GitHub OAuth application."
|
||||
}
|
||||
]
|
||||
},
|
||||
gitlab_sso: {
|
||||
name: "GitLab SSO",
|
||||
fields: [
|
||||
{
|
||||
key: "CLIENT_ID_GITLAB_LOGIN",
|
||||
description: "The Client ID of your GitLab application."
|
||||
},
|
||||
{
|
||||
key: "CLIENT_SECRET_GITLAB_LOGIN",
|
||||
description: "The Secret of your GitLab application."
|
||||
},
|
||||
{
|
||||
key: "CLIENT_GITLAB_LOGIN_URL",
|
||||
description:
|
||||
"The URL of your self-hosted instance of GitLab where the OAuth application is registered. If no URL is passed in, this will default to https://gitlab.com."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const overridableKeys = new Set(
|
||||
Object.values(overwriteSchema).flatMap(({ fields }) => fields.map(({ key }) => key))
|
||||
);
|
||||
|
||||
export const validateOverrides = (config: Record<string, string>) => {
|
||||
const allowedOverrides = Object.fromEntries(
|
||||
Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input<typeof envSchema>))
|
||||
);
|
||||
|
||||
const tempEnv: Record<string, unknown> = { ...process.env, ...allowedOverrides };
|
||||
const parsedResult = envSchema.safeParse(tempEnv);
|
||||
|
||||
if (!parsedResult.success) {
|
||||
const errorDetails = parsedResult.error.issues
|
||||
.map((issue) => `Key: "${issue.path.join(".")}", Error: ${issue.message}`)
|
||||
.join("\n");
|
||||
throw new BadRequestError({ message: errorDetails });
|
||||
}
|
||||
};
|
||||
|
||||
export const overrideEnvConfig = (config: Record<string, string>) => {
|
||||
const allowedOverrides = Object.fromEntries(
|
||||
Object.entries(config).filter(([key]) => overridableKeys.has(key as keyof z.input<typeof envSchema>))
|
||||
);
|
||||
|
||||
const tempEnv: Record<string, unknown> = { ...process.env, ...allowedOverrides };
|
||||
const parsedResult = envSchema.safeParse(tempEnv);
|
||||
|
||||
if (parsedResult.success) {
|
||||
envCfg = Object.freeze(parsedResult.data);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatSmtpConfig = () => {
|
||||
const tlsOptions: {
|
||||
rejectUnauthorized: boolean;
|
||||
|
@ -1419,7 +1419,8 @@ export const registerRoutes = async (
|
||||
const identityAccessTokenService = identityAccessTokenServiceFactory({
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL,
|
||||
accessTokenQueue
|
||||
accessTokenQueue,
|
||||
identityDAL
|
||||
});
|
||||
|
||||
const identityProjectService = identityProjectServiceFactory({
|
||||
@ -2044,6 +2045,10 @@ export const registerRoutes = async (
|
||||
cronJobs.push(adminIntegrationsSyncJob);
|
||||
}
|
||||
}
|
||||
const configSyncJob = await superAdminService.initializeEnvConfigSync();
|
||||
if (configSyncJob) {
|
||||
cronJobs.push(configSyncJob);
|
||||
}
|
||||
|
||||
server.decorate<FastifyZodProvider["store"]>("store", {
|
||||
user: userDAL,
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
SuperAdminSchema,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { getConfig, overridableKeys } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
@ -42,7 +42,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
encryptedGitHubAppConnectionClientSecret: true,
|
||||
encryptedGitHubAppConnectionSlug: true,
|
||||
encryptedGitHubAppConnectionId: true,
|
||||
encryptedGitHubAppConnectionPrivateKey: true
|
||||
encryptedGitHubAppConnectionPrivateKey: true,
|
||||
encryptedEnvOverrides: true
|
||||
}).extend({
|
||||
isMigrationModeOn: z.boolean(),
|
||||
defaultAuthOrgSlug: z.string().nullable(),
|
||||
@ -110,11 +111,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
.refine((content) => DOMPurify.sanitize(content) === content, {
|
||||
message: "Page frame content contains unsafe HTML."
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
envOverrides: z.record(z.enum(Array.from(overridableKeys) as [string, ...string[]]), z.string()).optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
config: SuperAdminSchema.extend({
|
||||
config: SuperAdminSchema.omit({
|
||||
encryptedEnvOverrides: true
|
||||
}).extend({
|
||||
defaultAuthOrgSlug: z.string().nullable()
|
||||
})
|
||||
})
|
||||
@ -381,6 +385,41 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/env-overrides",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
fields: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
hasEnvEntry: z.boolean(),
|
||||
description: z.string().optional()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onRequest: (req, res, done) => {
|
||||
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
|
||||
verifySuperAdmin(req, res, done);
|
||||
});
|
||||
},
|
||||
handler: async () => {
|
||||
const envOverrides = await server.services.superAdmin.getEnvOverridesOrganized();
|
||||
return envOverrides;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/user-management/users/:userId",
|
||||
|
@ -457,6 +457,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiAlerting],
|
||||
params: z.object({
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
@ -487,6 +489,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateCollections],
|
||||
params: z.object({
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
@ -549,6 +553,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateTemplates],
|
||||
params: z.object({
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
|
@ -17,71 +17,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
|
||||
const doc = await (tx || db.replicaNode())(TableName.IdentityAccessToken)
|
||||
.where(filter)
|
||||
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`)
|
||||
.leftJoin(
|
||||
TableName.IdentityUaClientSecret,
|
||||
`${TableName.IdentityAccessToken}.identityUAClientSecretId`,
|
||||
`${TableName.IdentityUaClientSecret}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.IdentityUniversalAuth,
|
||||
`${TableName.IdentityUaClientSecret}.identityUAId`,
|
||||
`${TableName.IdentityUniversalAuth}.id`
|
||||
)
|
||||
.leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`)
|
||||
.leftJoin(
|
||||
TableName.IdentityAliCloudAuth,
|
||||
`${TableName.Identity}.id`,
|
||||
`${TableName.IdentityAliCloudAuth}.identityId`
|
||||
)
|
||||
.leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`)
|
||||
.leftJoin(
|
||||
TableName.IdentityKubernetesAuth,
|
||||
`${TableName.Identity}.id`,
|
||||
`${TableName.IdentityKubernetesAuth}.identityId`
|
||||
)
|
||||
.leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`)
|
||||
.leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`)
|
||||
.select(selectAllTableCols(TableName.IdentityAccessToken))
|
||||
.select(
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"),
|
||||
db
|
||||
.ref("accessTokenTrustedIps")
|
||||
.withSchema(TableName.IdentityAliCloudAuth)
|
||||
.as("accessTokenTrustedIpsAliCloud"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOciAuth).as("accessTokenTrustedIpsOci"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"),
|
||||
db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"),
|
||||
db.ref("name").withSchema(TableName.Identity)
|
||||
)
|
||||
.select(db.ref("name").withSchema(TableName.Identity))
|
||||
.first();
|
||||
|
||||
if (!doc) return;
|
||||
|
||||
return {
|
||||
...doc,
|
||||
trustedIpsUniversalAuth: doc.accessTokenTrustedIpsUa,
|
||||
trustedIpsGcpAuth: doc.accessTokenTrustedIpsGcp,
|
||||
trustedIpsAliCloudAuth: doc.accessTokenTrustedIpsAliCloud,
|
||||
trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws,
|
||||
trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure,
|
||||
trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s,
|
||||
trustedIpsOciAuth: doc.accessTokenTrustedIpsOci,
|
||||
trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc,
|
||||
trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken,
|
||||
trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt,
|
||||
trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap,
|
||||
trustedIpsAccessTlsCertAuth: []
|
||||
};
|
||||
return doc;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "IdAccessTokenFindOne" });
|
||||
}
|
||||
|
@ -7,12 +7,14 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip";
|
||||
|
||||
import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue";
|
||||
import { AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityDALFactory } from "../identity/identity-dal";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal";
|
||||
import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types";
|
||||
|
||||
type TIdentityAccessTokenServiceFactoryDep = {
|
||||
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
|
||||
identityDAL: Pick<TIdentityDALFactory, "getTrustedIpsByAuthMethod">;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
accessTokenQueue: Pick<
|
||||
TAccessTokenQueueServiceFactory,
|
||||
@ -25,7 +27,8 @@ export type TIdentityAccessTokenServiceFactory = ReturnType<typeof identityAcces
|
||||
export const identityAccessTokenServiceFactory = ({
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL,
|
||||
accessTokenQueue
|
||||
accessTokenQueue,
|
||||
identityDAL
|
||||
}: TIdentityAccessTokenServiceFactoryDep) => {
|
||||
const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => {
|
||||
const {
|
||||
@ -190,24 +193,11 @@ export const identityAccessTokenServiceFactory = ({
|
||||
message: "Failed to authorize revoked access token, access token is revoked"
|
||||
});
|
||||
|
||||
const trustedIpsMap: Record<IdentityAuthMethod, unknown> = {
|
||||
[IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth,
|
||||
[IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth,
|
||||
[IdentityAuthMethod.ALICLOUD_AUTH]: identityAccessToken.trustedIpsAliCloudAuth,
|
||||
[IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth,
|
||||
[IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth,
|
||||
[IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth,
|
||||
[IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth,
|
||||
[IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth,
|
||||
[IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth,
|
||||
[IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth,
|
||||
[IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth,
|
||||
[IdentityAuthMethod.TLS_CERT_AUTH]: identityAccessToken.trustedIpsAccessTlsCertAuth
|
||||
};
|
||||
|
||||
const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod];
|
||||
|
||||
if (ipAddress) {
|
||||
const trustedIps = await identityDAL.getTrustedIpsByAuthMethod(
|
||||
identityAccessToken.identityId,
|
||||
identityAccessToken.authMethod as IdentityAuthMethod
|
||||
);
|
||||
if (ipAddress && trustedIps) {
|
||||
checkIPAgainstBlocklist({
|
||||
ipAddress,
|
||||
trustedIps: trustedIps as TIp[]
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TIdentities } from "@app/db/schemas";
|
||||
import { IdentityAuthMethod, TableName, TIdentities } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
@ -8,6 +8,28 @@ export type TIdentityDALFactory = ReturnType<typeof identityDALFactory>;
|
||||
export const identityDALFactory = (db: TDbClient) => {
|
||||
const identityOrm = ormify(db, TableName.Identity);
|
||||
|
||||
const getTrustedIpsByAuthMethod = async (identityId: string, authMethod: IdentityAuthMethod) => {
|
||||
const authMethodToTableName = {
|
||||
[IdentityAuthMethod.TOKEN_AUTH]: TableName.IdentityTokenAuth,
|
||||
[IdentityAuthMethod.UNIVERSAL_AUTH]: TableName.IdentityUniversalAuth,
|
||||
[IdentityAuthMethod.KUBERNETES_AUTH]: TableName.IdentityKubernetesAuth,
|
||||
[IdentityAuthMethod.GCP_AUTH]: TableName.IdentityGcpAuth,
|
||||
[IdentityAuthMethod.ALICLOUD_AUTH]: TableName.IdentityAliCloudAuth,
|
||||
[IdentityAuthMethod.AWS_AUTH]: TableName.IdentityAwsAuth,
|
||||
[IdentityAuthMethod.AZURE_AUTH]: TableName.IdentityAzureAuth,
|
||||
[IdentityAuthMethod.TLS_CERT_AUTH]: TableName.IdentityTlsCertAuth,
|
||||
[IdentityAuthMethod.OCI_AUTH]: TableName.IdentityOciAuth,
|
||||
[IdentityAuthMethod.OIDC_AUTH]: TableName.IdentityOidcAuth,
|
||||
[IdentityAuthMethod.JWT_AUTH]: TableName.IdentityJwtAuth,
|
||||
[IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth
|
||||
} as const;
|
||||
const tableName = authMethodToTableName[authMethod];
|
||||
if (!tableName) return;
|
||||
const data = await db(tableName).where({ identityId }).first();
|
||||
if (!data) return;
|
||||
return data.accessTokenTrustedIps;
|
||||
};
|
||||
|
||||
const getIdentitiesByFilter = async ({
|
||||
limit,
|
||||
offset,
|
||||
@ -38,5 +60,5 @@ export const identityDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { ...identityOrm, getIdentitiesByFilter };
|
||||
return { ...identityOrm, getTrustedIpsByAuthMethod, getIdentitiesByFilter };
|
||||
};
|
||||
|
@ -392,7 +392,12 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`)
|
||||
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
|
||||
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
|
||||
.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection)
|
||||
.orderBy(
|
||||
orderBy === OrgIdentityOrderBy.Role
|
||||
? `${TableName.IdentityOrgMembership}.${orderBy}`
|
||||
: `${TableName.Identity}.${orderBy}`,
|
||||
orderDirection
|
||||
)
|
||||
.select(`${TableName.IdentityOrgMembership}.id`)
|
||||
.select<{ id: string; total_count: string }>(
|
||||
db.raw(
|
||||
@ -523,6 +528,23 @@ export const identityOrgDALFactory = (db: TDbClient) => {
|
||||
|
||||
if (orderBy === OrgIdentityOrderBy.Name) {
|
||||
void query.orderBy("identityName", orderDirection);
|
||||
} else if (orderBy === OrgIdentityOrderBy.Role) {
|
||||
void query.orderByRaw(
|
||||
`
|
||||
CASE
|
||||
WHEN ??.role = ?
|
||||
THEN ??.slug
|
||||
ELSE ??.role
|
||||
END ?
|
||||
`,
|
||||
[
|
||||
TableName.IdentityOrgMembership,
|
||||
"custom",
|
||||
TableName.OrgRoles,
|
||||
TableName.IdentityOrgMembership,
|
||||
db.raw(orderDirection)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const docs = await query;
|
||||
|
@ -46,8 +46,8 @@ export type TListOrgIdentitiesByOrgIdDTO = {
|
||||
} & TOrgPermission;
|
||||
|
||||
export enum OrgIdentityOrderBy {
|
||||
Name = "name"
|
||||
// Role = "role"
|
||||
Name = "name",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
export type TSearchOrgIdentitiesByOrgIdDAL = {
|
||||
|
@ -6,7 +6,6 @@ import {
|
||||
TOnePassListVariablesResponse,
|
||||
TOnePassSyncWithCredentials,
|
||||
TOnePassVariable,
|
||||
TOnePassVariableDetails,
|
||||
TPostOnePassVariable,
|
||||
TPutOnePassVariable
|
||||
} from "@app/services/secret-sync/1password/1password-sync-types";
|
||||
@ -14,7 +13,10 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassListVariables) => {
|
||||
// This should not be changed or it may break existing logic
|
||||
const VALUE_LABEL_DEFAULT = "value";
|
||||
|
||||
const listOnePassItems = async ({ instanceUrl, apiToken, vaultId, valueLabel }: TOnePassListVariables) => {
|
||||
const { data } = await request.get<TOnePassListVariablesResponse>(`${instanceUrl}/v1/vaults/${vaultId}/items`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
@ -22,36 +24,49 @@ const listOnePassItems = async ({ instanceUrl, apiToken, vaultId }: TOnePassList
|
||||
}
|
||||
});
|
||||
|
||||
const result: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
|
||||
const items: Record<string, TOnePassVariable & { value: string; fieldId: string }> = {};
|
||||
const duplicates: Record<string, string> = {};
|
||||
|
||||
for await (const s of data) {
|
||||
const { data: secret } = await request.get<TOnePassVariableDetails>(
|
||||
`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line no-continue
|
||||
if (s.category !== "API_CREDENTIAL") continue;
|
||||
|
||||
const value = secret.fields.find((f) => f.label === "value")?.value;
|
||||
const fieldId = secret.fields.find((f) => f.label === "value")?.id;
|
||||
if (items[s.title]) {
|
||||
duplicates[s.id] = s.title;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: secret } = await request.get<TOnePassVariable>(`${instanceUrl}/v1/vaults/${vaultId}/items/${s.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const valueField = secret.fields.find((f) => f.label === valueLabel);
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!value || !fieldId) continue;
|
||||
if (!valueField || !valueField.value || !valueField.id) continue;
|
||||
|
||||
result[s.title] = {
|
||||
items[s.title] = {
|
||||
...secret,
|
||||
value,
|
||||
fieldId
|
||||
value: valueField.value,
|
||||
fieldId: valueField.id
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
return { items, duplicates };
|
||||
};
|
||||
|
||||
const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, itemValue }: TPostOnePassVariable) => {
|
||||
const createOnePassItem = async ({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
itemTitle,
|
||||
itemValue,
|
||||
valueLabel
|
||||
}: TPostOnePassVariable) => {
|
||||
return request.post(
|
||||
`${instanceUrl}/v1/vaults/${vaultId}/items`,
|
||||
{
|
||||
@ -63,7 +78,7 @@ const createOnePassItem = async ({ instanceUrl, apiToken, vaultId, itemTitle, it
|
||||
tags: ["synced-from-infisical"],
|
||||
fields: [
|
||||
{
|
||||
label: "value",
|
||||
label: valueLabel,
|
||||
value: itemValue,
|
||||
type: "CONCEALED"
|
||||
}
|
||||
@ -85,7 +100,9 @@ const updateOnePassItem = async ({
|
||||
itemId,
|
||||
fieldId,
|
||||
itemTitle,
|
||||
itemValue
|
||||
itemValue,
|
||||
valueLabel,
|
||||
otherFields
|
||||
}: TPutOnePassVariable) => {
|
||||
return request.put(
|
||||
`${instanceUrl}/v1/vaults/${vaultId}/items/${itemId}`,
|
||||
@ -98,9 +115,10 @@ const updateOnePassItem = async ({
|
||||
},
|
||||
tags: ["synced-from-infisical"],
|
||||
fields: [
|
||||
...otherFields,
|
||||
{
|
||||
id: fieldId,
|
||||
label: "value",
|
||||
label: valueLabel,
|
||||
value: itemValue,
|
||||
type: "CONCEALED"
|
||||
}
|
||||
@ -128,13 +146,18 @@ export const OnePassSyncFns = {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
destinationConfig: { vaultId }
|
||||
destinationConfig: { vaultId, valueLabel }
|
||||
} = secretSync;
|
||||
|
||||
const instanceUrl = await getOnePassInstanceUrl(connection);
|
||||
const { apiToken } = connection.credentials;
|
||||
|
||||
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
|
||||
const { items, duplicates } = await listOnePassItems({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
|
||||
});
|
||||
|
||||
for await (const entry of Object.entries(secretMap)) {
|
||||
const [key, { value }] = entry;
|
||||
@ -148,10 +171,19 @@ export const OnePassSyncFns = {
|
||||
itemTitle: key,
|
||||
itemValue: value,
|
||||
itemId: items[key].id,
|
||||
fieldId: items[key].fieldId
|
||||
fieldId: items[key].fieldId,
|
||||
valueLabel: valueLabel || VALUE_LABEL_DEFAULT,
|
||||
otherFields: items[key].fields.filter((field) => field.label !== (valueLabel || VALUE_LABEL_DEFAULT))
|
||||
});
|
||||
} else {
|
||||
await createOnePassItem({ instanceUrl, apiToken, vaultId, itemTitle: key, itemValue: value });
|
||||
await createOnePassItem({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
itemTitle: key,
|
||||
itemValue: value,
|
||||
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
@ -163,7 +195,28 @@ export const OnePassSyncFns = {
|
||||
|
||||
if (secretSync.syncOptions.disableSecretDeletion) return;
|
||||
|
||||
for await (const [key, variable] of Object.entries(items)) {
|
||||
// Delete duplicate item entries
|
||||
for await (const [itemId, key] of Object.entries(duplicates)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
try {
|
||||
await deleteOnePassItem({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
itemId
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: key
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete item entries that are not in secretMap
|
||||
for await (const [key, item] of Object.entries(items)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
@ -173,7 +226,7 @@ export const OnePassSyncFns = {
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
itemId: variable.id
|
||||
itemId: item.id
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
@ -187,13 +240,18 @@ export const OnePassSyncFns = {
|
||||
removeSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { vaultId }
|
||||
destinationConfig: { vaultId, valueLabel }
|
||||
} = secretSync;
|
||||
|
||||
const instanceUrl = await getOnePassInstanceUrl(connection);
|
||||
const { apiToken } = connection.credentials;
|
||||
|
||||
const items = await listOnePassItems({ instanceUrl, apiToken, vaultId });
|
||||
const { items } = await listOnePassItems({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
|
||||
});
|
||||
|
||||
for await (const [key, item] of Object.entries(items)) {
|
||||
if (key in secretMap) {
|
||||
@ -216,12 +274,19 @@ export const OnePassSyncFns = {
|
||||
getSecrets: async (secretSync: TOnePassSyncWithCredentials) => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { vaultId }
|
||||
destinationConfig: { vaultId, valueLabel }
|
||||
} = secretSync;
|
||||
|
||||
const instanceUrl = await getOnePassInstanceUrl(connection);
|
||||
const { apiToken } = connection.credentials;
|
||||
|
||||
return listOnePassItems({ instanceUrl, apiToken, vaultId });
|
||||
const res = await listOnePassItems({
|
||||
instanceUrl,
|
||||
apiToken,
|
||||
vaultId,
|
||||
valueLabel: valueLabel || VALUE_LABEL_DEFAULT
|
||||
});
|
||||
|
||||
return Object.fromEntries(Object.entries(res.items).map(([key, item]) => [key, { value: item.value }]));
|
||||
}
|
||||
};
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const OnePassSyncDestinationConfigSchema = z.object({
|
||||
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId)
|
||||
vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId),
|
||||
valueLabel: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.valueLabel)
|
||||
});
|
||||
|
||||
const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
|
||||
|
@ -14,29 +14,32 @@ export type TOnePassSyncWithCredentials = TOnePassSync & {
|
||||
connection: TOnePassConnection;
|
||||
};
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
type: string; // CONCEALED, STRING
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type TOnePassVariable = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string; // API_CREDENTIAL, SECURE_NOTE, LOGIN, etc
|
||||
};
|
||||
|
||||
export type TOnePassVariableDetails = TOnePassVariable & {
|
||||
fields: {
|
||||
id: string;
|
||||
type: string; // CONCEALED, STRING
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export type TOnePassListVariablesResponse = TOnePassVariable[];
|
||||
|
||||
export type TOnePassListVariables = {
|
||||
type TOnePassBase = {
|
||||
apiToken: string;
|
||||
instanceUrl: string;
|
||||
vaultId: string;
|
||||
};
|
||||
|
||||
export type TOnePassListVariables = TOnePassBase & {
|
||||
valueLabel: string;
|
||||
};
|
||||
|
||||
export type TPostOnePassVariable = TOnePassListVariables & {
|
||||
itemTitle: string;
|
||||
itemValue: string;
|
||||
@ -47,8 +50,9 @@ export type TPutOnePassVariable = TOnePassListVariables & {
|
||||
fieldId: string;
|
||||
itemTitle: string;
|
||||
itemValue: string;
|
||||
otherFields: Field[];
|
||||
};
|
||||
|
||||
export type TDeleteOnePassVariable = TOnePassListVariables & {
|
||||
export type TDeleteOnePassVariable = TOnePassBase & {
|
||||
itemId: string;
|
||||
};
|
||||
|
@ -5,7 +5,13 @@ import jwt from "jsonwebtoken";
|
||||
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import {
|
||||
getConfig,
|
||||
getOriginalConfig,
|
||||
overrideEnvConfig,
|
||||
overwriteSchema,
|
||||
validateOverrides
|
||||
} from "@app/lib/config/env";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
@ -33,6 +39,7 @@ import { TInvalidateCacheQueueFactory } from "./invalidate-cache-queue";
|
||||
import { TSuperAdminDALFactory } from "./super-admin-dal";
|
||||
import {
|
||||
CacheType,
|
||||
EnvOverrides,
|
||||
LoginMethod,
|
||||
TAdminBootstrapInstanceDTO,
|
||||
TAdminGetIdentitiesDTO,
|
||||
@ -234,6 +241,45 @@ export const superAdminServiceFactory = ({
|
||||
adminIntegrationsConfig = config;
|
||||
};
|
||||
|
||||
const getEnvOverrides = async () => {
|
||||
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
|
||||
|
||||
if (!serverCfg || !serverCfg.encryptedEnvOverrides) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const decrypt = kmsService.decryptWithRootKey();
|
||||
|
||||
const overrides = JSON.parse(decrypt(serverCfg.encryptedEnvOverrides).toString()) as Record<string, string>;
|
||||
|
||||
return overrides;
|
||||
};
|
||||
|
||||
const getEnvOverridesOrganized = async (): Promise<EnvOverrides> => {
|
||||
const overrides = await getEnvOverrides();
|
||||
const ogConfig = getOriginalConfig();
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(overwriteSchema).map(([groupKey, groupDef]) => [
|
||||
groupKey,
|
||||
{
|
||||
name: groupDef.name,
|
||||
fields: groupDef.fields.map(({ key, description }) => ({
|
||||
key,
|
||||
description,
|
||||
value: overrides[key] || "",
|
||||
hasEnvEntry: !!(ogConfig as unknown as Record<string, string | undefined>)[key]
|
||||
}))
|
||||
}
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const $syncEnvConfig = async () => {
|
||||
const config = await getEnvOverrides();
|
||||
overrideEnvConfig(config);
|
||||
};
|
||||
|
||||
const updateServerCfg = async (
|
||||
data: TSuperAdminUpdate & {
|
||||
slackClientId?: string;
|
||||
@ -246,6 +292,7 @@ export const superAdminServiceFactory = ({
|
||||
gitHubAppConnectionSlug?: string;
|
||||
gitHubAppConnectionId?: string;
|
||||
gitHubAppConnectionPrivateKey?: string;
|
||||
envOverrides?: Record<string, string>;
|
||||
},
|
||||
userId: string
|
||||
) => {
|
||||
@ -374,6 +421,17 @@ export const superAdminServiceFactory = ({
|
||||
gitHubAppConnectionSettingsUpdated = true;
|
||||
}
|
||||
|
||||
let envOverridesUpdated = false;
|
||||
if (data.envOverrides !== undefined) {
|
||||
// Verify input format
|
||||
validateOverrides(data.envOverrides);
|
||||
|
||||
const encryptedEnvOverrides = encryptWithRoot(Buffer.from(JSON.stringify(data.envOverrides)));
|
||||
updatedData.encryptedEnvOverrides = encryptedEnvOverrides;
|
||||
updatedData.envOverrides = undefined;
|
||||
envOverridesUpdated = true;
|
||||
}
|
||||
|
||||
const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData);
|
||||
|
||||
await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg));
|
||||
@ -382,6 +440,10 @@ export const superAdminServiceFactory = ({
|
||||
await $syncAdminIntegrationConfig();
|
||||
}
|
||||
|
||||
if (envOverridesUpdated) {
|
||||
await $syncEnvConfig();
|
||||
}
|
||||
|
||||
if (
|
||||
updatedServerCfg.encryptedMicrosoftTeamsAppId &&
|
||||
updatedServerCfg.encryptedMicrosoftTeamsClientSecret &&
|
||||
@ -814,6 +876,18 @@ export const superAdminServiceFactory = ({
|
||||
return job;
|
||||
};
|
||||
|
||||
const initializeEnvConfigSync = async () => {
|
||||
logger.info("Setting up background sync process for environment overrides");
|
||||
|
||||
await $syncEnvConfig();
|
||||
|
||||
// sync every 5 minutes
|
||||
const job = new CronJob("*/5 * * * *", $syncEnvConfig);
|
||||
job.start();
|
||||
|
||||
return job;
|
||||
};
|
||||
|
||||
return {
|
||||
initServerCfg,
|
||||
updateServerCfg,
|
||||
@ -833,6 +907,9 @@ export const superAdminServiceFactory = ({
|
||||
getOrganizations,
|
||||
deleteOrganization,
|
||||
deleteOrganizationMembership,
|
||||
initializeAdminIntegrationConfigSync
|
||||
initializeAdminIntegrationConfigSync,
|
||||
initializeEnvConfigSync,
|
||||
getEnvOverrides,
|
||||
getEnvOverridesOrganized
|
||||
};
|
||||
};
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { TEnvConfig } from "@app/lib/config/env";
|
||||
|
||||
export type TAdminSignUpDTO = {
|
||||
email: string;
|
||||
password: string;
|
||||
@ -74,3 +76,10 @@ export type TAdminIntegrationConfig = {
|
||||
privateKey: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface EnvOverrides {
|
||||
[key: string]: {
|
||||
name: string;
|
||||
fields: { key: keyof TEnvConfig; value: string; hasEnvEntry: boolean; description?: string }[];
|
||||
};
|
||||
}
|
||||
|
@ -114,6 +114,11 @@ var userGetTokenCmd = &cobra.Command{
|
||||
loggedInUserDetails = util.EstablishUserLoginSession()
|
||||
}
|
||||
|
||||
plain, err := cmd.Flags().GetBool("plain")
|
||||
if err != nil {
|
||||
util.HandleError(err, "[infisical user get token]: Unable to get plain flag")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.HandleError(err, "[infisical user get token]: Unable to get logged in user token")
|
||||
}
|
||||
@ -135,8 +140,12 @@ var userGetTokenCmd = &cobra.Command{
|
||||
util.HandleError(err, "[infisical user get token]: Unable to parse token payload")
|
||||
}
|
||||
|
||||
fmt.Println("Session ID:", tokenPayload.TokenVersionId)
|
||||
fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken)
|
||||
if plain {
|
||||
fmt.Println(loggedInUserDetails.UserCredentials.JTWToken)
|
||||
} else {
|
||||
fmt.Println("Session ID:", tokenPayload.TokenVersionId)
|
||||
fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -240,7 +249,10 @@ var domainCmd = &cobra.Command{
|
||||
func init() {
|
||||
updateCmd.AddCommand(domainCmd)
|
||||
userCmd.AddCommand(updateCmd)
|
||||
|
||||
userGetTokenCmd.Flags().Bool("plain", false, "print token without formatting")
|
||||
userGetCmd.AddCommand(userGetTokenCmd)
|
||||
|
||||
userCmd.AddCommand(userGetCmd)
|
||||
userCmd.AddCommand(switchCmd)
|
||||
rootCmd.AddCommand(userCmd)
|
||||
|
@ -35,19 +35,40 @@ infisical user update domain
|
||||
<Accordion title="infisical user get token">
|
||||
Use this command to get your current Infisical access token and session information. This command requires you to be logged in.
|
||||
|
||||
The command will display:
|
||||
The command will display:
|
||||
|
||||
- Your session ID
|
||||
- Your full JWT access token
|
||||
- Your session ID
|
||||
- Your full JWT access token
|
||||
|
||||
```bash
|
||||
infisical user get token
|
||||
```
|
||||
```bash
|
||||
infisical user get token
|
||||
```
|
||||
|
||||
Example output:
|
||||
Example output:
|
||||
|
||||
```bash
|
||||
Session ID: abc123-xyz-456
|
||||
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
<Accordion title="--plain">
|
||||
Output only the JWT token without formatting (no session ID)
|
||||
|
||||
Default value: `false`
|
||||
|
||||
```bash
|
||||
# Example
|
||||
infisical user get token --plain
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```bash
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
```bash
|
||||
Session ID: abc123-xyz-456
|
||||
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
</Accordion>
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 715 KiB After Width: | Height: | Size: 641 KiB |
@ -36,6 +36,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
|
||||
|
||||
- **1Password Connection**: The 1Password Connection to authenticate with.
|
||||
- **Vault**: The 1Password vault to sync secrets to.
|
||||
- **Value Label**: The label of the 1Password item field that will hold your secret value.
|
||||
</Step>
|
||||
<Step title="Configure sync options">
|
||||
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
|
||||
@ -94,7 +95,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
|
||||
"initialSyncBehavior": "overwrite-destination"
|
||||
},
|
||||
"destinationConfig": {
|
||||
"vaultId": "..."
|
||||
"vaultId": "...",
|
||||
"valueLabel": "value"
|
||||
}
|
||||
}'
|
||||
```
|
||||
@ -145,7 +147,8 @@ description: "Learn how to configure a 1Password Sync for Infisical."
|
||||
},
|
||||
"destination": "1password",
|
||||
"destinationConfig": {
|
||||
"vaultId": "..."
|
||||
"vaultId": "...",
|
||||
"valueLabel": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -160,4 +163,7 @@ description: "Learn how to configure a 1Password Sync for Infisical."
|
||||
Infisical can only perform CRUD operations on the following item types:
|
||||
- API Credentials
|
||||
</Accordion>
|
||||
<Accordion title="What is a 'Value Label'?">
|
||||
It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
@ -4,7 +4,7 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
|
||||
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
|
||||
import { FilterableSelect, FormControl, Input, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
TOnePassVault,
|
||||
useOnePassConnectionListVaults
|
||||
@ -32,6 +32,7 @@ export const OnePassSyncFields = () => {
|
||||
<SecretSyncConnectionField
|
||||
onChange={() => {
|
||||
setValue("destinationConfig.vaultId", "");
|
||||
setValue("destinationConfig.valueLabel", "");
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -69,6 +70,22 @@ export const OnePassSyncFields = () => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isOptional
|
||||
label="Value Label"
|
||||
tooltipText="It's the label of the 1Password item field which will hold your secret value. For example, if you were to sync Infisical secret 'foo: bar', the 1Password item equivalent would have an item title of 'foo', and a field on that item 'value: bar'. The field label 'value' is what gets changed by this option."
|
||||
>
|
||||
<Input value={value} onChange={onChange} placeholder="value" />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="destinationConfig.valueLabel"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -6,7 +6,15 @@ import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
export const OnePassSyncReviewFields = () => {
|
||||
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.OnePass }>();
|
||||
const vaultId = watch("destinationConfig.vaultId");
|
||||
const [vaultId, valueLabel] = watch([
|
||||
"destinationConfig.vaultId",
|
||||
"destinationConfig.valueLabel"
|
||||
]);
|
||||
|
||||
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Value Key">{valueLabel || "value"}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -7,7 +7,8 @@ export const OnePassSyncDestinationSchema = BaseSecretSyncSchema().merge(
|
||||
z.object({
|
||||
destination: z.literal(SecretSync.OnePass),
|
||||
destinationConfig: z.object({
|
||||
vaultId: z.string().trim().min(1, "Vault ID required")
|
||||
vaultId: z.string().trim().min(1, "Vault ID required"),
|
||||
valueLabel: z.string().trim().optional()
|
||||
})
|
||||
})
|
||||
);
|
||||
|
42
frontend/src/components/v2/HighlightText/HighlightText.tsx
Normal file
42
frontend/src/components/v2/HighlightText/HighlightText.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
export const HighlightText = ({
|
||||
text,
|
||||
highlight,
|
||||
highlightClassName
|
||||
}: {
|
||||
text: string | undefined | null;
|
||||
highlight: string;
|
||||
highlightClassName?: string;
|
||||
}) => {
|
||||
if (!text) return null;
|
||||
const searchTerm = highlight.toLowerCase().trim();
|
||||
|
||||
if (!searchTerm) return <span>{text}</span>;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedSearchTerm, "gi");
|
||||
|
||||
text.replace(regex, (match: string, offset: number) => {
|
||||
if (offset > lastIndex) {
|
||||
parts.push(<span key={`pre-${lastIndex}`}>{text.substring(lastIndex, offset)}</span>);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
|
||||
{match}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = offset + match.length;
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(<span key={`post-${lastIndex}`}>{text.substring(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
1
frontend/src/components/v2/HighlightText/index.tsx
Normal file
1
frontend/src/components/v2/HighlightText/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { HighlightText } from "./HighlightText";
|
@ -64,7 +64,7 @@ export const Pagination = ({
|
||||
<FontAwesomeIcon className="text-xs" icon={faCaretDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-fit">
|
||||
<DropdownMenuContent sideOffset={2} className="min-w-fit">
|
||||
{perPageList.map((perPageOption) => (
|
||||
<DropdownMenuItem
|
||||
key={`pagination-per-page-options-${perPageOption}`}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
AdminGetUsersFilters,
|
||||
AdminIntegrationsConfig,
|
||||
OrganizationWithProjects,
|
||||
TGetEnvOverrides,
|
||||
TGetInvalidatingCacheStatus,
|
||||
TGetServerRootKmsEncryptionDetails,
|
||||
TServerConfig
|
||||
@ -31,7 +32,8 @@ export const adminQueryKeys = {
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||
getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const,
|
||||
getInvalidateCache: () => ["admin-invalidate-cache"] as const,
|
||||
getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const
|
||||
getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const,
|
||||
getEnvOverrides: () => ["env-overrides"] as const
|
||||
};
|
||||
|
||||
export const fetchServerConfig = async () => {
|
||||
@ -163,3 +165,13 @@ export const useGetInvalidatingCacheStatus = (enabled = true) => {
|
||||
refetchInterval: (data) => (data ? 3000 : false)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetEnvOverrides = () => {
|
||||
return useQuery({
|
||||
queryKey: adminQueryKeys.getEnvOverrides(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TGetEnvOverrides>("/api/v1/admin/env-overrides");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -48,6 +48,7 @@ export type TServerConfig = {
|
||||
authConsentContent?: string;
|
||||
pageFrameContent?: string;
|
||||
invalidatingCache: boolean;
|
||||
envOverrides?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TUpdateServerConfigDTO = {
|
||||
@ -61,6 +62,7 @@ export type TUpdateServerConfigDTO = {
|
||||
gitHubAppConnectionSlug?: string;
|
||||
gitHubAppConnectionId?: string;
|
||||
gitHubAppConnectionPrivateKey?: string;
|
||||
envOverrides?: Record<string, string>;
|
||||
} & Partial<TServerConfig>;
|
||||
|
||||
export type TCreateAdminUserDTO = {
|
||||
@ -138,3 +140,10 @@ export type TInvalidateCacheDTO = {
|
||||
export type TGetInvalidatingCacheStatus = {
|
||||
invalidating: boolean;
|
||||
};
|
||||
|
||||
export interface TGetEnvOverrides {
|
||||
[key: string]: {
|
||||
name: string;
|
||||
fields: { key: string; value: string; hasEnvEntry: boolean; description?: string }[];
|
||||
};
|
||||
}
|
||||
|
@ -81,7 +81,8 @@ export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => {
|
||||
search
|
||||
});
|
||||
return data;
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -154,6 +154,6 @@ export type TOrgIdentitiesList = {
|
||||
};
|
||||
|
||||
export enum OrgIdentityOrderBy {
|
||||
Name = "name"
|
||||
// Role = "role"
|
||||
Name = "name",
|
||||
Role = "role"
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ export type TOnePassSync = TRootSecretSync & {
|
||||
destination: SecretSync.OnePass;
|
||||
destinationConfig: {
|
||||
vaultId: string;
|
||||
valueLabel?: string;
|
||||
};
|
||||
connection: {
|
||||
app: AppConnection.OnePass;
|
||||
|
@ -41,6 +41,11 @@ const generalTabs = [
|
||||
label: "Caching",
|
||||
icon: "note",
|
||||
link: "/admin/caching"
|
||||
},
|
||||
{
|
||||
label: "Environment",
|
||||
icon: "unlock",
|
||||
link: "/admin/environment"
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -164,7 +164,10 @@ export const MinimizedOrgSidebar = () => {
|
||||
const handleCopyToken = async () => {
|
||||
try {
|
||||
await window.navigator.clipboard.writeText(getAuthToken());
|
||||
createNotification({ type: "success", text: "Copied current login session token to clipboard" });
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Copied current login session token to clipboard"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({ type: "error", text: "Failed to copy user token to clipboard" });
|
||||
|
27
frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx
Normal file
27
frontend/src/pages/admin/EnvironmentPage/EnvironmentPage.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
|
||||
import { EnvironmentPageForm } from "./components";
|
||||
|
||||
export const EnvironmentPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="bg-bunker-800">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "Admin" })}</title>
|
||||
</Helmet>
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Environment"
|
||||
description="Manage the environment for your Infisical instance."
|
||||
/>
|
||||
<EnvironmentPageForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Control, Controller, useForm, useWatch } from "react-hook-form";
|
||||
import {
|
||||
faChevronRight,
|
||||
faExclamationTriangle,
|
||||
faMagnifyingGlass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, SecretInput, Tooltip } from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { useGetEnvOverrides, useUpdateServerConfig } from "@app/hooks/api";
|
||||
|
||||
type TForm = Record<string, string>;
|
||||
|
||||
export const GroupContainer = ({
|
||||
group,
|
||||
control,
|
||||
search
|
||||
}: {
|
||||
group: {
|
||||
fields: {
|
||||
key: string;
|
||||
value: string;
|
||||
hasEnvEntry: boolean;
|
||||
description?: string;
|
||||
}[];
|
||||
name: string;
|
||||
};
|
||||
control: Control<TForm, any, TForm>;
|
||||
search: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
className="overflow-clip border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md last:border-b"
|
||||
>
|
||||
<div
|
||||
className="flex h-14 cursor-pointer items-center px-5 py-4 text-sm text-gray-300"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className={`mr-8 transition-transform duration-100 ${open || search ? "rotate-90" : ""}`}
|
||||
icon={faChevronRight}
|
||||
/>
|
||||
|
||||
<div className="flex-grow select-none text-base">{group.name}</div>
|
||||
</div>
|
||||
|
||||
{(open || search) && (
|
||||
<div className="flex flex-col">
|
||||
{group.fields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex items-center justify-between gap-4 border-t border-mineshaft-500 bg-mineshaft-700/50 p-4"
|
||||
>
|
||||
<div className="flex max-w-lg flex-col">
|
||||
<span className="text-sm">
|
||||
<HighlightText text={field.key} highlight={search} />
|
||||
</span>
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
<HighlightText text={field.description} highlight={search} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex grow items-center justify-end gap-2">
|
||||
{field.hasEnvEntry && (
|
||||
<Tooltip
|
||||
content="Setting this value will override an existing environment variable"
|
||||
className="text-center"
|
||||
>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-yellow" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={field.key}
|
||||
render={({ field: formGenField, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 w-full max-w-sm"
|
||||
>
|
||||
<SecretInput
|
||||
{...formGenField}
|
||||
autoComplete="off"
|
||||
containerClassName="text-bunker-300 hover:border-mineshaft-400 border border-mineshaft-600 bg-bunker-600 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EnvironmentPageForm = () => {
|
||||
const { data: envOverrides } = useGetEnvOverrides();
|
||||
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const allFields = useMemo(() => {
|
||||
if (!envOverrides) return [];
|
||||
return Object.values(envOverrides).flatMap((group) => group.fields);
|
||||
}, [envOverrides]);
|
||||
|
||||
const formSchema = useMemo(() => {
|
||||
return z.object(Object.fromEntries(allFields.map((field) => [field.key, z.string()])));
|
||||
}, [allFields]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const values: Record<string, string> = {};
|
||||
allFields.forEach((field) => {
|
||||
values[field.key] = field.value ?? "";
|
||||
});
|
||||
return values;
|
||||
}, [allFields]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues
|
||||
});
|
||||
|
||||
const formValues = useWatch({ control });
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!envOverrides) return [];
|
||||
|
||||
const searchTerm = search.toLowerCase().trim();
|
||||
if (!searchTerm) {
|
||||
return Object.values(envOverrides);
|
||||
}
|
||||
|
||||
return Object.values(envOverrides)
|
||||
.map((group) => {
|
||||
const filteredFields = group.fields.filter(
|
||||
(field) =>
|
||||
field.key.toLowerCase().includes(searchTerm) ||
|
||||
(field.description ?? "").toLowerCase().includes(searchTerm)
|
||||
);
|
||||
|
||||
if (filteredFields.length > 0) {
|
||||
return { ...group, fields: filteredFields };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [search, formValues, envOverrides]);
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [defaultValues, reset]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (formData: TForm) => {
|
||||
try {
|
||||
const filteredFormData = Object.fromEntries(
|
||||
Object.entries(formData).filter(([, value]) => value !== "")
|
||||
);
|
||||
await updateServerConfig({
|
||||
envOverrides: filteredFormData
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Environment overrides updated successfully. It can take up to 5 minutes to take effect."
|
||||
});
|
||||
|
||||
reset(formData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as any)?.response?.data?.message ||
|
||||
(error as any)?.message ||
|
||||
"An unknown error occurred";
|
||||
createNotification({
|
||||
type: "error",
|
||||
title: "Failed to update environment overrides",
|
||||
text: errorMessage
|
||||
});
|
||||
}
|
||||
},
|
||||
[reset, updateServerConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-start gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Overrides</p>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-300">Override specific environment variables.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline_bg"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
>
|
||||
Save Overrides
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search for keys, descriptions, and values..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{filteredData.map((group) => (
|
||||
<GroupContainer group={group!} control={control} search={search} />
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { EnvironmentPageForm } from "./EnvironmentPageForm";
|
25
frontend/src/pages/admin/EnvironmentPage/route.tsx
Normal file
25
frontend/src/pages/admin/EnvironmentPage/route.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
||||
|
||||
import { EnvironmentPage } from "./EnvironmentPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/environment"
|
||||
)({
|
||||
component: EnvironmentPage,
|
||||
beforeLoad: async () => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
{
|
||||
label: "Admin",
|
||||
link: linkOptions({ to: "/admin" })
|
||||
},
|
||||
{
|
||||
label: "Environment",
|
||||
link: linkOptions({
|
||||
to: "/admin/environment"
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
@ -56,12 +56,12 @@ export const OrgGroupsSection = () => {
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
|
||||
<OrgPermissionCan I={OrgPermissionGroupActions.Create} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddGroupModal()}
|
||||
|
@ -2,14 +2,17 @@ import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faEllipsis,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faTrash,
|
||||
faUserGroup,
|
||||
faUsers
|
||||
} 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 { OrgPermissionCan } from "@app/components/permissions";
|
||||
@ -261,7 +264,8 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Select
|
||||
value={role === "custom" ? (customRole?.slug as string) : role}
|
||||
isDisabled={!isAllowed}
|
||||
className="w-48 bg-mineshaft-600"
|
||||
className="h-8 w-48 bg-mineshaft-700"
|
||||
position="popper"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
handleChangeRole({
|
||||
@ -282,13 +286,19 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</Td>
|
||||
<Td>
|
||||
<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 asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
className="w-6"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<DropdownMenuItem
|
||||
icon={<FontAwesomeIcon icon={faCopy} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
createNotification({
|
||||
@ -306,10 +316,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("group", {
|
||||
@ -320,7 +327,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
customRole
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Edit Group
|
||||
</DropdownMenuItem>
|
||||
@ -332,10 +339,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
icon={<FontAwesomeIcon icon={faUserGroup} />}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/organization/groups/$groupId",
|
||||
@ -344,7 +348,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
}
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Manage Members
|
||||
</DropdownMenuItem>
|
||||
@ -356,11 +360,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
@ -368,7 +368,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
name
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Delete Group
|
||||
</DropdownMenuItem>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
@ -71,20 +71,22 @@ export const IdentitySection = withPermission(
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/identities/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/identities/overview"
|
||||
className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white"
|
||||
>
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<OrgPermissionCan
|
||||
@ -93,7 +95,7 @@ export const IdentitySection = withPermission(
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faEllipsis,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faServer
|
||||
faServer,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
@ -15,19 +18,18 @@ import { twMerge } from "tailwind-merge";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
@ -38,7 +40,6 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
@ -63,6 +64,10 @@ type Props = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
type Filter = {
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
@ -90,7 +95,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
roles: []
|
||||
});
|
||||
|
||||
const organizationId = currentOrg?.id || "";
|
||||
|
||||
@ -103,7 +110,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
orderBy,
|
||||
search: {
|
||||
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
|
||||
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
|
||||
role: filter.roles?.length ? { $in: filter.roles } : undefined
|
||||
}
|
||||
});
|
||||
|
||||
@ -113,7 +120,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
const filterForm = useForm<{ roles: string }>();
|
||||
|
||||
const { data: roles } = useGetOrgRoles(organizationId);
|
||||
|
||||
@ -153,79 +159,80 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleToggle = useCallback(
|
||||
(roleSlug: string) =>
|
||||
setFilter((state) => {
|
||||
const currentRoles = state.roles || [];
|
||||
|
||||
if (currentRoles.includes(roleSlug)) {
|
||||
return { ...state, roles: currentRoles.filter((role) => role !== roleSlug) };
|
||||
}
|
||||
return { ...state, roles: [...currentRoles, roleSlug] };
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const isTableFiltered = Boolean(filter.roles.length);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Identities"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Roles
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Apply Roles to Filter Identities
|
||||
</DropdownMenuLabel>
|
||||
{roles?.map(({ id, slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleRoleToggle(slug);
|
||||
}}
|
||||
key={id}
|
||||
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search identities by name..."
|
||||
/>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
ariaLabel="filter"
|
||||
variant="outline_bg"
|
||||
className={filteredRoles?.length ? "border-primary" : ""}
|
||||
>
|
||||
<Tooltip content="Advance Filter">
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
|
||||
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Advance Filter
|
||||
</div>
|
||||
<form
|
||||
onSubmit={filterForm.handleSubmit((el) => {
|
||||
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
|
||||
})}
|
||||
>
|
||||
<Controller
|
||||
control={filterForm.control}
|
||||
name="roles"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Roles"
|
||||
helperText="Eg: admin,viewer"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="xs"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
className="mt-4"
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
{Boolean(filteredRoles.length) && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="ml-4 mt-4"
|
||||
onClick={() => {
|
||||
filterForm.reset({ roles: "" });
|
||||
setFilteredRoles([]);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@ -251,8 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Role</Th>
|
||||
{/* <Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Role
|
||||
<IconButton
|
||||
@ -271,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th> */}
|
||||
</Th>
|
||||
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
@ -303,7 +309,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Select
|
||||
value={role === "custom" ? (customRole?.slug as string) : role}
|
||||
isDisabled={!isAllowed}
|
||||
className="w-48 bg-mineshaft-600"
|
||||
className="h-8 w-48 bg-mineshaft-700"
|
||||
position="popper"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
handleChangeRole({
|
||||
@ -324,21 +331,24 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="flex justify-center hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
className="w-6"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="mt-3 p-1">
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionIdentityActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
@ -348,7 +358,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Edit Identity
|
||||
</DropdownMenuItem>
|
||||
@ -360,11 +370,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
@ -372,7 +377,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
name
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete Identity
|
||||
</DropdownMenuItem>
|
||||
@ -398,7 +404,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
{!isPending && data && data?.identities.length === 0 && (
|
||||
<EmptyState
|
||||
title={
|
||||
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
|
||||
debouncedSearch.trim().length > 0 || filter.roles?.length > 0
|
||||
? "No identities match search filter"
|
||||
: "No identities have been created in this organization"
|
||||
}
|
||||
|
@ -115,12 +115,12 @@ export const OrgMembersSection = () => {
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddMemberModal()}
|
||||
|
@ -4,11 +4,14 @@ import {
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEllipsis,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUsers
|
||||
faUsers,
|
||||
faUserSlash,
|
||||
faUserXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
@ -79,7 +82,8 @@ type Props = {
|
||||
|
||||
enum OrgMembersOrderBy {
|
||||
Name = "firstName",
|
||||
Email = "email"
|
||||
Email = "email",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
type Filter = {
|
||||
@ -99,8 +103,10 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { data: members = [], isPending: isMembersLoading } = useGetOrgUsers(orgId);
|
||||
|
||||
const { mutateAsync: resendOrgMemberInvitation } = useResendOrgMemberInvitation();
|
||||
const { mutateAsync: resendOrgMemberInvitation, isPending: isResendInvitePending } =
|
||||
useResendOrgMemberInvitation();
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
const [resendInviteId, setResendInviteId] = useState<string | null>(null);
|
||||
|
||||
const onRoleChange = async (membershipId: string, role: string) => {
|
||||
if (!currentOrg?.id) return;
|
||||
@ -136,6 +142,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
};
|
||||
|
||||
const onResendInvite = async (membershipId: string) => {
|
||||
setResendInviteId(membershipId);
|
||||
try {
|
||||
const signupToken = await resendOrgMemberInvitation({
|
||||
membershipId
|
||||
@ -156,6 +163,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
text: "Failed to resend org invitation",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setResendInviteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -229,6 +238,16 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
valueOne = memberOne.user.email || memberOne.inviteEmail;
|
||||
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
|
||||
break;
|
||||
case OrgMembersOrderBy.Role:
|
||||
valueOne =
|
||||
memberOne.role === "custom"
|
||||
? findRoleFromId(memberOne.roleId)!.slug
|
||||
: memberOne.role;
|
||||
valueTwo =
|
||||
memberTwo.role === "custom"
|
||||
? findRoleFromId(memberTwo.roleId)!.slug
|
||||
: memberTwo.role;
|
||||
break;
|
||||
case OrgMembersOrderBy.Name:
|
||||
default:
|
||||
valueOne = memberOne.user.firstName;
|
||||
@ -284,7 +303,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
@ -378,7 +397,26 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Role</Th>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Role
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === OrgMembersOrderBy.Role ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrgMembersOrderBy.Role)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC &&
|
||||
orderBy === OrgMembersOrderBy.Role
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
@ -398,7 +436,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
isActive
|
||||
}) => {
|
||||
const name =
|
||||
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
|
||||
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : null;
|
||||
const email = u?.email || inviteEmail;
|
||||
const username = u?.username ?? inviteEmail ?? "-";
|
||||
return (
|
||||
@ -415,7 +453,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
}
|
||||
>
|
||||
<Td className={isActive ? "" : "text-mineshaft-400"}>
|
||||
{name}
|
||||
{name ?? <span className="text-mineshaft-400">Not Set</span>}
|
||||
{u.superAdmin && (
|
||||
<Badge variant="primary" className="ml-2">
|
||||
Server Admin
|
||||
@ -429,79 +467,77 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
a={OrgPermissionSubjects.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<>
|
||||
{!isActive && (
|
||||
<Button
|
||||
isDisabled
|
||||
className="w-40"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Suspended
|
||||
</Button>
|
||||
)}
|
||||
{isActive && status === "accepted" && (
|
||||
<Select
|
||||
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
className="w-48 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
onRoleChange(orgMembershipId, selectedRole)
|
||||
}
|
||||
>
|
||||
{(roles || [])
|
||||
.filter(({ slug }) =>
|
||||
slug === "owner" ? isIamOwner || role === "owner" : true
|
||||
)
|
||||
.map(({ slug, name: roleName }) => (
|
||||
<SelectItem value={slug} key={`owner-option-${slug}`}>
|
||||
{roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{isActive &&
|
||||
(status === "invited" || status === "verified") &&
|
||||
email &&
|
||||
serverDetails?.emailConfigured && (
|
||||
<Select
|
||||
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
className="h-8 w-48 bg-mineshaft-700"
|
||||
position="popper"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
onRoleChange(orgMembershipId, selectedRole)
|
||||
}
|
||||
>
|
||||
{(roles || [])
|
||||
.filter(({ slug }) =>
|
||||
slug === "owner" ? isIamOwner || role === "owner" : true
|
||||
)
|
||||
.map(({ slug, name: roleName }) => (
|
||||
<SelectItem value={slug} key={`owner-option-${slug}`}>
|
||||
{roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-6">
|
||||
{isActive &&
|
||||
(status === "invited" || status === "verified") &&
|
||||
email &&
|
||||
serverDetails?.emailConfigured && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
className="w-48"
|
||||
isDisabled={!isAllowed || isResendInvitePending}
|
||||
className="h-8 border-mineshaft-600 bg-mineshaft-700 font-normal"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={
|
||||
isResendInvitePending && resendInviteId === orgMembershipId
|
||||
}
|
||||
onClick={(e) => {
|
||||
onResendInvite(orgMembershipId);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
Resend invite
|
||||
Resend Invite
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
<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 asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className={twMerge("w-6", userId === u?.id && "opacity-50")}
|
||||
variant="plain"
|
||||
isDisabled={userId === u?.id}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
@ -511,7 +547,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
>
|
||||
Edit User
|
||||
</DropdownMenuItem>
|
||||
@ -523,15 +560,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={
|
||||
isActive
|
||||
? twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
icon={<FontAwesomeIcon icon={faUserSlash} />}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@ -560,7 +589,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
username
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{`${isActive ? "Deactivate" : "Activate"} User`}
|
||||
</DropdownMenuItem>
|
||||
@ -572,11 +601,6 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@ -593,7 +617,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
username
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faUserXmark} />}
|
||||
>
|
||||
Remove User
|
||||
</DropdownMenuItem>
|
||||
@ -601,7 +626,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
|
@ -6,10 +6,10 @@ export const OrgRoleTabSection = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="role-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: -30 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<OrgRoleTable />
|
||||
</motion.div>
|
||||
|
@ -1,4 +1,17 @@
|
||||
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faEye,
|
||||
faIdBadge,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@ -14,6 +27,10 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@ -30,13 +47,25 @@ import {
|
||||
useOrganization,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { isCustomOrgRole, isCustomProjectRole } from "@app/helpers/roles";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useDeleteOrgRole, useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { TOrgRole } from "@app/hooks/api/roles/types";
|
||||
import { DuplicateOrgRoleModal } from "@app/pages/organization/RoleByIDPage/components/DuplicateOrgRoleModal";
|
||||
import { RoleModal } from "@app/pages/organization/RoleByIDPage/components/RoleModal";
|
||||
|
||||
enum RolesOrderBy {
|
||||
Name = "name",
|
||||
Slug = "slug",
|
||||
Type = "type"
|
||||
}
|
||||
|
||||
export const OrgRoleTable = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentOrg } = useOrganization();
|
||||
@ -93,14 +122,89 @@ export const OrgRoleTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy,
|
||||
search,
|
||||
setSearch,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
setPage,
|
||||
offset
|
||||
} = usePagination<RolesOrderBy>(RolesOrderBy.Type, {
|
||||
initPerPage: getUserTablePreference("orgRolesTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("orgRolesTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredRoles = useMemo(
|
||||
() =>
|
||||
roles
|
||||
?.filter((role) => {
|
||||
const { slug, name } = role;
|
||||
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case RolesOrderBy.Slug:
|
||||
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
|
||||
case RolesOrderBy.Type: {
|
||||
const roleOneValue = isCustomOrgRole(roleOne.slug) ? -1 : 1;
|
||||
const roleTwoValue = isCustomOrgRole(roleTwo.slug) ? -1 : 1;
|
||||
|
||||
return roleTwoValue - roleOneValue;
|
||||
}
|
||||
case RolesOrderBy.Name:
|
||||
default:
|
||||
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
|
||||
}
|
||||
}) ?? [],
|
||||
[roles, orderDirection, search, orderBy]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredRoles.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: RolesOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: RolesOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Organization Roles</p>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
@ -113,18 +217,63 @@ export const OrgRoleTable = () => {
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search roles..."
|
||||
className="flex-1"
|
||||
containerClassName="mb-4"
|
||||
/>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(RolesOrderBy.Name)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(RolesOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Slug
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(RolesOrderBy.Slug)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(RolesOrderBy.Slug)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Type
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(RolesOrderBy.Type)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(RolesOrderBy.Type)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Type)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th aria-label="actions" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={3} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||
{filteredRoles?.slice(offset, perPage * page).map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug);
|
||||
const isDefaultOrgRole = isCustomOrgRole(slug)
|
||||
@ -162,23 +311,30 @@ export const OrgRoleTable = () => {
|
||||
<Td className="max-w-md overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{slug}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge className="w-min whitespace-nowrap bg-mineshaft-400/50 text-bunker-200">
|
||||
{isCustomProjectRole(slug) ? "Custom" : "Default"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<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 asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
@ -188,7 +344,8 @@ export const OrgRoleTable = () => {
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
|
||||
>
|
||||
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||
</DropdownMenuItem>
|
||||
@ -200,14 +357,12 @@ export const OrgRoleTable = () => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("duplicateRole", role);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faCopy} />}
|
||||
>
|
||||
Duplicate Role
|
||||
</DropdownMenuItem>
|
||||
@ -220,14 +375,12 @@ export const OrgRoleTable = () => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={!isAllowed}
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSetRoleAsDefault(slug);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faIdBadge} />}
|
||||
>
|
||||
Set as Default Role
|
||||
</DropdownMenuItem>
|
||||
@ -250,16 +403,12 @@ export const OrgRoleTable = () => {
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed && !isDefaultOrgRole
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
}}
|
||||
disabled={!isAllowed || isDefaultOrgRole}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
isDisabled={!isAllowed || isDefaultOrgRole}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
@ -276,6 +425,25 @@ export const OrgRoleTable = () => {
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredRoles?.length) && (
|
||||
<Pagination
|
||||
count={filteredRoles!.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!filteredRoles?.length && !isRolesLoading && (
|
||||
<EmptyState
|
||||
title={
|
||||
roles?.length
|
||||
? "No roles match search..."
|
||||
: "This organization does not have any roles"
|
||||
}
|
||||
icon={roles?.length ? faSearch : undefined}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
|
@ -197,7 +197,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
"flex h-[2.375rem] w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
@ -298,7 +298,8 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||
{!isMembersLoading &&
|
||||
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
|
||||
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
|
||||
const name =
|
||||
u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : null;
|
||||
const email = u?.email || inviteEmail;
|
||||
|
||||
return (
|
||||
@ -328,7 +329,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{name ?? <span className="text-mineshaft-400">Not Set</span>}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
@ -236,7 +236,7 @@ export const ProjectRoleList = () => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="project-roles" />}
|
||||
{filteredRoles?.slice(offset, perPage * page).map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
|
||||
import { Button, Checkbox, IconButton, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
@ -241,16 +241,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
/>
|
||||
</Tooltip>
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="ml-auto mr-3"
|
||||
onClick={() => remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove Rule
|
||||
</Button>
|
||||
<Tooltip content="Remove Rule">
|
||||
<IconButton
|
||||
ariaLabel="Remove rule"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
className="ml-auto mr-3 rounded"
|
||||
onClick={() => remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Tooltip position="left" content="Drag to reorder permission">
|
||||
@ -271,16 +274,19 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="mb-2">Actions</div>
|
||||
{!isDisabled && !isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={() => remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove Rule
|
||||
</Button>
|
||||
<Tooltip content="Remove Rule">
|
||||
<IconButton
|
||||
ariaLabel="Remove rule"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
className="ml-auto rounded"
|
||||
onClick={() => remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
|
||||
|
@ -439,39 +439,35 @@ export const ReviewAccessRequestModal = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isSoftEnforcement &&
|
||||
request.isRequestedByCurrentUser &&
|
||||
!(request.isApprover && request.isSelfApproveAllowed) &&
|
||||
canBypass && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setBypassApproval(checked === true)}
|
||||
isChecked={bypassApproval}
|
||||
id="byPassApproval"
|
||||
className={twMerge("mr-2", bypassApproval ? "border-red/30 bg-red/10" : "")}
|
||||
{isSoftEnforcement && request.isRequestedByCurrentUser && canBypass && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setBypassApproval(checked === true)}
|
||||
isChecked={bypassApproval}
|
||||
id="byPassApproval"
|
||||
className={twMerge("mr-2", bypassApproval ? "!border-red/30 !bg-red/10" : "")}
|
||||
>
|
||||
<span className="text-xs text-red">
|
||||
Approve without waiting for requirements to be met (bypass policy protection)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{bypassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the policy"
|
||||
>
|
||||
<span className="text-xs text-red">
|
||||
Approve without waiting for requirements to be met (bypass policy
|
||||
protection)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{bypassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the secret change policy"
|
||||
>
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.currentTarget.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.currentTarget.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
isLoading={isLoading === "approved"}
|
||||
|
@ -400,7 +400,7 @@ const Form = ({
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
className="flex-1"
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
@ -419,6 +419,20 @@ const Form = ({
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isAccessPolicyType && (
|
||||
<Controller
|
||||
control={control}
|
||||
@ -429,7 +443,7 @@ const Form = ({
|
||||
label="Min. Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
className="flex-shrink"
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
@ -443,20 +457,6 @@ const Form = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
@ -467,35 +467,36 @@ const Form = ({
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-1"
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
@ -504,11 +505,11 @@ const Form = ({
|
||||
</div>
|
||||
{isAccessPolicyType ? (
|
||||
<>
|
||||
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
|
||||
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded border border-mineshaft-600 bg-mineshaft-900 p-2">
|
||||
{sequenceApproversFieldArray.fields.map((el, index) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
|
||||
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0 shadow-inner",
|
||||
dragOverItem === index ? "border-2 border-blue-400" : "",
|
||||
draggedItem === index ? "opacity-50" : ""
|
||||
)}
|
||||
@ -567,7 +568,7 @@ const Form = ({
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
className="flex-1"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
@ -597,7 +598,7 @@ const Form = ({
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
className="flex-grow"
|
||||
className="flex-1"
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPortalTarget={modalContainer.current}
|
||||
@ -800,10 +801,15 @@ const Form = ({
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||
<Button onClick={() => onToggle(false)} colorSchema="secondary" variant="plain">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -102,43 +102,73 @@ export const SecretApprovalRequestAction = ({
|
||||
|
||||
if (!hasMerged && status === "open") {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-start justify-between py-4 transition-all">
|
||||
<div className="flex items-center space-x-4 px-4">
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full ${isMergable ? "h-10 w-10 bg-green" : "h-11 w-11 bg-red-600"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMergable ? faCheck : faXmark}
|
||||
className={isMergable ? "text-lg text-black" : "text-2xl text-white"}
|
||||
/>
|
||||
<div className="flex w-full flex-col items-start justify-between py-4 text-mineshaft-100 transition-all">
|
||||
<div className="flex w-full flex-col justify-between xl:flex-row xl:items-center">
|
||||
<div className="mr-auto flex items-center space-x-4 px-4">
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full ${isMergable ? "h-8 w-8 bg-green" : "h-10 w-10 bg-red-600"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isMergable ? faCheck : faXmark}
|
||||
className={isMergable ? "text-lg text-white" : "text-2xl text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex flex-col">
|
||||
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
|
||||
{isMergable ? "Good to merge" : "Merging is blocked"}
|
||||
</p>
|
||||
{!isMergable && (
|
||||
<span className="inline-block text-xs text-mineshaft-300">
|
||||
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
|
||||
eligible reviewers.
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-col">
|
||||
<p className={`text-md font-medium ${isMergable && "text-lg"}`}>
|
||||
{isMergable ? "Good to merge" : "Merging is blocked"}
|
||||
</p>
|
||||
{!isMergable && (
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by
|
||||
eligible reviewers.
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
<div className="mt-4 flex items-center justify-end space-x-2 px-4 xl:mt-0">
|
||||
{canApprove || isSoftEnforcement ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("close")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faClose} />}
|
||||
className="hover:border-red/60 hover:bg-red/10"
|
||||
>
|
||||
Close request
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
|
||||
isDisabled={
|
||||
!(
|
||||
(isMergable && canApprove) ||
|
||||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
|
||||
)
|
||||
}
|
||||
isLoading={isMerging}
|
||||
onClick={handleSecretApprovalRequestMerge}
|
||||
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
|
||||
variant="outline_bg"
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-mineshaft-400">Only approvers can merge</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isSoftEnforcement && !isMergable && isBypasser && (
|
||||
<div
|
||||
className={`mt-4 w-full border-mineshaft-600 px-5 ${isMergable ? "border-t pb-2" : "border-y pb-4"}`}
|
||||
>
|
||||
<div className="mt-4 w-full border-t border-mineshaft-600 px-5">
|
||||
<div className="mt-2 flex flex-col space-y-2 pt-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={twMerge(
|
||||
"mr-2",
|
||||
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
|
||||
)}
|
||||
className={twMerge("mr-2", byPassApproval ? "!border-red/30 !bg-red/10" : "")}
|
||||
>
|
||||
<span className="text-sm">
|
||||
Merge without waiting for approval (bypass secret change policy)
|
||||
@ -162,51 +192,18 @@ export const SecretApprovalRequestAction = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex w-full items-center justify-end space-x-2 px-4">
|
||||
{canApprove || isSoftEnforcement ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("close")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faClose} />}
|
||||
className="hover:border-red/60 hover:bg-red/10"
|
||||
>
|
||||
Close request
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
|
||||
isDisabled={
|
||||
!(
|
||||
(isMergable && canApprove) ||
|
||||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
|
||||
)
|
||||
}
|
||||
isLoading={isMerging}
|
||||
onClick={handleSecretApprovalRequestMerge}
|
||||
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
|
||||
variant="solid"
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>Only approvers can merge</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMerged && status === "close")
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-md border border-primary/60 bg-primary/10">
|
||||
<div className="flex items-start space-x-4 p-4">
|
||||
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
|
||||
<div className="flex w-full items-center justify-between rounded-md border border-green/60 bg-green/10">
|
||||
<div className="flex items-start space-x-2 p-4">
|
||||
<FontAwesomeIcon icon={faCheck} className="mt-0.5 text-xl text-green" />
|
||||
<span className="flex flex-col">
|
||||
Change request merged
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
<span className="inline-block text-xs text-mineshaft-300">
|
||||
Merged by {statusChangeByEmail}.
|
||||
</span>
|
||||
</span>
|
||||
@ -215,26 +212,26 @@ export const SecretApprovalRequestAction = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
|
||||
<div className="flex w-full items-center justify-between rounded-md border border-yellow/60 bg-yellow/10">
|
||||
<div className="flex items-start space-x-2 p-4">
|
||||
<FontAwesomeIcon icon={faUserLock} className="mt-0.5 text-xl text-yellow" />
|
||||
<span className="flex flex-col">
|
||||
Secret approval has been closed
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
<span className="inline-block text-xs text-mineshaft-300">
|
||||
Closed by {statusChangeByEmail}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("open")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
>
|
||||
Reopen request
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("open")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="mr-4 text-yellow/60 hover:text-yellow"
|
||||
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
>
|
||||
Reopen request
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,11 +3,12 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faCircleCheck,
|
||||
faCircleXmark,
|
||||
faExclamationTriangle,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faInfo,
|
||||
faInfoCircle,
|
||||
faKey
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@ -29,14 +30,14 @@ export type Props = {
|
||||
};
|
||||
|
||||
const generateItemTitle = (op: CommitType) => {
|
||||
let text = { label: "", color: "" };
|
||||
if (op === CommitType.CREATE) text = { label: "create", color: "#60DD00" };
|
||||
else if (op === CommitType.UPDATE) text = { label: "change", color: "#F8EB30" };
|
||||
else text = { label: "deletion", color: "#F83030" };
|
||||
let text = { label: "", className: "" };
|
||||
if (op === CommitType.CREATE) text = { label: "create", className: "text-green-600" };
|
||||
else if (op === CommitType.UPDATE) text = { label: "change", className: "text-yellow-600" };
|
||||
else text = { label: "deletion", className: "text-red-600" };
|
||||
|
||||
return (
|
||||
<div className="text-md pb-2 font-medium">
|
||||
Request for <span style={{ color: text.color }}>secret {text.label}</span>
|
||||
Request for <span className={text.className}>secret {text.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -68,15 +69,15 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<div className="flex items-center px-1 py-1">
|
||||
<div className="flex-grow">{generateItemTitle(op)}</div>
|
||||
{!hasMerged && isStale && (
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faInfo} className="text-sm text-primary-600" />
|
||||
<span className="ml-2 text-xs">Secret has been changed(stale)</span>
|
||||
<div className="flex items-center text-mineshaft-300">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-xs" />
|
||||
<span className="ml-1 text-xs">Secret has been changed (stale)</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMerged && hasConflict && (
|
||||
<div className="flex items-center space-x-2 text-sm text-bunker-300">
|
||||
<div className="flex items-center space-x-1 text-xs text-bunker-300">
|
||||
<Tooltip content="Merge Conflict">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-xs text-red" />
|
||||
</Tooltip>
|
||||
<div>{generateConflictText(op)}</div>
|
||||
</div>
|
||||
@ -95,7 +96,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Key</div>
|
||||
<div className="text-sm">{secretVersion?.secretKey} </div>
|
||||
<p className="max-w-lg break-words text-sm">{secretVersion?.secretKey}</p>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Value</div>
|
||||
@ -147,7 +148,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
|
||||
<div className="max-h-[5rem] overflow-y-auto text-sm">
|
||||
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
|
||||
{secretVersion?.secretComment || (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
@ -186,15 +187,27 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
|
||||
<div>{el.key}</div>
|
||||
<Tooltip
|
||||
className="max-w-lg whitespace-normal break-words"
|
||||
content={el.key}
|
||||
>
|
||||
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.key}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tag>
|
||||
<Tag
|
||||
size="xs"
|
||||
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
|
||||
>
|
||||
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
<Tooltip
|
||||
className="max-w-lg whitespace-normal break-words"
|
||||
content={el.value}
|
||||
>
|
||||
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
@ -215,13 +228,13 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
<div className="mb-4 flex flex-row justify-between">
|
||||
<span className="text-md font-medium">New Secret</span>
|
||||
<div className="rounded-full bg-green-600 px-2 pb-[0.14rem] pt-[0.2rem] text-xs font-medium">
|
||||
<FontAwesomeIcon icon={faCircleXmark} className="pr-1 text-white" />
|
||||
<FontAwesomeIcon icon={faCircleCheck} className="pr-1 text-white" />
|
||||
New
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Key</div>
|
||||
<div className="text-sm">{newVersion?.secretKey} </div>
|
||||
<div className="max-w-md break-words text-sm">{newVersion?.secretKey} </div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Value</div>
|
||||
@ -273,7 +286,7 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Comment</div>
|
||||
<div className="max-h-[5rem] overflow-y-auto text-sm">
|
||||
<div className="thin-scrollbar max-h-[5rem] max-w-[34rem] overflow-y-auto break-words text-sm xl:max-w-[28rem]">
|
||||
{newVersion?.secretComment || (
|
||||
<span className="text-sm text-mineshaft-300">-</span>
|
||||
)}{" "}
|
||||
@ -281,15 +294,15 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-y-2">
|
||||
{(newVersion?.tags?.length ?? 0) ? (
|
||||
newVersion?.tags?.map(({ slug, id: tagId, color }) => (
|
||||
<Tag
|
||||
className="flex w-min items-center space-x-2"
|
||||
className="flex w-min items-center space-x-1.5 border border-mineshaft-500 bg-mineshaft-800"
|
||||
key={`${newVersion.id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{slug}</div>
|
||||
@ -311,15 +324,27 @@ export const SecretApprovalRequestChangeItem = ({
|
||||
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
|
||||
<div>{el.key}</div>
|
||||
<Tooltip
|
||||
className="max-w-lg whitespace-normal break-words"
|
||||
content={el.key}
|
||||
>
|
||||
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.key}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tag>
|
||||
<Tag
|
||||
size="xs"
|
||||
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
|
||||
>
|
||||
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
<Tooltip
|
||||
className="max-w-lg whitespace-normal break-words"
|
||||
content={el.value}
|
||||
>
|
||||
<div className="max-w-[125px] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{el.value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
|
@ -3,12 +3,12 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowLeft,
|
||||
faCheckCircle,
|
||||
faCircle,
|
||||
faBan,
|
||||
faCheck,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faFolder,
|
||||
faXmarkCircle
|
||||
faHourglass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -26,6 +26,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
GenericFieldLabel,
|
||||
IconButton,
|
||||
TextArea,
|
||||
Tooltip
|
||||
@ -81,10 +82,10 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
|
||||
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||
if (status === ApprovalStatus.APPROVED)
|
||||
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
|
||||
return <FontAwesomeIcon icon={faCheck} size="xs" className="text-green" />;
|
||||
if (status === ApprovalStatus.REJECTED)
|
||||
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
|
||||
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
|
||||
return <FontAwesomeIcon icon={faBan} size="xs" className="text-red" />;
|
||||
return <FontAwesomeIcon icon={faHourglass} size="xs" className="text-yellow" />;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@ -223,8 +224,8 @@ export const SecretApprovalRequestChanges = ({
|
||||
const hasMerged = secretApprovalRequestDetails?.hasMerged;
|
||||
|
||||
return (
|
||||
<div className="flex space-x-6">
|
||||
<div className="flex-grow">
|
||||
<div className="flex flex-col space-x-6 lg:flex-row">
|
||||
<div className="flex-1 lg:max-w-[calc(100%-17rem)]">
|
||||
<div className="sticky top-0 z-20 flex items-center space-x-4 bg-bunker-800 pb-6 pt-2">
|
||||
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
@ -242,17 +243,17 @@ export const SecretApprovalRequestChanges = ({
|
||||
: secretApprovalRequestDetails.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-grow flex-col">
|
||||
<div className="-mt-0.5 flex-grow flex-col">
|
||||
<div className="text-xl">
|
||||
{generateCommitText(
|
||||
secretApprovalRequestDetails.commits,
|
||||
secretApprovalRequestDetails.isReplicated
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="-mt-1 flex items-center space-x-2 text-xs text-gray-400">
|
||||
By {secretApprovalRequestDetails?.committerUser?.firstName} (
|
||||
{secretApprovalRequestDetails?.committerUser?.email})
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
{!hasMerged &&
|
||||
secretApprovalRequestDetails.status === "open" &&
|
||||
@ -262,7 +263,10 @@ export const SecretApprovalRequestChanges = ({
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@ -279,82 +283,87 @@ export const SecretApprovalRequestChanges = ({
|
||||
{...field}
|
||||
placeholder="Leave a comment..."
|
||||
reSize="none"
|
||||
className="text-md mt-2 h-40 border border-mineshaft-600 bg-bunker-800"
|
||||
className="text-md mt-2 h-40 border border-mineshaft-600 bg-mineshaft-800 placeholder:text-mineshaft-400"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
defaultValue={ApprovalStatus.APPROVED}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl errorText={error?.message} isError={Boolean(error)}>
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="mb-4 space-y-2"
|
||||
aria-label="Status"
|
||||
<div className="flex justify-between">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
defaultValue={ApprovalStatus.APPROVED}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
id="approve"
|
||||
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
|
||||
value={ApprovalStatus.APPROVED}
|
||||
aria-labelledby="approve-label"
|
||||
>
|
||||
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
|
||||
</RadioGroupItem>
|
||||
<span
|
||||
id="approve-label"
|
||||
className="cursor-pointer"
|
||||
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
field.onChange(ApprovalStatus.APPROVED);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Approve
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
id="reject"
|
||||
className="h-4 w-4 rounded-full border border-gray-300 text-red focus:ring-2 focus:ring-mineshaft-500"
|
||||
value={ApprovalStatus.REJECTED}
|
||||
aria-labelledby="reject-label"
|
||||
>
|
||||
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
|
||||
</RadioGroupItem>
|
||||
<span
|
||||
id="reject-label"
|
||||
className="cursor-pointer"
|
||||
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
field.onChange(ApprovalStatus.REJECTED);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="space-y-2"
|
||||
aria-label="Status"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
id="approve"
|
||||
className="h-4 w-4 rounded-full border border-gray-400 text-green focus:ring-2 focus:ring-mineshaft-500"
|
||||
value={ApprovalStatus.APPROVED}
|
||||
aria-labelledby="approve-label"
|
||||
>
|
||||
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
|
||||
</RadioGroupItem>
|
||||
<span
|
||||
id="approve-label"
|
||||
className="cursor-pointer"
|
||||
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
field.onChange(ApprovalStatus.APPROVED);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Approve
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
id="reject"
|
||||
className="h-4 w-4 rounded-full border border-gray-400 text-red focus:ring-2 focus:ring-mineshaft-500"
|
||||
value={ApprovalStatus.REJECTED}
|
||||
aria-labelledby="reject-label"
|
||||
>
|
||||
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
|
||||
</RadioGroupItem>
|
||||
<span
|
||||
id="reject-label"
|
||||
className="cursor-pointer"
|
||||
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
field.onChange(ApprovalStatus.REJECTED);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Reject
|
||||
</span>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isApproving || isRejecting || isSubmitting}
|
||||
variant="outline_bg"
|
||||
className="mt-auto h-min"
|
||||
>
|
||||
Submit Review
|
||||
</Button>
|
||||
@ -371,14 +380,14 @@ export const SecretApprovalRequestChanges = ({
|
||||
<div className="text-sm text-bunker-300">
|
||||
A secret import in
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{secretApprovalRequestDetails?.environment}
|
||||
</p>
|
||||
<div className="mr-2 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={approvalSecretPath}>
|
||||
<p
|
||||
@ -391,14 +400,14 @@ export const SecretApprovalRequestChanges = ({
|
||||
</div>
|
||||
has pending changes to be accepted from its source at{" "}
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{replicatedImport?.importEnv?.slug}
|
||||
</p>
|
||||
<div className="inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={replicatedImport?.importPath}>
|
||||
<p
|
||||
@ -415,14 +424,14 @@ export const SecretApprovalRequestChanges = ({
|
||||
<div className="text-sm text-bunker-300">
|
||||
<p className="inline">Secret(s) in</p>
|
||||
<p
|
||||
className="mx-1 inline rounded bg-primary-600/40 text-primary-300"
|
||||
className="mx-1 inline rounded bg-mineshaft-600/80 text-mineshaft-300"
|
||||
style={{ padding: "2px 4px" }}
|
||||
>
|
||||
{secretApprovalRequestDetails?.environment}
|
||||
</p>
|
||||
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
<div className="mr-1 inline-flex w-min items-center rounded border border-mineshaft-500 pl-1.5 pr-2">
|
||||
<p className="cursor-default border-r border-mineshaft-500 pr-1.5">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow" size="sm" />
|
||||
</p>
|
||||
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
|
||||
<p
|
||||
@ -463,7 +472,7 @@ export const SecretApprovalRequestChanges = ({
|
||||
const reviewer = reviewedUsers?.[requiredApprover.userId];
|
||||
return (
|
||||
<div
|
||||
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4"
|
||||
className="flex w-full flex-col rounded-md bg-mineshaft-800 p-4 text-sm text-mineshaft-100"
|
||||
key={`required-approver-${requiredApprover.userId}`}
|
||||
>
|
||||
<div>
|
||||
@ -477,14 +486,16 @@ export const SecretApprovalRequestChanges = ({
|
||||
{reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"}
|
||||
</span>{" "}
|
||||
the request on{" "}
|
||||
{format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
|
||||
{format(
|
||||
new Date(secretApprovalRequestDetails.createdAt),
|
||||
"MM/dd/yyyy h:mm:ss aa"
|
||||
)}
|
||||
.
|
||||
</div>
|
||||
{reviewer?.comment && (
|
||||
<FormControl label="Comment" className="mb-0 mt-4">
|
||||
<TextArea value={reviewer.comment} isDisabled reSize="none">
|
||||
{reviewer?.comment && reviewer.comment}
|
||||
</TextArea>
|
||||
</FormControl>
|
||||
<GenericFieldLabel label="Comment" className="mt-2 max-w-4xl break-words">
|
||||
{reviewer?.comment && reviewer.comment}
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -505,7 +516,7 @@ export const SecretApprovalRequestChanges = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky top-0 w-1/5 cursor-default pt-4" style={{ minWidth: "240px" }}>
|
||||
<div className="sticky top-0 z-[51] w-1/5 cursor-default pt-2" style={{ minWidth: "240px" }}>
|
||||
<div className="text-sm text-bunker-300">Reviewers</div>
|
||||
<div className="mt-2 flex flex-col space-y-2 text-sm">
|
||||
{secretApprovalRequestDetails?.policy?.approvers
|
||||
@ -526,17 +537,17 @@ export const SecretApprovalRequestChanges = ({
|
||||
requiredApprover.lastName || ""
|
||||
}`}
|
||||
>
|
||||
<span>{requiredApprover?.email} </span>
|
||||
<span>{requiredApprover?.email}</span>
|
||||
</Tooltip>
|
||||
<span className="text-red">*</span>
|
||||
</div>
|
||||
<div>
|
||||
{reviewer?.comment && (
|
||||
<Tooltip content={reviewer.comment}>
|
||||
<Tooltip className="max-w-lg break-words" content={reviewer.comment}>
|
||||
<FontAwesomeIcon
|
||||
icon={faComment}
|
||||
size="xs"
|
||||
className="mr-1 text-mineshaft-300"
|
||||
className="mr-1.5 text-mineshaft-300"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
@ -7,8 +7,13 @@ type Props = {
|
||||
|
||||
export const OnePassSyncDestinationSection = ({ secretSync }: Props) => {
|
||||
const {
|
||||
destinationConfig: { vaultId }
|
||||
destinationConfig: { vaultId, valueLabel }
|
||||
} = secretSync;
|
||||
|
||||
return <GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>;
|
||||
return (
|
||||
<>
|
||||
<GenericFieldLabel label="Vault ID">{vaultId}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Value Key">{valueLabel || "value"}</GenericFieldLabel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -43,6 +43,7 @@ import { Route as authProviderSuccessPageRouteImport } from './pages/auth/Provid
|
||||
import { Route as authProviderErrorPageRouteImport } from './pages/auth/ProviderErrorPage/route'
|
||||
import { Route as userPersonalSettingsPageRouteImport } from './pages/user/PersonalSettingsPage/route'
|
||||
import { Route as adminIntegrationsPageRouteImport } from './pages/admin/IntegrationsPage/route'
|
||||
import { Route as adminEnvironmentPageRouteImport } from './pages/admin/EnvironmentPage/route'
|
||||
import { Route as adminEncryptionPageRouteImport } from './pages/admin/EncryptionPage/route'
|
||||
import { Route as adminCachingPageRouteImport } from './pages/admin/CachingPage/route'
|
||||
import { Route as adminAuthenticationPageRouteImport } from './pages/admin/AuthenticationPage/route'
|
||||
@ -607,6 +608,12 @@ const adminIntegrationsPageRouteRoute = adminIntegrationsPageRouteImport.update(
|
||||
} as any,
|
||||
)
|
||||
|
||||
const adminEnvironmentPageRouteRoute = adminEnvironmentPageRouteImport.update({
|
||||
id: '/environment',
|
||||
path: '/environment',
|
||||
getParentRoute: () => adminLayoutRoute,
|
||||
} as any)
|
||||
|
||||
const adminEncryptionPageRouteRoute = adminEncryptionPageRouteImport.update({
|
||||
id: '/encryption',
|
||||
path: '/encryption',
|
||||
@ -2353,6 +2360,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof adminEncryptionPageRouteImport
|
||||
parentRoute: typeof adminLayoutImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/environment': {
|
||||
id: '/_authenticate/_inject-org-details/admin/_admin-layout/environment'
|
||||
path: '/environment'
|
||||
fullPath: '/admin/environment'
|
||||
preLoaderRoute: typeof adminEnvironmentPageRouteImport
|
||||
parentRoute: typeof adminLayoutImport
|
||||
}
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/integrations': {
|
||||
id: '/_authenticate/_inject-org-details/admin/_admin-layout/integrations'
|
||||
path: '/integrations'
|
||||
@ -4484,6 +4498,7 @@ interface adminLayoutRouteChildren {
|
||||
adminAuthenticationPageRouteRoute: typeof adminAuthenticationPageRouteRoute
|
||||
adminCachingPageRouteRoute: typeof adminCachingPageRouteRoute
|
||||
adminEncryptionPageRouteRoute: typeof adminEncryptionPageRouteRoute
|
||||
adminEnvironmentPageRouteRoute: typeof adminEnvironmentPageRouteRoute
|
||||
adminIntegrationsPageRouteRoute: typeof adminIntegrationsPageRouteRoute
|
||||
adminMachineIdentitiesResourcesPageRouteRoute: typeof adminMachineIdentitiesResourcesPageRouteRoute
|
||||
adminOrganizationResourcesPageRouteRoute: typeof adminOrganizationResourcesPageRouteRoute
|
||||
@ -4495,6 +4510,7 @@ const adminLayoutRouteChildren: adminLayoutRouteChildren = {
|
||||
adminAuthenticationPageRouteRoute: adminAuthenticationPageRouteRoute,
|
||||
adminCachingPageRouteRoute: adminCachingPageRouteRoute,
|
||||
adminEncryptionPageRouteRoute: adminEncryptionPageRouteRoute,
|
||||
adminEnvironmentPageRouteRoute: adminEnvironmentPageRouteRoute,
|
||||
adminIntegrationsPageRouteRoute: adminIntegrationsPageRouteRoute,
|
||||
adminMachineIdentitiesResourcesPageRouteRoute:
|
||||
adminMachineIdentitiesResourcesPageRouteRoute,
|
||||
@ -4697,6 +4713,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
'/admin/caching': typeof adminCachingPageRouteRoute
|
||||
'/admin/encryption': typeof adminEncryptionPageRouteRoute
|
||||
'/admin/environment': typeof adminEnvironmentPageRouteRoute
|
||||
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/cert-manager/$projectId': typeof certManagerLayoutRouteWithChildren
|
||||
'/kms/$projectId': typeof kmsLayoutRouteWithChildren
|
||||
@ -4918,6 +4935,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
'/admin/caching': typeof adminCachingPageRouteRoute
|
||||
'/admin/encryption': typeof adminEncryptionPageRouteRoute
|
||||
'/admin/environment': typeof adminEnvironmentPageRouteRoute
|
||||
'/admin/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/cert-manager/$projectId': typeof certManagerLayoutRouteWithChildren
|
||||
'/kms/$projectId': typeof kmsLayoutRouteWithChildren
|
||||
@ -5139,6 +5157,7 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/authentication': typeof adminAuthenticationPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/caching': typeof adminCachingPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/encryption': typeof adminEncryptionPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/environment': typeof adminEnvironmentPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/admin/_admin-layout/integrations': typeof adminIntegrationsPageRouteRoute
|
||||
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutCertManagerProjectIdRouteWithChildren
|
||||
'/_authenticate/_inject-org-details/_org-layout/kms/$projectId': typeof AuthenticateInjectOrgDetailsOrgLayoutKmsProjectIdRouteWithChildren
|
||||
@ -5371,6 +5390,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/authentication'
|
||||
| '/admin/caching'
|
||||
| '/admin/encryption'
|
||||
| '/admin/environment'
|
||||
| '/admin/integrations'
|
||||
| '/cert-manager/$projectId'
|
||||
| '/kms/$projectId'
|
||||
@ -5591,6 +5611,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/authentication'
|
||||
| '/admin/caching'
|
||||
| '/admin/encryption'
|
||||
| '/admin/environment'
|
||||
| '/admin/integrations'
|
||||
| '/cert-manager/$projectId'
|
||||
| '/kms/$projectId'
|
||||
@ -5810,6 +5831,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/authentication'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/caching'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/encryption'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/environment'
|
||||
| '/_authenticate/_inject-org-details/admin/_admin-layout/integrations'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId'
|
||||
| '/_authenticate/_inject-org-details/_org-layout/kms/$projectId'
|
||||
@ -6267,6 +6289,7 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/authentication",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/caching",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/encryption",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/environment",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/machine-identities",
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/resources/organizations",
|
||||
@ -6309,6 +6332,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "admin/EncryptionPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/environment": {
|
||||
"filePath": "admin/EnvironmentPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
|
||||
},
|
||||
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations": {
|
||||
"filePath": "admin/IntegrationsPage/route.tsx",
|
||||
"parent": "/_authenticate/_inject-org-details/admin/_admin-layout"
|
||||
|
@ -8,6 +8,7 @@ const adminRoute = route("/admin", [
|
||||
index("admin/GeneralPage/route.tsx"),
|
||||
route("/encryption", "admin/EncryptionPage/route.tsx"),
|
||||
route("/authentication", "admin/AuthenticationPage/route.tsx"),
|
||||
route("/environment", "admin/EnvironmentPage/route.tsx"),
|
||||
route("/integrations", "admin/IntegrationsPage/route.tsx"),
|
||||
route("/caching", "admin/CachingPage/route.tsx"),
|
||||
route("/resources/organizations", "admin/OrganizationResourcesPage/route.tsx"),
|
||||
|
Reference in New Issue
Block a user