Merge remote-tracking branch 'origin/main' into ENG-3546

This commit is contained in:
Carlos Monastyrski
2025-09-02 10:04:03 -03:00
115 changed files with 3643 additions and 1057 deletions
@@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
reset();
onOpenChange(false);
navigate({
to: getProjectHomePage(project.type),
to: getProjectHomePage(project.type, project.environments),
params: { projectId: project.id }
});
} catch (err) {
+1 -1
View File
@@ -204,7 +204,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{leftIcon && (
<div
className={twMerge(
"inline-flex shrink-0 cursor-pointer items-center justify-center transition-all",
"pointer-events-none inline-flex shrink-0 items-center justify-center transition-all",
loadingToggleClass,
size === "xs" ? "mr-1" : "mr-2"
)}
@@ -10,6 +10,7 @@ type Props = {
children?: ReactNode;
icon?: IconDefinition;
iconSize?: SizeProp;
titleClassName?: string;
};
export const EmptyState = ({
@@ -17,7 +18,8 @@ export const EmptyState = ({
className,
children,
icon = faCubesStacked,
iconSize = "2x"
iconSize = "2x",
titleClassName
}: Props) => (
<div
className={twMerge(
@@ -27,7 +29,7 @@ export const EmptyState = ({
>
<FontAwesomeIcon icon={icon} size={iconSize} />
<div className="flex flex-col items-center py-4">
<div className="text-sm text-bunker-300">{title}</div>
<div className={twMerge("text-sm text-bunker-300", titleClassName)}>{title}</div>
<div>{children}</div>
</div>
</div>
@@ -67,7 +67,7 @@ export const FilterableSelect = <T,>({
}),
menuPortal: (provided) => ({
...provided,
zIndex: 9999
zIndex: 99999
})
}}
tabSelectsValue={tabSelectsValue}
@@ -6,20 +6,12 @@ import { useToggle } from "@app/hooks";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const replaceContentWithDot = (str: string) => {
let finalStr = "";
for (let i = 0; i < str.length; i += 1) {
const char = str.at(i);
finalStr += char === "\n" ? "\n" : "*";
}
return finalStr;
};
const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => {
if (isImport && !content) return "IMPORTED";
if (content === "") return "EMPTY";
if (!content) return "EMPTY";
if (!isVisible) return replaceContentWithDot(content);
if (!isVisible) return HIDDEN_SECRET_VALUE;
let skipNext = false;
const formattedContent = content.split(REGEX).flatMap((el, i) => {
+15 -17
View File
@@ -1,4 +1,4 @@
import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
import { DetailedHTMLProps, forwardRef, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { Skeleton } from "../Skeleton";
@@ -9,22 +9,20 @@ export type TableContainerProps = {
className?: string;
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export const TableContainer = ({
children,
className,
isRounded = true,
...props
}: TableContainerProps): JSX.Element => (
<div
className={twMerge(
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
isRounded && "rounded-lg",
className
)}
{...props}
>
{children}
</div>
export const TableContainer = forwardRef<HTMLDivElement, TableContainerProps>(
({ children, className, isRounded = true, ...props }, ref): JSX.Element => (
<div
ref={ref}
className={twMerge(
"relative w-full overflow-x-auto border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter",
isRounded && "rounded-lg",
className
)}
{...props}
>
{children}
</div>
)
);
// main parent table
@@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext";
export type { TOrgPermission } from "./types";
export {
OrgPermissionActions,
OrgPermissionAuditLogsActions,
OrgPermissionBillingActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
@@ -71,6 +71,10 @@ export enum OrgPermissionAppConnectionActions {
Connect = "connect"
}
export enum OrgPermissionAuditLogsActions {
Read = "read"
}
export enum OrgPermissionKmipActions {
Proxy = "proxy",
Setup = "setup"
@@ -118,7 +122,7 @@ export type OrgPermissionSet =
| [OrgPermissionBillingActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections]
| [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity]
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types";
export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
@@ -150,6 +150,10 @@ export enum ProjectPermissionSecretEventActions {
SubscribeImportMutations = "subscribe-on-import-mutations"
}
export enum ProjectPermissionAuditLogsActions {
Read = "read"
}
export enum PermissionConditionOperators {
$IN = "$in",
$ALL = "$all",
@@ -365,7 +369,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Groups]
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
| [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs]
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
+2
View File
@@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext";
export type { TOrgPermission } from "./OrgPermissionContext";
export {
OrgPermissionActions,
OrgPermissionAuditLogsActions,
OrgPermissionBillingActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
@@ -11,6 +12,7 @@ export {
export type { TProjectPermission } from "./ProjectPermissionContext";
export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
+59
View File
@@ -22,3 +22,62 @@ export const formatDateTime = ({
}
return format(date, dateFormat);
};
// Helper function to convert seconds to value and unit
export const getObjectFromSeconds = (
totalSeconds: number,
activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y">
): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => {
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60;
const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24;
const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7;
const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365;
const activeUnitsSet = activeUnits ? new Set(activeUnits) : null;
const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => {
return activeUnitsSet ? activeUnitsSet.has(unit) : true;
};
if (
isUnitActive("y") &&
totalSeconds >= SECONDS_IN_YEAR &&
totalSeconds % SECONDS_IN_YEAR === 0
) {
return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" };
}
if (
isUnitActive("w") &&
totalSeconds >= SECONDS_IN_WEEK &&
totalSeconds % SECONDS_IN_WEEK === 0
) {
return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" };
}
if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) {
return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" };
}
if (
isUnitActive("h") &&
totalSeconds >= SECONDS_IN_HOUR &&
totalSeconds % SECONDS_IN_HOUR === 0
) {
return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" };
}
if (
isUnitActive("m") &&
totalSeconds >= SECONDS_IN_MINUTE &&
totalSeconds % SECONDS_IN_MINUTE === 0
) {
return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" };
}
return {
value: totalSeconds,
unit: "s"
};
};
+6 -4
View File
@@ -1,6 +1,6 @@
import { apiRequest } from "@app/config/request";
import { createWorkspace } from "@app/hooks/api/workspace/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectType, WorkspaceEnv } from "@app/hooks/api/workspace/types";
const secretsToBeAdded = [
{
@@ -72,12 +72,14 @@ export const getProjectBaseURL = (type: ProjectType) => {
}
};
export const getProjectHomePage = (type: ProjectType) => {
// @ts-expect-error akhilmhdh: will remove this later
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const getProjectHomePage = (type: ProjectType, environments: WorkspaceEnv[]) => {
switch (type) {
case ProjectType.SecretManager:
return "/projects/secret-management/$projectId/overview";
return "/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers";
return "/projects/cert-management/$projectId/subscribers" as const;
case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const;
default:
@@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth",
[EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret",
[EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret",
[EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts",
[EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets",
[EventType.CREATE_ENVIRONMENT]: "Create environment",
[EventType.UPDATE_ENVIRONMENT]: "Update environment",
@@ -46,6 +46,7 @@ export enum EventType {
GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth",
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth",
@@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent {
};
}
interface ClearIdentityUniversalAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS;
metadata: {
identityId: string;
clientSecretId: string;
};
}
interface CreateEnvironmentEvent {
type: EventType.CREATE_ENVIRONMENT;
metadata: {
@@ -892,6 +900,7 @@ export type Event =
| CreateIdentityUniversalAuthClientSecretEvent
| GetIdentityUniversalAuthClientSecretsEvent
| RevokeIdentityUniversalAuthClientSecretEvent
| ClearIdentityUniversalAuthLockoutsEvent
| CreateEnvironmentEvent
| UpdateEnvironmentEvent
| DeleteEnvironmentEvent
+2
View File
@@ -16,6 +16,8 @@ export type TAcmeCertificateAuthority = {
};
directoryUrl: string;
accountEmail: string;
eabKid?: string;
eabHmacKey?: string;
};
};
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { apiRequest } from "@app/config/request";
import {
@@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = (
...options,
// wait for all values to be available
enabled: Boolean(projectId) && (options?.enabled ?? true),
retry: (count, error) => {
// don't retry 404s
if (error instanceof AxiosError && error.status === 404) return false;
return count <= 5;
},
queryKey: dashboardKeys.getProjectSecretsDetails({
secretPath,
search,
+1 -1
View File
@@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets" | "secretRotations"
> & {
secrets?: SecretV3RawSanitized[];
secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
@@ -18,6 +18,7 @@ import {
AddIdentityTlsCertAuthDTO,
AddIdentityTokenAuthDTO,
AddIdentityUniversalAuthDTO,
ClearIdentityUniversalAuthLockoutsDTO,
ClientSecretData,
CreateIdentityDTO,
CreateIdentityUniversalAuthClientSecretDTO,
@@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => {
const {
data: { identityUniversalAuth }
@@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => {
accessTokenTTL,
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps
accessTokenTrustedIps,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return identityUniversalAuth;
},
@@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}) => {
const {
data: { identityUniversalAuth }
@@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => {
accessTokenMaxTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return identityUniversalAuth;
},
@@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => {
});
};
export const useClearIdentityUniversalAuthLockouts = () => {
const queryClient = useQueryClient();
return useMutation<number, object, ClearIdentityUniversalAuthLockoutsDTO>({
mutationFn: async ({ identityId }) => {
const {
data: { deleted }
} = await apiRequest.post<{ deleted: number }>(
`/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts`
);
return deleted;
},
onSuccess: (_, { identityId }) => {
queryClient.invalidateQueries({
queryKey: identitiesKeys.getIdentityUniversalAuth(identityId)
});
}
});
};
export const useAddIdentityGcpAuth = () => {
const queryClient = useQueryClient();
return useMutation<IdentityGcpAuth, object, AddIdentityGcpAuthDTO>({
@@ -16,6 +16,7 @@ export type Identity = {
name: string;
hasDeleteProtection: boolean;
authMethods: IdentityAuthMethod[];
activeLockoutAuthMethods: IdentityAuthMethod[];
createdAt: string;
updatedAt: string;
isInstanceAdmin?: boolean;
@@ -113,6 +114,10 @@ export type IdentityUniversalAuth = {
accessTokenNumUsesLimit: number;
accessTokenTrustedIps: IdentityTrustedIp[];
accessTokenPeriod: number;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
};
export type AddIdentityUniversalAuthDTO = {
@@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = {
accessTokenTrustedIps: {
ipAddress: string;
}[];
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
};
export type UpdateIdentityUniversalAuthDTO = {
@@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = {
accessTokenTrustedIps?: {
ipAddress: string;
}[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
};
export type DeleteIdentityUniversalAuthDTO = {
@@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = {
clientSecretId: string;
};
export type ClearIdentityUniversalAuthLockoutsDTO = {
identityId: string;
};
export type IdentityTokenAuth = {
identityId: string;
accessTokenTTL: number;
@@ -46,18 +46,21 @@ export const useImportVault = () => {
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType
mappingType,
gatewayId
}: {
vaultAccessToken: string;
vaultNamespace?: string;
vaultUrl: string;
mappingType: string;
gatewayId?: string;
}) => {
await apiRequest.post("/api/v3/external-migration/vault/", {
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType
mappingType,
gatewayId
});
}
});
+12 -7
View File
@@ -47,7 +47,8 @@ import {
UpdateEnvironmentDTO,
UpdatePitVersionLimitDTO,
UpdateProjectDTO,
Workspace
Workspace,
WorkspaceEnv
} from "./types";
export const fetchWorkspaceById = async (workspaceId: string) => {
@@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => {
export const useCreateWsEnvironment = () => {
const queryClient = useQueryClient();
return useMutation<object, object, CreateEnvironmentDTO>({
mutationFn: ({ workspaceId, name, slug }) => {
return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, {
name,
slug
});
return useMutation<WorkspaceEnv, WorkspaceEnv, CreateEnvironmentDTO>({
mutationFn: async ({ workspaceId, name, slug }) => {
const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>(
`/api/v1/workspace/${workspaceId}/environments`,
{
name,
slug
}
);
return data.environment;
},
onSuccess: () => {
queryClient.invalidateQueries({
+25 -2
View File
@@ -1,12 +1,13 @@
import { MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react";
type Params = {
minWidth: number;
maxWidth: number;
initialWidth: number;
ref: RefObject<HTMLTableElement>;
};
export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Params) => {
export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth, ref }: Params) => {
const [colWidth, setColWidth] = useState(initialWidth);
const [isResizing, setIsResizing] = useState(false);
const startX = useRef(0);
@@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param
};
}, [isResizing, handleMouseMove, handleMouseUp]);
useEffect(() => {
const element = ref?.current;
if (!element) return;
const handleResize = () => {
if (colWidth > maxWidth) {
setColWidth(Math.max(maxWidth, minWidth));
} else if (ref.current?.clientWidth && colWidth > ref.current.clientWidth * 0.9) {
// this else is a fallback to ensure col is always visible
setColWidth(initialWidth);
}
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(element);
// eslint-disable-next-line consistent-return
return () => {
resizeObserver.disconnect();
};
}, [ref, maxWidth, colWidth]);
return {
colWidth,
handleMouseDown,
@@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr
const getEnvSecretKeyCount = useCallback(
(env: string) => {
return secrets?.filter((secret) => secret.env === env).length ?? 0;
return (
secrets?.filter((secret) =>
secret.sourceEnv ? secret.sourceEnv === env : secret.env === env
).length ?? 0
);
},
[secrets]
);
@@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => {
},
{
onSuccess: () => {
const url = getProjectHomePage(currentWorkspace.type);
const url = getProjectHomePage(
currentWorkspace.type,
currentWorkspace.environments
);
window.location.href = url.replace("$projectId", currentWorkspace.id);
}
}
@@ -101,7 +101,7 @@ export const ProjectSelect = () => {
<div className="-mr-2 flex w-full items-center gap-1">
<DropdownMenu modal={false}>
<Link
to={getProjectHomePage(currentWorkspace.type)}
to={getProjectHomePage(currentWorkspace.type, currentWorkspace.environments)}
params={{
projectId: currentWorkspace.id
}}
@@ -158,7 +158,7 @@ export const ProjectSelect = () => {
// to reproduce change this back to router.push and switch between two projects with different env count
// look into this on dashboard revamp
const url = linkOptions({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -11,7 +11,7 @@ import {
faVault
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, useLocation } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
@@ -31,6 +31,7 @@ export const SecretManagerLayout = () => {
const { t } = useTranslation();
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
const location = useLocation();
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
workspaceId
@@ -73,11 +74,21 @@ export const SecretManagerLayout = () => {
<Link
to="/projects/secret-management/$projectId/overview"
params={{
projectId: currentWorkspace.id
projectId: currentWorkspace.id,
...(currentWorkspace.environments.length
? { envSlug: currentWorkspace.environments[0]?.slug }
: {})
}}
>
{({ isActive }) => (
<MenuItem isSelected={isActive}>
<MenuItem
isSelected={
isActive ||
location.pathname.startsWith(
`/projects/secret-management/${currentWorkspace.id}/overview`
)
}
>
<div className="mx-1 flex gap-2">
<div className="w-6">
<FontAwesomeIcon icon={faVault} />
@@ -42,6 +42,17 @@ import {
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
const REQUIRED_EAB_DIRECTORIES = [
"https://acme.digicert.com/v2/acme/directory",
"https://acme.zerossl.com/v2/DV90",
"https://acme.ssl.com/sslcom-dv-rsa",
"https://acme.ssl.com/sslcom-dv-ecc",
"https://dv.acme-v02.api.pki.goog/directory",
"https://acme.sectigo.com/v2/OV",
"https://acme.sectigo.com/v2/EV",
"https://acme.cisco.com/ACMEv2/directory"
];
const baseSchema = z.object({
type: z.nativeEnum(CaType),
name: slugSchema({
@@ -51,18 +62,39 @@ const baseSchema = z.object({
status: z.nativeEnum(CaStatus)
});
const acmeConfigurationSchema = z.object({
dnsAppConnection: z.object({
id: z.string(),
name: z.string()
}),
dnsProviderConfig: z.object({
provider: z.nativeEnum(AcmeDnsProvider),
hostedZoneId: z.string()
}),
directoryUrl: z.string(),
accountEmail: z.string()
});
const acmeConfigurationSchema = z
.object({
dnsAppConnection: z.object({
id: z.string(),
name: z.string()
}),
dnsProviderConfig: z.object({
provider: z.nativeEnum(AcmeDnsProvider),
hostedZoneId: z.string()
}),
directoryUrl: z.string(),
accountEmail: z.string(),
eabKid: z.string().optional(),
eabHmacKey: z.string().optional()
})
.superRefine((data, ctx) => {
if (REQUIRED_EAB_DIRECTORIES.includes(data.directoryUrl)) {
if (!data.eabKid || data.eabKid.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "EAB Key Identifier (KID) is required for this directory URL",
path: ["eabKid"]
});
}
if (!data.eabHmacKey || data.eabHmacKey.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "EAB HMAC Key is required for this directory URL",
path: ["eabHmacKey"]
});
}
}
});
const azureAdCsConfigurationSchema = z.object({
azureAdcsConnection: z.object({
@@ -122,6 +154,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration
? configuration.dnsProviderConfig.provider
: undefined;
const directoryUrl =
caType === CaType.ACME && configuration && "directoryUrl" in configuration
? configuration.directoryUrl
: undefined;
useEffect(() => {
const initialType = (popUp?.ca?.data as { type: CaType })?.type;
@@ -155,7 +191,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
hostedZoneId: ""
},
directoryUrl: "",
accountEmail: ""
accountEmail: "",
eabKid: "",
eabHmacKey: ""
}
});
}
@@ -178,13 +216,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
});
const availableConnections: TAvailableAppConnection[] = useMemo(() => {
if (caType === CaType.ACME) {
return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])];
}
if (caType === CaType.AZURE_AD_CS) {
return availableAzureConnections || [];
}
return [];
return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])];
}, [
caType,
availableRoute53Connections,
@@ -192,7 +227,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
availableAzureConnections
]);
const isPending = isRoute53Pending || isCloudflarePending || isAzurePending;
const isPending =
isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS);
const dnsAppConnection =
caType === CaType.ACME && configuration && "dnsAppConnection" in configuration
@@ -227,7 +263,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId
},
directoryUrl: ca.configuration.directoryUrl,
accountEmail: ca.configuration.accountEmail
accountEmail: ca.configuration.accountEmail,
eabKid: ca.configuration.eabKid,
eabHmacKey: ca.configuration.eabHmacKey
}
});
} else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) {
@@ -268,7 +306,9 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
dnsProviderConfig: formConfiguration.dnsProviderConfig,
directoryUrl: formConfiguration.directoryUrl,
accountEmail: formConfiguration.accountEmail,
dnsAppConnectionId: formConfiguration.dnsAppConnection.id
dnsAppConnectionId: formConfiguration.dnsAppConnection.id,
eabKid: formConfiguration.eabKid,
eabHmacKey: formConfiguration.eabHmacKey
};
} else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) {
configPayload = {
@@ -499,6 +539,44 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.eabKid"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EAB Key Identifier (KID)"
isError={Boolean(error)}
errorText={error?.message}
isOptional={!REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
isRequired={REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
>
<Input
{...field}
placeholder="abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.eabHmacKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="EAB HMAC Key"
isError={Boolean(error)}
errorText={error?.message}
isOptional={!REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
isRequired={REQUIRED_EAB_DIRECTORIES.includes(directoryUrl || "")}
>
<Input
{...field}
placeholder="dGhpc2lzYW5leGFtcGxlaG1hY2tleWZvcmRpZ2ljZXJ0YWNtZXRlc3RpbmcxMjM0NTY3ODkw"
/>
</FormControl>
)}
/>
</>
)}
{caType === CaType.AZURE_AD_CS && (
@@ -148,7 +148,11 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
accessTokenTTL: 2592000,
accessTokenMaxTTL: 2592000,
accessTokenNumUsesLimit: 0,
accessTokenPeriod: 0
accessTokenPeriod: 0,
lockoutEnabled: true,
lockoutThreshold: 3,
lockoutDurationSeconds: 300,
lockoutCounterResetSeconds: 30
});
handlePopUpToggle("identity", false);
@@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -11,12 +12,16 @@ import {
FormControl,
IconButton,
Input,
Select,
SelectItem,
Switch,
Tab,
TabList,
TabPanel,
Tabs
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import {
useAddIdentityUniversalAuth,
useGetIdentityUniversalAuth,
@@ -60,9 +65,79 @@ const schema = z
ipAddress: z.string().max(50)
})
.array()
.min(1)
.min(1),
lockoutEnabled: z.boolean().default(true),
lockoutThreshold: z
.string()
.refine(
(value) => Number(value) <= 30 && Number(value) >= 1,
"Lockout threshold must be between 1 and 30"
),
lockoutDurationValue: z.string(),
lockoutDurationUnit: z.enum(["s", "m", "h", "d"], {
invalid_type_error: "Please select a valid time unit"
}),
lockoutCounterResetValue: z.string(),
lockoutCounterResetUnit: z.enum(["s", "m", "h"], {
invalid_type_error: "Please select a valid time unit"
})
})
.required();
.required()
.superRefine((data, ctx) => {
const {
lockoutDurationValue,
lockoutCounterResetValue,
lockoutDurationUnit,
lockoutCounterResetUnit,
lockoutEnabled
} = data;
if (!lockoutEnabled) return;
let isAnyParseError = false;
const parsedLockoutDuration = parseInt(lockoutDurationValue, 10);
if (Number.isNaN(parsedLockoutDuration)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be a number",
path: ["lockoutDurationValue"]
});
isAnyParseError = true;
}
const parsedLockoutCounterReset = parseInt(lockoutCounterResetValue, 10);
if (Number.isNaN(parsedLockoutCounterReset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be a number",
path: ["lockoutCounterResetValue"]
});
isAnyParseError = true;
}
if (isAnyParseError) return;
const lockoutDurationInSeconds = ms(`${parsedLockoutDuration}${lockoutDurationUnit}`) / 1000;
const lockoutCounterResetInSeconds =
ms(`${parsedLockoutCounterReset}${lockoutCounterResetUnit}`) / 1000;
if (lockoutDurationInSeconds > 86400 || lockoutDurationInSeconds < 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout duration must be between 30 seconds and 1 day",
path: ["lockoutDurationValue"]
});
}
if (lockoutCounterResetInSeconds > 3600 || lockoutCounterResetInSeconds < 5) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Lockout counter reset must be between 5 seconds and 1 hour",
path: ["lockoutCounterResetValue"]
});
}
});
export type FormData = z.infer<typeof schema>;
@@ -107,12 +182,25 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenPeriod: "0"
accessTokenPeriod: "0",
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
}
});
const accessTokenPeriodValue = Number(watch("accessTokenPeriod"));
const lockoutEnabledWatch = watch("lockoutEnabled");
const lockoutThresholdWatch = watch("lockoutThreshold");
const lockoutDurationValueWatch = watch("lockoutDurationValue");
const lockoutDurationUnitWatch = watch("lockoutDurationUnit");
const lockoutCounterResetValueWatch = watch("lockoutCounterResetValue");
const lockoutCounterResetUnitWatch = watch("lockoutCounterResetUnit");
const {
fields: clientSecretTrustedIpsFields,
append: appendClientSecretTrustedIp,
@@ -126,6 +214,9 @@ export const IdentityUniversalAuthForm = ({
useEffect(() => {
if (data) {
const lockoutDurationObj = getObjectFromSeconds(data.lockoutDurationSeconds);
const lockoutCounterResetObj = getObjectFromSeconds(data.lockoutCounterResetSeconds);
reset({
accessTokenTTL: String(data.accessTokenTTL),
accessTokenMaxTTL: String(data.accessTokenMaxTTL),
@@ -144,7 +235,13 @@ export const IdentityUniversalAuthForm = ({
ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`
};
}
)
),
lockoutEnabled: data.lockoutEnabled,
lockoutThreshold: String(data.lockoutThreshold),
lockoutDurationValue: String(lockoutDurationObj.value),
lockoutDurationUnit: lockoutDurationObj.unit as "s" | "m" | "h" | "d",
lockoutCounterResetValue: String(lockoutCounterResetObj.value),
lockoutCounterResetUnit: lockoutCounterResetObj.unit as "s" | "m" | "h"
});
} else {
reset({
@@ -153,7 +250,13 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit: "0",
accessTokenPeriod: "0",
clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]
accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }],
lockoutEnabled: true,
lockoutThreshold: "3",
lockoutDurationValue: "5",
lockoutDurationUnit: "m",
lockoutCounterResetValue: "30",
lockoutCounterResetUnit: "s"
});
}
}, [data]);
@@ -164,11 +267,21 @@ export const IdentityUniversalAuthForm = ({
accessTokenNumUsesLimit,
clientSecretTrustedIps,
accessTokenTrustedIps,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationValue,
lockoutDurationUnit,
lockoutCounterResetValue,
lockoutCounterResetUnit
}: FormData) => {
try {
if (!identityId) return;
const lockoutDurationSeconds = ms(`${lockoutDurationValue}${lockoutDurationUnit}`) / 1000;
const lockoutCounterResetSeconds =
ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000;
if (data) {
// update universal auth configuration
await updateMutateAsync({
@@ -179,7 +292,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
} else {
// create new universal auth configuration
@@ -192,7 +309,11 @@ export const IdentityUniversalAuthForm = ({
accessTokenMaxTTL: Number(accessTokenMaxTTL),
accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit),
accessTokenTrustedIps,
accessTokenPeriod: Number(accessTokenPeriod)
accessTokenPeriod: Number(accessTokenPeriod),
lockoutEnabled,
lockoutThreshold: Number(lockoutThreshold),
lockoutDurationSeconds: Number(lockoutDurationSeconds),
lockoutCounterResetSeconds: Number(lockoutCounterResetSeconds)
});
}
@@ -217,16 +338,31 @@ export const IdentityUniversalAuthForm = ({
return (
<form
onSubmit={handleSubmit(onFormSubmit, (fields) => {
setTabValue(
["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(Object.keys(fields)[0])
? IdentityFormTab.Advanced
: IdentityFormTab.Configuration
);
const firstErrorField = Object.keys(fields)[0];
let tab = IdentityFormTab.Configuration;
if (["accessTokenTrustedIps", "clientSecretTrustedIps"].includes(firstErrorField)) {
tab = IdentityFormTab.Advanced;
} else if (
[
"lockoutEnabled",
"lockoutThreshold",
"lockoutDurationValue",
"lockoutDurationUnit",
"lockoutCounterResetValue",
"lockoutCounterResetUnit"
].includes(firstErrorField)
) {
tab = IdentityFormTab.Lockout;
}
setTabValue(tab);
})}
>
<Tabs value={tabValue} onValueChange={(value) => setTabValue(value as IdentityFormTab)}>
<TabList>
<Tab value={IdentityFormTab.Configuration}>Configuration</Tab>
<Tab value={IdentityFormTab.Lockout}>Lockout</Tab>
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
@@ -296,6 +432,187 @@ export const IdentityUniversalAuthForm = ({
)}
/>
</TabPanel>
<TabPanel value={IdentityFormTab.Lockout}>
<div className="mb-3 flex flex-col">
<Controller
control={control}
name="lockoutEnabled"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={`The lockout feature will prevent login attempts for ${lockoutDurationValueWatch}${lockoutDurationUnitWatch} after ${lockoutThresholdWatch} consecutive login failures. If ${lockoutCounterResetValueWatch}${lockoutCounterResetUnitWatch} pass after the most recent failure, the lockout counter resets.`}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="ml-0 mr-3 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="lockout-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
Lockout
</Switch>
</FormControl>
);
}}
/>
<div className="flex flex-col gap-2">
<Controller
control={control}
name="lockoutThreshold"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Threshold"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="The amount of times login must fail before locking the identity auth method"
>
<Input
{...field}
placeholder="Enter lockout threshold..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutDurationValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Duration"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long an identity auth method lockout lasts"
>
<Input
{...field}
placeholder="Enter lockout duration..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutDurationUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
<SelectItem
value="d"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Days</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex items-end gap-2">
<Controller
control={control}
name="lockoutCounterResetValue"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
className={`mb-0 flex-grow ${lockoutEnabledWatch ? "" : "opacity-70"}`}
label="Lockout Counter Reset"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="How long to wait from the most recent failed login until resetting the lockout counter"
>
<Input
{...field}
placeholder="Enter lockout counter reset..."
isDisabled={!lockoutEnabledWatch}
/>
</FormControl>
);
}}
/>
<Controller
control={control}
name="lockoutCounterResetUnit"
render={({ field, fieldState: { error } }) => (
<FormControl
className={`mb-0 ${lockoutEnabledWatch ? "" : "opacity-70"}`}
isError={Boolean(error)}
errorText={error?.message}
>
<Select
isDisabled={!lockoutEnabledWatch}
value={field.value}
className="min-w-32 pr-2"
onValueChange={field.onChange}
position="popper"
>
<SelectItem
value="s"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Seconds</div>
</SelectItem>
<SelectItem
value="m"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Minutes</div>
</SelectItem>
<SelectItem
value="h"
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">Hours</div>
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</div>
</div>
</TabPanel>
<TabPanel value={IdentityFormTab.Advanced}>
{clientSecretTrustedIpsFields.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
@@ -1,4 +1,5 @@
export enum IdentityFormTab {
Advanced = "advanced",
Lockout = "lockout",
Configuration = "configuration"
}
@@ -4,9 +4,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import {
OrgPermissionAuditLogsActions,
OrgPermissionSubjects,
ProjectPermissionAuditLogsActions,
ProjectPermissionSub,
useSubscription
} from "@app/context";
import { Timezone } from "@app/helpers/datetime";
import { withPermission } from "@app/hoc";
import { withPermission, withProjectPermission } from "@app/hoc";
import { Workspace } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -28,153 +34,173 @@ type Props = {
project?: Workspace;
};
export const LogsSection = withPermission(
({ presets, refetchInterval, showFilters = true, pageView = false, project }: Props) => {
const { subscription } = useSubscription();
const LogsSectionComponent = ({
presets,
refetchInterval,
showFilters = true,
pageView = false,
project
}: Props) => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const [logFilter, setLogFilter] = useState<TAuditLogFilterFormData>({
eventType: presets?.eventType || [],
actor: presets?.actorId,
eventMetadata: presets?.eventMetadata
});
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const [logFilter, setLogFilter] = useState<TAuditLogFilterFormData>({
eventType: presets?.eventType || [],
actor: presets?.actorId,
eventMetadata: presets?.eventMetadata
});
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
presets?.endDate || presets?.startDate
? {
type: AuditLogDateFilterType.Absolute,
startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")),
endDate: presets?.endDate || new Date()
}
: {
startDate: new Date(Number(new Date()) - ms("1h")),
endDate: new Date(),
type: AuditLogDateFilterType.Relative,
relativeModeValue: "1h"
}
);
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
presets?.endDate || presets?.startDate
? {
type: AuditLogDateFilterType.Absolute,
startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")),
endDate: presets?.endDate || new Date()
}
: {
startDate: new Date(Number(new Date()) - ms("1h")),
endDate: new Date(),
type: AuditLogDateFilterType.Relative,
relativeModeValue: "1h"
}
);
useEffect(() => {
if (subscription && !subscription.auditLogs) {
handlePopUpOpen("upgradePlan");
}
}, [subscription]);
if (pageView)
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-1 whitespace-nowrap">
<p className="text-xl font-semibold text-mineshaft-100">Audit History</p>
<a
href="https://infisical.com/docs/documentation/platform/audit-logs"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.1rem] 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>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter
project={project}
presets={presets}
setFilter={setLogFilter}
filter={logFilter}
/>
)}
</div>
</div>
<div className="space-y-2">
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
</div>
);
useEffect(() => {
if (subscription && !subscription.auditLogs) {
handlePopUpOpen("upgradePlan");
}
}, [subscription]);
if (pageView)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter presets={presets} setFilter={setLogFilter} filter={logFilter} />
)}
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-y-2">
<div>
<div className="flex items-center gap-1 whitespace-nowrap">
<p className="text-xl font-semibold text-mineshaft-100">Audit History</p>
<a
href="https://infisical.com/docs/documentation/platform/audit-logs"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.1rem] 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>
</div>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter
project={project}
presets={presets}
setFilter={setLogFilter}
filter={logFilter}
/>
)}
</div>
</div>
<div className="space-y-2">
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs }
);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{showFilters && (
<LogsDateFilter
filter={dateFilter}
setFilter={setDateFilter}
timezone={timezone}
setTimezone={setTimezone}
/>
)}
{showFilters && (
<LogsFilter presets={presets} setFilter={setLogFilter} filter={logFilter} />
)}
</div>
<LogsTable
refetchInterval={refetchInterval}
filter={{
secretPath: logFilter.secretPath || undefined,
secretKey: logFilter.secretKey || undefined,
eventMetadata: logFilter?.eventMetadata,
projectId: project?.id || logFilter?.project?.id,
actorType: presets?.actorType,
limit: 15,
eventType: logFilter?.eventType,
userAgentType: logFilter?.userAgentType,
startDate: dateFilter?.startDate,
endDate: dateFilter?.endDate,
environment: logFilter?.environment?.slug,
actor: logFilter?.actor
}}
timezone={timezone}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", isOpen);
}}
text="You can use audit logs if you switch to a paid Infisical plan."
/>
</div>
);
};
export const LogsSection = (props: Props) => {
const { project } = props;
if (project) {
const ProjectLogsSectionWithPermission = withProjectPermission(LogsSectionComponent, {
action: ProjectPermissionAuditLogsActions.Read,
subject: ProjectPermissionSub.AuditLogs
});
return <ProjectLogsSectionWithPermission {...props} />;
}
const OrgLogsSectionWithPermission = withPermission(LogsSectionComponent, {
action: OrgPermissionAuditLogsActions.Read,
subject: OrgPermissionSubjects.AuditLogs
});
return <OrgLogsSectionWithPermission {...props} />;
};
@@ -115,8 +115,10 @@ const Page = () => {
<ViewIdentityAuthModal
isOpen={popUp.viewAuthMethod.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("viewAuthMethod", isOpen)}
authMethod={popUp.viewAuthMethod.data}
authMethod={popUp.viewAuthMethod.data?.authMethod}
lockedOut={popUp.viewAuthMethod.data?.lockedOut || false}
identityId={identityId}
onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity}
/>
</div>
);
@@ -1,8 +1,8 @@
import { faCog, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faCog, faLock, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { Button, Tooltip } from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { IdentityAuthMethod, identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -16,7 +16,7 @@ type Props = {
};
export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: Props) => {
const { data } = useGetIdentityById(identityId);
const { data, refetch } = useGetIdentityById(identityId);
return data ? (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
@@ -28,12 +28,25 @@ export const IdentityAuthenticationSection = ({ identityId, handlePopUpOpen }: P
{data.identity.authMethods.map((authMethod) => (
<button
key={authMethod}
onClick={() => handlePopUpOpen("viewAuthMethod", authMethod)}
onClick={() =>
handlePopUpOpen("viewAuthMethod", {
authMethod,
lockedOut: data.identity.activeLockoutAuthMethods.includes(authMethod),
refetchIdentity: refetch
})
}
type="button"
className="flex w-full items-center justify-between bg-mineshaft-900 px-4 py-2 text-sm hover:bg-mineshaft-700 data-[state=open]:bg-mineshaft-600"
>
<span>{identityAuthToNameMap[authMethod]}</span>
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
<div className="flex gap-2">
{data.identity.activeLockoutAuthMethods.includes(authMethod) && (
<Tooltip content="Auth method has active lockouts">
<FontAwesomeIcon icon={faLock} size="xs" className="text-red-400/50" />
</Tooltip>
)}
<FontAwesomeIcon icon={faCog} size="xs" className="text-mineshaft-400" />
</div>
</button>
))}
</div>
@@ -37,9 +37,11 @@ import { ViewIdentityUniversalAuthContent } from "./ViewIdentityUniversalAuthCon
type Props = {
identityId: string;
authMethod?: IdentityAuthMethod;
lockedOut: boolean;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onDeleteAuthMethod: () => void;
onResetAllLockouts: () => void;
};
type TRevokeOptions = {
@@ -50,8 +52,13 @@ type TRevokeOptions = {
export const Content = ({
identityId,
authMethod,
onDeleteAuthMethod
}: Pick<Props, "authMethod" | "identityId" | "onDeleteAuthMethod">) => {
lockedOut,
onDeleteAuthMethod,
onResetAllLockouts
}: Pick<
Props,
"authMethod" | "lockedOut" | "identityId" | "onDeleteAuthMethod" | "onResetAllLockouts"
>) => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
@@ -159,9 +166,11 @@ export const Content = ({
<Component
identityId={identityId}
onDelete={handleDelete}
onResetAllLockouts={onResetAllLockouts}
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
lockedOut={lockedOut}
/>
<DeleteActionModal
isOpen={popUp?.revokeAuthMethod?.isOpen}
@@ -184,7 +193,9 @@ export const ViewIdentityAuthModal = ({
isOpen,
onOpenChange,
authMethod,
identityId
identityId,
lockedOut,
onResetAllLockouts
}: Omit<Props, "onDeleteAuthMethod">) => {
if (!identityId || !authMethod) return null;
@@ -194,7 +205,9 @@ export const ViewIdentityAuthModal = ({
<Content
identityId={identityId}
authMethod={authMethod}
lockedOut={lockedOut}
onDeleteAuthMethod={() => onOpenChange(false)}
onResetAllLockouts={() => onResetAllLockouts()}
/>
</ModalContent>
</Modal>
@@ -1,9 +1,15 @@
import { useState } from "react";
import { faBan, faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, EmptyState, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context";
import { useTimedReset } from "@app/hooks";
import {
useClearIdentityUniversalAuthLockouts,
useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets
} from "@app/hooks/api";
@@ -19,16 +25,40 @@ export const ViewIdentityUniversalAuthContent = ({
handlePopUpToggle,
handlePopUpOpen,
onDelete,
popUp
popUp,
lockedOut,
onResetAllLockouts
}: ViewAuthMethodProps) => {
const { data, isPending } = useGetIdentityUniversalAuth(identityId);
const { data: clientSecrets = [], isPending: clientSecretsPending } =
useGetIdentityUniversalAuthClientSecrets(identityId);
const { mutateAsync: clearLockoutsFn, isPending: isClearLockoutsPending } =
useClearIdentityUniversalAuthLockouts();
const [lockedOutState, setLockedOutState] = useState(lockedOut);
const [copyTextClientId, isCopyingClientId, setCopyTextClientId] = useTimedReset<string>({
initialState: "Copy Client ID to clipboard"
});
async function clearLockouts() {
try {
const deleted = await clearLockoutsFn({ identityId });
createNotification({
text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`,
type: "success"
});
setLockedOutState(false);
onResetAllLockouts();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to clear lockouts. Please try again.",
type: "error"
});
}
}
if (isPending || clientSecretsPending) {
return (
<div className="flex w-full items-center justify-center">
@@ -85,6 +115,41 @@ export const ViewIdentityUniversalAuthContent = ({
<IdentityAuthFieldDisplay label="Client Secret Trusted IPs">
{data.clientSecretTrustedIps.map((ip) => ip.ipAddress).join(", ")}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout">
{data.lockoutEnabled ? "Enabled" : "Disabled"}
</IdentityAuthFieldDisplay>
{data.lockoutEnabled && (
<>
<div className="col-span-2 mt-3 flex justify-between border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Lockout Options</span>
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed || !lockedOutState || isClearLockoutsPending}
size="xs"
onClick={() => clearLockouts()}
isLoading={isClearLockoutsPending}
colorSchema="secondary"
>
Reset All Lockouts
</Button>
)}
</OrgPermissionCan>
</div>
<IdentityAuthFieldDisplay label="Lockout Threshold">
{data.lockoutThreshold}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Duration">
{ms(data.lockoutDurationSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
<IdentityAuthFieldDisplay label="Lockout Counter Reset">
{ms(data.lockoutCounterResetSeconds * 1000, { long: true })}
</IdentityAuthFieldDisplay>
</>
)}
<div className="col-span-2 my-3">
<div className="mb-3 border-b border-mineshaft-500 pb-2">
<span className="text-bunker-300">Client ID</span>
@@ -9,4 +9,6 @@ export type ViewAuthMethodProps = {
state?: boolean
) => void;
popUp: UsePopUpState<["revokeAuthMethod", "upgradePlan", "identityAuthMethod"]>;
lockedOut: boolean;
onResetAllLockouts: () => void;
};
@@ -48,7 +48,7 @@ import {
useRequestProjectAccess,
useSearchProjects
} from "@app/hooks/api";
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types";
import {
ProjectListToggle,
ProjectListView
@@ -152,13 +152,17 @@ export const AllProjectView = ({
type: projectTypeFilter
});
const handleAccessProject = async (type: ProjectType, projectId: string) => {
const handleAccessProject = async (
type: ProjectType,
projectId: string,
environments: WorkspaceEnv[]
) => {
try {
await orgAdminAccessProject.mutateAsync({
projectId
});
await navigate({
to: getProjectHomePage(type),
to: getProjectHomePage(type, environments),
params: {
projectId
}
@@ -315,7 +319,7 @@ export const AllProjectView = ({
onKeyDown={(evt) => {
if (evt.key === "Enter" && workspace.isMember) {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -325,7 +329,7 @@ export const AllProjectView = ({
onClick={() => {
if (workspace.isMember) {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -371,7 +375,7 @@ export const AllProjectView = ({
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAccessProject(workspace.type, workspace.id);
handleAccessProject(workspace.type, workspace.id, workspace.environments);
}}
disabled={
orgAdminAccessProject.variables?.projectId === workspace.id &&
@@ -193,7 +193,7 @@ export const MyProjectView = ({
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -247,7 +247,7 @@ export const MyProjectView = ({
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace.type),
to: getProjectHomePage(workspace.type, workspace.environments),
params: {
projectId: workspace.id
}
@@ -5,6 +5,7 @@ import { OrgPermissionSubjects } from "@app/context";
import {
OrgGatewayPermissionActions,
OrgPermissionAppConnectionActions,
OrgPermissionAuditLogsActions,
OrgPermissionBillingActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
@@ -23,6 +24,12 @@ const generalPermissionSchema = z
})
.optional();
const auditLogsPermissionSchema = z
.object({
[OrgPermissionAuditLogsActions.Read]: z.boolean().optional()
})
.optional();
const billingPermissionSchema = z
.object({
[OrgPermissionBillingActions.Read]: z.boolean().optional(),
@@ -121,7 +128,7 @@ export const formSchema = z.object({
})
.optional(),
"audit-logs": generalPermissionSchema,
"audit-logs": auditLogsPermissionSchema,
member: generalPermissionSchema,
groups: groupPermissionSchema,
role: generalPermissionSchema,
@@ -0,0 +1,145 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgPermissionAuditLogsActions } from "@app/context/OrgPermissionContext/types";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "../OrgRoleModifySection.utils";
type Props = {
isEditable: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
Custom = "custom"
}
const PERMISSION_ACTIONS = [
{
action: OrgPermissionAuditLogsActions.Read,
label: "Read"
}
] as const;
export const OrgPermissionAuditLogsRow = ({ isEditable, control, setValue }: Props) => {
const [isRowExpanded, setIsRowExpanded] = useToggle();
const [isCustom, setIsCustom] = useToggle();
const rule = useWatch({
control,
name: "permissions.audit-logs"
});
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
useEffect(() => {
const isRowCustom = selectedPermissionCategory === Permission.Custom;
if (isRowCustom) {
setIsRowExpanded.on();
}
}, []);
const handlePermissionChange = (val: Permission) => {
if (!val) return;
if (val === Permission.Custom) {
setIsRowExpanded.on();
setIsCustom.on();
return;
}
setIsCustom.off();
switch (val) {
case Permission.NoAccess:
default:
setValue(
"permissions.audit-logs",
{
[OrgPermissionAuditLogsActions.Read]: false
},
{ shouldDirty: true }
);
}
};
return (
<>
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() => setIsRowExpanded.toggle()}
>
<Td className="w-4">
<FontAwesomeIcon className="w-4" icon={isRowExpanded ? faChevronDown : faChevronRight} />
</Td>
<Td className="w-full select-none">Audit Logs</Td>
<Td>
<Select
value={selectedPermissionCategory}
className="h-8 w-40 bg-mineshaft-700"
dropdownContainerClassName="border text-left border-mineshaft-600 bg-mineshaft-800"
onValueChange={handlePermissionChange}
isDisabled={!isEditable}
position="popper"
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</Td>
</Tr>
{isRowExpanded && (
<Tr>
<Td colSpan={3} className="border-mineshaft-500 bg-mineshaft-900 p-8">
<div className="flex flex-grow flex-wrap justify-start gap-x-8 gap-y-4">
{PERMISSION_ACTIONS.map(({ action, label }) => {
return (
<Controller
name={`permissions.audit-logs.${action}`}
key={`permissions.audit-logs.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={Boolean(field.value)}
onCheckedChange={(e) => {
if (!isEditable) {
createNotification({
type: "error",
text: "Failed to update default role"
});
return;
}
field.onChange(e);
}}
id={`permissions.audit-logs.${action}`}
>
{label}
</Checkbox>
)}
/>
);
})}
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -71,6 +71,7 @@ type Props = {
| "gateway"
| "secret-share"
| "billing"
| "audit-logs"
| "machine-identity-auth-template"
>;
setValue: UseFormSetValue<TFormSchema>;
@@ -16,6 +16,7 @@ import {
TFormSchema
} from "../OrgRoleModifySection.utils";
import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow";
import { OrgPermissionAuditLogsRow } from "./OrgPermissionAuditLogsRow";
import { OrgPermissionBillingRow } from "./OrgPermissionBillingRow";
import { OrgGatewayPermissionRow } from "./OrgPermissionGatewayRow";
import { OrgPermissionGroupRow } from "./OrgPermissionGroupRow";
@@ -39,10 +40,6 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "Incident Contacts",
formName: "incident-contact"
},
{
title: "Audit Logs",
formName: "audit-logs"
},
{
title: "Organization Profile",
formName: "settings"
@@ -166,6 +163,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => {
/>
);
})}
<OrgPermissionAuditLogsRow
control={control}
setValue={setValue}
isEditable={isCustomRole}
/>
<OrgPermissionIdentityRow
control={control}
setValue={setValue}
@@ -1,70 +1,19 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { getObjectFromSeconds } from "@app/helpers/datetime";
import { useUpdateOrg } from "@app/hooks/api";
const MAX_SHARED_SECRET_LIFETIME_SECONDS = 30 * 24 * 60 * 60; // 30 days in seconds
const MIN_SHARED_SECRET_LIFETIME_SECONDS = 5 * 60; // 5 minutes in seconds
// Helper function to convert duration to seconds
const durationToSeconds = (value: number, unit: "m" | "h" | "d"): number => {
switch (unit) {
case "m":
return value * 60;
case "h":
return value * 60 * 60;
case "d":
return value * 60 * 60 * 24;
default:
return 0;
}
};
// Helper function to convert seconds to form lifetime value and unit
const getFormLifetimeFromSeconds = (
totalSeconds: number | null | undefined
): { maxLifetimeValue: number; maxLifetimeUnit: "m" | "h" | "d" } => {
const DEFAULT_LIFETIME_VALUE = 30;
const DEFAULT_LIFETIME_UNIT = "d" as "m" | "h" | "d";
if (totalSeconds == null || totalSeconds <= 0) {
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
}
const secondsInDay = 24 * 60 * 60;
const secondsInHour = 60 * 60;
const secondsInMinute = 60;
if (totalSeconds % secondsInDay === 0) {
const value = totalSeconds / secondsInDay;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "d" };
}
if (totalSeconds % secondsInHour === 0) {
const value = totalSeconds / secondsInHour;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "h" };
}
if (totalSeconds % secondsInMinute === 0) {
const value = totalSeconds / secondsInMinute;
if (value >= 1) return { maxLifetimeValue: value, maxLifetimeUnit: "m" };
}
return {
maxLifetimeValue: DEFAULT_LIFETIME_VALUE,
maxLifetimeUnit: DEFAULT_LIFETIME_UNIT
};
};
const formSchema = z
.object({
maxLifetimeValue: z.number().min(1, "Value must be at least 1"),
@@ -77,34 +26,20 @@ const formSchema = z
.superRefine((data, ctx) => {
const { maxLifetimeValue, maxLifetimeUnit } = data;
const durationInSeconds = durationToSeconds(maxLifetimeValue, maxLifetimeUnit);
const durationInSeconds = ms(`${maxLifetimeValue}${maxLifetimeUnit}`) / 1000;
// Check max limit
if (durationInSeconds > MAX_SHARED_SECRET_LIFETIME_SECONDS) {
let message = "Duration exceeds maximum allowed limit";
if (maxLifetimeUnit === "m") {
message = `Maximum allowed minutes is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / 60} (30 days)`;
} else if (maxLifetimeUnit === "h") {
message = `Maximum allowed hours is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (60 * 60)} (30 days)`;
} else if (maxLifetimeUnit === "d") {
message = `Maximum allowed days is ${MAX_SHARED_SECRET_LIFETIME_SECONDS / (24 * 60 * 60)}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
message: "Duration exceeds a maximum of 30 days",
path: ["maxLifetimeValue"]
});
}
// Check min limit
if (durationInSeconds < MIN_SHARED_SECRET_LIFETIME_SECONDS) {
const message = `Duration must be at least ${MIN_SHARED_SECRET_LIFETIME_SECONDS / 60} minutes`; // 5 minutes
ctx.addIssue({
code: z.ZodIssueCode.custom,
message,
message: "Duration must be at least 5 minutes",
path: ["maxLifetimeValue"]
});
}
@@ -122,10 +57,14 @@ export const OrgSecretShareLimitSection = () => {
const { currentOrg } = useOrganization();
const getDefaultFormValues = () => {
const initialLifetime = getFormLifetimeFromSeconds(currentOrg?.maxSharedSecretLifetime);
const initialLifetime = getObjectFromSeconds(currentOrg?.maxSharedSecretLifetime, [
"m",
"h",
"d"
]);
return {
maxLifetimeValue: initialLifetime.maxLifetimeValue,
maxLifetimeUnit: initialLifetime.maxLifetimeUnit,
maxLifetimeValue: initialLifetime.value,
maxLifetimeUnit: initialLifetime.unit as "m" | "h" | "d",
maxViewLimit: currentOrg?.maxSharedSecretViewLimit?.toString() || "1",
shouldLimitView: Boolean(currentOrg?.maxSharedSecretViewLimit)
};
@@ -152,10 +91,8 @@ export const OrgSecretShareLimitSection = () => {
const handleFormSubmit = async (formData: TForm) => {
try {
const maxSharedSecretLifetimeSeconds = durationToSeconds(
formData.maxLifetimeValue,
formData.maxLifetimeUnit
);
const maxSharedSecretLifetimeSeconds =
ms(`${formData.maxLifetimeValue}${formData.maxLifetimeUnit}`) / 1000;
await mutateAsync({
orgId: currentOrg.id,
@@ -1,6 +1,4 @@
import { useState } from "react";
import { faKey, faVault } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
@@ -20,14 +18,16 @@ enum WizardSteps {
const PLATFORM_LIST = [
{
icon: faKey,
image: "/images/integrations/EnvKey.png",
platform: "env-key",
title: "Env Key"
title: "EnvKey",
size: 34
},
{
icon: faVault,
image: "/images/integrations/Vault.png",
platform: "vault",
title: "Vault"
title: "HCP Vault",
size: 40
}
] as const;
@@ -67,7 +67,7 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
{PLATFORM_LIST.map((platform, idx) => (
<div
key={`platform-${idx + 1}`}
className="flex h-28 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
className="flex h-28 w-32 cursor-pointer flex-col items-center justify-between rounded border border-mineshaft-500 bg-mineshaft-700 p-6 py-5 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
@@ -81,7 +81,11 @@ export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
}
}}
>
<FontAwesomeIcon icon={platform.icon} size="lg" />
<img
src={platform.image}
alt={`${platform.title} logo`}
style={{ width: platform.size }}
/>
<div className="whitespace-pre-wrap text-center text-sm">{platform.title}</div>
</div>
))}
@@ -2,12 +2,19 @@ import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Tooltip } from "@app/components/v2";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem, Tooltip } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys } from "@app/hooks/api";
import { useImportVault } from "@app/hooks/api/migration/mutations";
type Props = {
@@ -62,36 +69,32 @@ const MAPPING_TYPE_MENU_ITEMS = [
export const VaultPlatformModal = ({ onClose }: Props) => {
const formSchema = z.object({
vaultUrl: z.string().min(1),
gatewayId: z.string().optional(),
vaultNamespace: z.string().trim().optional(),
vaultAccessToken: z.string().min(1),
mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault)
});
type TFormData = z.infer<typeof formSchema>;
const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list());
const { mutateAsync: importVault } = useImportVault();
const {
control,
handleSubmit,
reset,
formState: { isLoading, isDirty, isSubmitting, isValid, errors }
formState: { isLoading, isDirty, isSubmitting, isValid }
} = useForm<TFormData>({
resolver: zodResolver(formSchema)
});
console.log({
isSubmitting,
isLoading,
isValid,
errors
});
const onSubmit = async (data: TFormData) => {
await importVault({
vaultAccessToken: data.vaultAccessToken,
vaultNamespace: data.vaultNamespace,
vaultUrl: data.vaultUrl,
mappingType: data.mappingType
mappingType: data.mappingType,
...(data.gatewayId && { gatewayId: data.gatewayId })
});
createNotification({
title: "Import started",
@@ -110,11 +113,70 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
The Vault migration currently supports importing static secrets from Vault
Dedicated/Self-Hosted.
<div className="mt-2 text-xs opacity-80">
Currently only KV Secret Engine V2 is supported for Vault migrations.
Currently only KV Secret Engine is supported for Vault migrations.
</div>
</p>
</NoticeBannerV2>
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="w-full flex-1">
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
isOptional
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={(v) => {
if (v !== "") {
onChange(v);
}
}}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewayLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={undefined as unknown as string}
onClick={() => {
onChange(undefined);
}}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<Controller
control={control}
name="vaultUrl"
@@ -136,7 +136,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
text: "User privilege assumption has started"
});
const url = getProjectHomePage(currentWorkspace.type);
const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments);
window.location.href = url.replace("$projectId", currentWorkspace.id);
}
}
@@ -67,7 +67,7 @@ const Page = () => {
type: "success",
text: "Identity privilege assumption has started"
});
const url = getProjectHomePage(currentWorkspace.type);
const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments);
window.location.href = url.replace("$projectId", currentWorkspace.id);
}
}
@@ -72,7 +72,7 @@ export const Page = () => {
text: "User privilege assumption has started"
});
const url = getProjectHomePage(currentWorkspace.type);
const url = getProjectHomePage(currentWorkspace.type, currentWorkspace.environments);
window.location.href = url.replace("$projectId", currentWorkspace.id);
}
}
@@ -12,6 +12,7 @@ import {
} from "@app/context";
import {
PermissionConditionOperators,
ProjectPermissionAuditLogsActions,
ProjectPermissionCommitsActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,
@@ -41,6 +42,10 @@ const GeneralPolicyActionSchema = z.object({
create: z.boolean().optional()
});
const AuditLogsPolicyActionSchema = z.object({
[ProjectPermissionAuditLogsActions.Read]: z.boolean().optional()
});
const CertificatePolicyActionSchema = z.object({
[ProjectPermissionCertificateActions.Create]: z.boolean().optional(),
[ProjectPermissionCertificateActions.Delete]: z.boolean().optional(),
@@ -316,7 +321,7 @@ export const projectRoleFormSchema = z.object({
[ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.AuditLogs]: AuditLogsPolicyActionSchema.array().default([]),
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]),
@@ -1324,12 +1329,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
},
[ProjectPermissionSub.AuditLogs]: {
title: "Audit Logs",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
actions: [{ label: "Read", value: ProjectPermissionAuditLogsActions.Read }]
},
[ProjectPermissionSub.IpAllowList]: {
title: "IP Allowlist",
@@ -1721,7 +1721,7 @@ const projectManagerTemplate = (
permissions: [
{
subject: ProjectPermissionSub.AuditLogs,
actions: Object.values(ProjectPermissionActions)
actions: Object.values(ProjectPermissionAuditLogsActions)
},
{
subject: ProjectPermissionSub.Groups,
@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router";
import { EmptyState } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { useSubscription, useWorkspace } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
@@ -15,6 +15,7 @@ type Props = {
export const IntegrationAuditLogsSection = ({ integration }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
@@ -30,6 +31,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
<LogsSection
refetchInterval={4000}
showFilters={false}
project={currentWorkspace}
presets={{
eventMetadata: { integrationId: integration.id },
startDate: new Date(
@@ -52,6 +52,7 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
import { SecretV3RawSanitized } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
@@ -64,6 +65,8 @@ import { ActionBar } from "./components/ActionBar";
import { CommitForm } from "./components/CommitForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { EnvironmentTabs } from "./components/EnvironmentTabs";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
@@ -105,7 +108,7 @@ const Page = () => {
const { permission } = useProjectPermission();
const { mutateAsync: createCommit } = useCreateCommit();
const tableRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
const [isVisible, setIsVisible] = useState(false);
const { isBatchMode, pendingChanges } = useBatchMode();
@@ -249,7 +252,8 @@ const Page = () => {
const {
data,
isPending: isDetailsLoading,
isFetching: isDetailsFetching
isFetching: isDetailsFetching,
isFetched
} = useGetProjectSecretsDetails({
environment,
projectId: workspaceId,
@@ -270,6 +274,18 @@ const Page = () => {
tags: filter.tags
});
useEffect(() => {
// if switching tabs in a folder path that doesn't exist in a separate env we navigate to the root
if (!data && isFetched) {
navigate({
search: (prev) => ({
...prev,
secretPath: "/"
})
});
}
}, [data, isFetched]);
const {
imports,
folders,
@@ -491,7 +507,8 @@ const Page = () => {
minWidth: 100,
maxWidth: tableRef.current
? tableRef.current.clientWidth - 148 // ensure value column can't collapse completely
: 800
: 800,
ref: tableRef
});
useEffect(() => {
@@ -710,12 +727,18 @@ const Page = () => {
const mergedSecrets = getMergedSecretsWithPending();
const mergedFolders = getMergedFoldersWithPending();
if (!(currentWorkspace?.version === ProjectVersion.V3))
return (
<div className="flex h-full w-full flex-col items-center justify-center px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
</div>
);
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
title={
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
}
title="Secrets Management"
description={
<p className="text-md text-bunker-300">
Inject your secrets using
@@ -759,6 +782,8 @@ const Page = () => {
}
/>
<SecretV2MigrationSection />
<FolderBreadCrumbs secretPath={secretPath} />
<EnvironmentTabs secretPath={secretPath} />
{!isRollbackMode ? (
<>
<ActionBar
@@ -938,6 +963,7 @@ const Page = () => {
workspaceId={workspaceId}
secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
canNavigate={isFetched}
/>
)}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
@@ -0,0 +1,595 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { MultiValue } from "react-select";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFingerprint,
faFolder,
faKey,
faRotate,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
FilterableSelect,
FormLabel,
IconButton,
Input,
Lottie,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, useResetPageHelper } from "@app/hooks";
import { useGetImportedSecretsAllEnvs } from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { useResizableColWidth } from "@app/hooks/useResizableColWidth";
import {
useDynamicSecretOverview,
useFolderOverview,
useSecretOverview,
useSecretRotationOverview
} from "@app/hooks/utils";
import { SecretTableResourceCount } from "@app/pages/secret-manager/OverviewPage/components/SecretTableResourceCount";
import { DynamicSecretRow } from "./components/DynamicSecretRow";
import { FolderRow } from "./components/FolderRow";
import { SecretRotationRow } from "./components/SecretRotationRow";
import { SecretNoAccessRow, SecretRow } from "./components/SecretRow";
type Props = {
secretPath: string;
};
enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "secret",
SecretRotation = "rotation"
}
type Filter = {
[key in RowType]: boolean;
};
const DEFAULT_FILTER_STATE = {
[RowType.Folder]: false,
[RowType.DynamicSecret]: false,
[RowType.Secret]: false,
[RowType.SecretRotation]: false
};
const COL_WIDTH_OFFSET = 220;
export const CompareEnvironments = ({ secretPath }: Props) => {
const { currentWorkspace } = useWorkspace();
const compareEnvironmentsKey = `compare-environments-${currentWorkspace.id}`;
const [selectedEnvironments, setSelectedEnvironments] = useState<WorkspaceEnv[]>(() => {
try {
const storedEnvironments = JSON.parse(localStorage.getItem(compareEnvironmentsKey) ?? "[]");
if (Array.isArray(storedEnvironments) && storedEnvironments.length > 0) {
const potentialEnvs: string[] = [];
storedEnvironments.forEach((env) => {
if (typeof env === "string") {
potentialEnvs.push(env);
}
});
return currentWorkspace.environments.filter((env) => potentialEnvs.includes(env.id));
}
} catch {
// do nothing and proceed
}
return currentWorkspace.environments.slice(0, 2);
});
const [filter, setFilter] = useState<Filter>(DEFAULT_FILTER_STATE);
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name, {
initPerPage: getUserTablePreference("secretCompareTable", PreferenceKey.PerPage, 50)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("secretCompareTable", PreferenceKey.PerPage, newPerPage);
};
const workspaceId = currentWorkspace.id;
const [searchFilter, setSearchFilter] = useState("");
const [debouncedSearchFilter] = useDebounce(searchFilter);
const [debouncedSelectedEnvironments] = useDebounce(selectedEnvironments);
useEffect(() => {
localStorage.setItem(
compareEnvironmentsKey,
JSON.stringify(selectedEnvironments.map((env) => env.id))
);
}, [debouncedSelectedEnvironments]);
const {
secretImports,
isImportedSecretPresentInEnv,
getImportedSecretByKey,
getEnvImportedSecretKeyCount
} = useGetImportedSecretsAllEnvs({
projectId: workspaceId,
path: secretPath,
environments: (currentWorkspace.environments || []).map(({ slug }) => slug)
});
const compareEnvironments = selectedEnvironments.length
? selectedEnvironments
: currentWorkspace.environments;
const isFilteredByResources = Object.values(filter).some(Boolean);
const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
{
projectId: workspaceId,
environments: compareEnvironments.map((env) => env.slug),
secretPath,
orderDirection,
orderBy,
includeFolders: isFilteredByResources ? filter.folder : true,
includeDynamicSecrets: isFilteredByResources ? filter.dynamic : true,
includeSecrets: isFilteredByResources ? filter.secret : true,
includeImports: true,
includeSecretRotations: isFilteredByResources ? filter.rotation : true,
search: debouncedSearchFilter,
limit,
offset
},
{ enabled: Boolean(compareEnvironments.length) }
);
const {
secrets,
folders,
dynamicSecrets,
secretRotations,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount,
totalSecretRotationCount,
totalCount = 0,
totalUniqueFoldersInPage,
totalUniqueSecretsInPage,
totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage
} = overview ?? {};
const secretImportsShaped = secretImports
?.flatMap(({ data }) => data)
.filter(Boolean)
.flatMap((item) => item?.secrets || []);
const handleIsImportedSecretPresentInEnv = (envSlug: string, secretName: string) => {
if (secrets?.some((s) => s.key === secretName && s.env === envSlug)) {
return false;
}
if (secretImportsShaped.some((s) => s.key === secretName && s.sourceEnv === envSlug)) {
return true;
}
return isImportedSecretPresentInEnv(envSlug, secretName);
};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { folderNamesAndDescriptions, isFolderPresentInEnv } = useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
const { secretRotationNames, isSecretRotationPresentInEnv, getSecretRotationByName } =
useSecretRotationOverview(secretRotations);
const { secKeys, getEnvSecretKeyCount } = useSecretOverview(
secrets?.concat(secretImportsShaped) || []
);
const getSecretByKey = useCallback(
(env: string, key: string) => {
const sec = secrets?.find((s) => s.env === env && s.key === key);
return sec;
},
[secrets]
);
const [tableWidth, setTableWidth] = useState(0);
const tableRef = useRef<HTMLTableElement>(null);
const { handleMouseDown, isResizing, colWidth } = useResizableColWidth({
initialWidth: 320,
minWidth: 160,
maxWidth: tableRef.current
? tableRef.current.clientWidth - COL_WIDTH_OFFSET // ensure value column can't collapse completely
: 800,
ref: tableRef
});
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
[rowType]: !state[rowType]
};
}),
[]
);
const isTableEmpty = totalCount === 0;
const isTableFiltered = isFilteredByResources;
useEffect(() => {
const element = tableRef.current;
if (!element) return;
const handleResize = () => {
setTableWidth(element.clientWidth - 1);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(element);
// eslint-disable-next-line consistent-return
return () => {
resizeObserver.disconnect();
};
}, [tableRef]);
return (
// scott: this is reverse to fix z-indexing bug of dropdown with sticky table cols; couldn't resolve with flex-col
<div className="flex flex-1 flex-col-reverse overflow-hidden">
{!isOverviewLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-lg border border-solid border-mineshaft-500 bg-mineshaft-700"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
<div className="thin-scrollbar flex flex-1 flex-col overflow-y-auto">
<TableContainer
ref={tableRef}
className={twMerge(
"mt-4 flex flex-1 flex-col border-mineshaft-500 bg-mineshaft-700",
!isTableEmpty && "rounded-b-none"
)}
>
{/* eslint-disable-next-line no-nested-ternary */}
{isOverviewLoading ? (
<div className="flex h-full flex-col items-center justify-center">
<Lottie
isAutoPlay
icon="infisical_loading"
className="h-10 place-self-center self-center"
/>
</div>
) : isTableEmpty ? (
<EmptyState
titleClassName="text-base"
className="m-auto bg-mineshaft-700 text-lg"
title={
isTableFiltered
? "No secrets to compare with current filters"
: "No secrets to compare"
}
/>
) : (
<Table className="border-collapse bg-mineshaft-700">
<THead className="sticky top-0 z-20">
<Tr className="sticky top-0 z-20">
<Th className="sticky left-0 z-10 border-none p-0" style={{ width: colWidth }}>
<div className="relative">
<div
tabIndex={-1}
role="button"
className={`absolute -right-[0.02rem] z-40 h-full w-0.5 cursor-ew-resize hover:bg-blue-400/20 ${
isResizing ? "bg-blue-400/75" : "bg-transparent"
}`}
onMouseDown={handleMouseDown}
/>
<div className="pointer-events-none absolute -right-[0.02rem] top-[0.67rem] z-30">
<div className="h-5 w-0.5 rounded-[1.5px] bg-gray-400 opacity-50" />
</div>
<div className="flex h-full items-center border-b-2 border-r border-mineshaft-500 bg-mineshaft-700 bg-clip-padding p-0 px-4 py-2.5 text-sm normal-case">
Name
<IconButton
variant="plain"
className="ml-1 mt-[0.1rem]"
ariaLabel="sort"
onClick={() =>
setOrderDirection((prev) =>
prev === OrderByDirection.ASC
? OrderByDirection.DESC
: OrderByDirection.ASC
)
}
>
<FontAwesomeIcon
className="h-3"
icon={orderDirection === "asc" ? faArrowDown : faArrowUp}
/>
</IconButton>
</div>
</div>
</Th>
{compareEnvironments?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const importedSecKeyCount = getEnvImportedSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount;
return (
<Th
className="whitespace-nowrap border-none p-0 text-center"
key={`environment-${slug}`}
>
<div
className={twMerge(
"flex h-full w-full items-center justify-center gap-x-2 border-b-2 border-mineshaft-500 bg-mineshaft-700 p-0 px-4 py-2.5 text-center text-sm normal-case",
index < compareEnvironments.length - 1 && "border-r"
)}
>
{name}
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={
<>
{missingKeyCount} secret{missingKeyCount > 1 ? "s" : ""} missing
compared to other environments on this page
</>
}
>
<Badge
variant="primary"
className="-mt-[0.05rem] flex h-4 items-center gap-x-1 pt-[0.1rem] font-normal leading-3"
>
<FontAwesomeIcon icon={faWarning} className="-mt-[0.1rem] w-2.5" />
{missingKeyCount}
</Badge>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{folderNamesAndDescriptions.map(({ name: folderName }, index) => (
<FolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={compareEnvironments}
key={`overview-${folderName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{dynamicSecretNames.map((dynamicSecretName, index) => (
<DynamicSecretRow
dynamicSecretName={dynamicSecretName}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={compareEnvironments}
key={`overview-${dynamicSecretName}-${index + 1}`}
colWidth={colWidth}
/>
))}
{secretRotationNames.map((secretRotationName, index) => (
<SecretRotationRow
secretRotationName={secretRotationName}
isSecretRotationInEnv={isSecretRotationPresentInEnv}
environments={compareEnvironments}
getSecretRotationByName={getSecretRotationByName}
key={`overview-${secretRotationName}-${index + 1}`}
colWidth={colWidth}
tableWidth={tableWidth}
/>
))}
{secKeys.map((key, index) => (
<SecretRow
colWidth={colWidth}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={handleIsImportedSecretPresentInEnv}
key={`overview-${key}-${index + 1}`}
environments={compareEnvironments}
secretKey={key}
getSecretByKey={getSecretByKey}
tableWidth={tableWidth}
/>
))}
<SecretNoAccessRow
colWidth={colWidth}
environments={compareEnvironments}
count={Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0) -
(totalUniqueSecretImportsInPage || 0) -
(totalUniqueSecretRotationsInPage || 0),
0
)}
/>
</TBody>
</Table>
)}
</TableContainer>
</div>
<div className="mt-3 flex flex-row items-center justify-center space-x-2">
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="h-full flex-1"
placeholder="Search by resource name..."
leftIcon={<FontAwesomeIcon icon={faSearch} />}
containerClassName="h-10"
/>
{isTableFiltered && (
<Button
variant="plain"
colorSchema="secondary"
onClick={() => {
setFilter(DEFAULT_FILTER_STATE);
}}
>
Clear Filters
</Button>
)}
{compareEnvironments.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline_bg"
className={twMerge(
"flex h-[2.5rem]",
isTableFiltered && "border-primary/40 bg-primary/10"
)}
leftIcon={
<FontAwesomeIcon
icon={faFilter}
className={isTableFiltered ? "text-primary/80" : undefined}
/>
}
>
Filters
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
sideOffset={2}
>
<DropdownMenuLabel>Filter by Resource</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Folder);
}}
icon={filter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.DynamicSecret);
}}
icon={filter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.SecretRotation);
}}
icon={filter[RowType.SecretRotation] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>Secret Rotations</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Secret);
}}
icon={filter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="!z-[99999999]">
<FormLabel label="Select Environments to Compare" />
<FilterableSelect
value={selectedEnvironments}
onChange={(value) => {
const selected = value as MultiValue<WorkspaceEnv>;
setSelectedEnvironments((selected as WorkspaceEnv[]) ?? []);
}}
placeholder="Leave blank to compare all environments"
options={currentWorkspace.environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
isMulti
/>
</div>
</div>
);
};
@@ -0,0 +1,41 @@
import { faFingerprint } from "@fortawesome/free-solid-svg-icons";
import { Tr } from "@app/components/v2";
import { EnvironmentStatusCell, ResourceNameCell } from "../shared";
type Props = {
dynamicSecretName: string;
environments: { name: string; slug: string }[];
isDynamicSecretInEnv: (name: string, env: string) => boolean;
colWidth: number;
};
export const DynamicSecretRow = ({
dynamicSecretName,
environments = [],
isDynamicSecretInEnv,
colWidth
}: Props) => {
return (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={dynamicSecretName}
icon={faFingerprint}
colWidth={colWidth}
iconClassName="text-yellow-700"
/>
{environments.map(({ slug }, i) => {
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`dynamic-secret-${dynamicSecretName}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { DynamicSecretRow } from "./DynamicSecretRow";
@@ -0,0 +1,41 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { Tr } from "@app/components/v2";
import { EnvironmentStatusCell, ResourceNameCell } from "../shared";
type Props = {
folderName: string;
environments: { name: string; slug: string }[];
isFolderPresentInEnv: (name: string, env: string) => boolean;
colWidth: number;
};
export const FolderRow = ({
folderName,
environments = [],
isFolderPresentInEnv,
colWidth
}: Props) => {
return (
<Tr isHoverable className="group border-mineshaft-500">
<ResourceNameCell
label={folderName}
icon={faFolder}
iconClassName="text-yellow-700"
colWidth={colWidth}
/>
{environments.map(({ slug }, i) => {
const isPresent = isFolderPresentInEnv(folderName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`folder-${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { FolderRow } from "./FolderRow";
@@ -0,0 +1,179 @@
import { faEye, faEyeSlash, faInfoCircle, faRotate } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2";
import { Blur } from "@app/components/v2/Blur";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { useToggle } from "@app/hooks";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { EnvironmentStatusCell, ResourceNameCell } from "../shared";
type Props = {
secretRotationName: string;
environments: { name: string; slug: string }[];
isSecretRotationInEnv: (name: string, env: string) => boolean;
getSecretRotationByName: (slug: string, name: string) => TSecretRotationV2 | undefined;
colWidth: number;
tableWidth: number;
};
export const SecretRotationRow = ({
secretRotationName,
environments = [],
isSecretRotationInEnv,
colWidth,
getSecretRotationByName,
tableWidth
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle(false);
const [isSecretVisible, setIsSecretVisible] = useToggle();
const totalCols = environments.length + 1; // secret key row
return (
<>
<Tr isHoverable isSelectable onClick={setIsExpanded.toggle} className="group">
<ResourceNameCell
label={secretRotationName}
icon={faRotate}
colWidth={colWidth}
iconClassName="text-mineshaft-400"
isRowExpanded={isExpanded}
/>
{environments.map(({ slug }, i) => {
const isPresent = isSecretRotationInEnv(secretRotationName, slug);
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status={isPresent ? "present" : "missing"}
key={`secret-rotation-${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
{isExpanded &&
environments.map(({ name: envName, slug }) => {
const secretRotation = getSecretRotationByName(slug, secretRotationName);
if (!secretRotation) return null;
const { type, secrets, description } = secretRotation;
const { name: rotationType, image } = SECRET_ROTATION_MAP[type];
return (
<Tr key={`secret-rotation-${slug}-${secretRotationName}`} className="border-b-0">
<Td
colSpan={totalCols}
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-clip-padding px-0 py-0"
>
<div
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-clip-padding px-0 py-0"
>
<div className="flex !h-[40px] items-center justify-between gap-x-2 bg-mineshaft-800 px-4">
<div className="w-full">
<div className="flex w-full flex-wrap items-center gap-x-2.5">
<span>{envName}</span>
<Tag className="flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
<img
src={`/images/integrations/${image}`}
className="w-[11px]"
alt={`${rotationType} logo`}
/>
{rotationType}
</Tag>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</div>
<Tooltip
side="left"
content={isSecretVisible ? "Hide Values" : "Reveal Values"}
>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel={isSecretVisible ? "Hide Values" : "Reveal Values"}
onClick={() => setIsSecretVisible.toggle()}
>
<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />
</IconButton>
</Tooltip>
</div>
<TableContainer className="rounded-none border-0">
<table className="secret-table w-full border-b-0 !bg-mineshaft-900">
<tbody className="!last:border-b-0 w-full border-t-2 border-mineshaft-600">
{secrets.map((secret, index) => {
return (
<Tooltip
className="max-w-sm"
content={
secret
? undefined
: "You do not have permission to view this secret."
}
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
>
<tr className="hover:bg-mineshaft-800/50">
<td
style={{
width: colWidth
}}
className="!h-[1px] border-none !p-0"
>
<div
className="flex h-full flex-1 items-center border-r border-mineshaft-500 px-4 py-1"
style={{
width: colWidth
}}
>
<span className={twMerge(!secret && "blur", "truncate")}>
{secret?.key ?? "********"}
</span>
</div>
</td>
<td className="!h-[40px] !px-4">
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<InfisicalSecretInput
isReadOnly
value={secret.value}
isVisible={isSecretVisible}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
onChange={() => {}}
/>
)}
</td>
</tr>
</Tooltip>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
);
})}
</>
);
};
@@ -0,0 +1 @@
export { SecretRotationRow } from "./SecretRotationRow";
@@ -0,0 +1,49 @@
import { faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tooltip } from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
type Props = {
defaultValue?: string | null;
isOverride?: boolean;
isVisible?: boolean;
isImportedSecret: boolean;
environment: string;
secretValueHidden: boolean;
secretPath: string;
};
export const EnvironmentSecretRow = ({
defaultValue,
isOverride,
isImportedSecret,
secretValueHidden,
environment,
secretPath,
isVisible
}: Props) => {
return (
<div className="group flex w-full cursor-text items-center space-x-2">
{secretValueHidden && !isOverride && (
<Tooltip content="You do not have access to view the current value">
<FontAwesomeIcon className="pl-2" size="sm" icon={faEyeSlash} />
</Tooltip>
)}
<div className="flex-1 pl-3 pr-2">
<InfisicalSecretInput
onChange={() => {}}
isReadOnly
value={defaultValue as string}
key="secret-input"
isVisible={isVisible && !secretValueHidden}
secretPath={secretPath}
environment={environment}
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
/>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { Tr } from "@app/components/v2";
import { Blur } from "@app/components/v2/Blur";
import { EnvironmentStatusCell, ResourceNameCell } from "../shared";
type Props = {
environments: { name: string; slug: string }[];
count: number;
colWidth: number;
};
export const SecretNoAccessRow = ({ environments = [], count, colWidth }: Props) => {
return (
<>
{Array.from(Array(count)).map((_, j) => (
<Tr
key={`no-access-secret-${j + 1}`}
isHoverable
isSelectable
className="group border-mineshaft-500"
>
<ResourceNameCell
label={<Blur />}
iconClassName="text-bunker-400"
icon={faLock}
colWidth={colWidth}
tooltipContent="You do not have permission to view this secret"
/>
{environments.map(({ slug }, i) => {
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
status="no-access"
key={`no-access-sec--${slug}-${i + 1}-value`}
/>
);
})}
</Tr>
))}
</>
);
};
@@ -0,0 +1,231 @@
import { subject } from "@casl/ability";
import {
faCodeBranch,
faEye,
faEyeSlash,
faFileImport,
faKey,
faRotate
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
import { EnvironmentStatus, EnvironmentStatusCell, ResourceNameCell } from "../shared";
import { EnvironmentSecretRow } from "./EnvironmentSecretRow";
type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean;
getImportedSecretByKey: (
env: string,
secretName: string
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
colWidth: number;
tableWidth: number;
};
export const SecretRow = ({
secretKey,
environments = [],
secretPath,
getSecretByKey,
isImportedSecretPresentInEnv,
getImportedSecretByKey,
colWidth,
tableWidth
}: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row
const [isSecretVisible, setIsSecretVisible] = useToggle();
const { permission } = useProjectPermission();
const getDefaultValue = (
secret: SecretV3RawSanitized | undefined,
importedSecret: { secret?: SecretV3RawSanitized } | undefined
) => {
const canEditSecretValue = permission.can(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment: secret?.env || "",
secretPath: secret?.path || "",
secretName: secret?.key || "",
secretTags: ["*"]
})
);
if (secret?.secretValueHidden && !secret?.valueOverride) {
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || "";
};
return (
<>
<Tr
isHoverable
isSelectable
onClick={() => setIsFormExpanded.toggle()}
className="group border-mineshaft-500"
>
<ResourceNameCell
colWidth={colWidth}
label={secretKey}
icon={faKey}
iconClassName="text-bunker-300"
isRowExpanded={isFormExpanded}
/>
{environments.map(({ slug }, i) => {
const secret = getSecretByKey(slug, secretKey);
const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey);
const isSecretPresent = Boolean(secret);
const isSecretEmpty = secret?.value === "";
let status: EnvironmentStatus;
if (isSecretEmpty) {
status = "empty";
} else if (isSecretPresent) {
status = "present";
} else if (isSecretImported) {
status = "imported";
} else {
status = "missing";
}
return (
<EnvironmentStatusCell
isLast={i === environments.length - 1}
key={`sec-overview-${slug}-${i + 1}-value`}
status={status}
/>
);
})}
</Tr>
{isFormExpanded && (
<Tr className="border-b-0">
<Td
colSpan={totalCols}
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-clip-padding px-0 py-0"
>
<div
style={{ minWidth: tableWidth, maxWidth: tableWidth }}
className="sticky left-0 bg-clip-padding px-0 py-0"
>
<TableContainer className="rounded-none border-0">
<table className="secret-table bg-mineshaft-900">
<thead>
<tr className="h-10 border-b-2 border-mineshaft-600 bg-mineshaft-800">
<th
style={{
width: colWidth
}}
className="min-table-row"
>
<span className="truncate">Environment</span>
</th>
<th style={{ padding: "0.5rem 1rem" }} className="border-none">
Value
</th>
<div className="absolute right-3 top-[4px] ml-auto mr-1 mt-1 w-min">
<Tooltip
side="left"
content={isSecretVisible ? "Hide Values" : "Reveal Values"}
>
<IconButton
variant="plain"
colorSchema="secondary"
ariaLabel={isSecretVisible ? "Hide Values" : "Reveal Values"}
onClick={() => setIsSecretVisible.toggle()}
>
<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />
</IconButton>
</Tooltip>
</div>
</tr>
</thead>
<tbody className="border-t-2 border-mineshaft-600">
{environments.map(({ name, slug }) => {
const secret = getSecretByKey(slug, secretKey);
const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey);
const importedSecret = getImportedSecretByKey(slug, secretKey);
return (
<tr
key={`secret-expanded-${slug}-${secretKey}`}
className="h-full hover:bg-mineshaft-700/70"
>
<td
className="h-[1px] border-none !p-0"
style={{
width: colWidth
}}
>
<div
title={name}
style={{
width: colWidth
}}
className="flex h-full min-h-[40px] w-[8rem] items-center space-x-2 border-r border-mineshaft-500 px-4"
>
<span className="truncate">{name}</span>
{isImportedSecret && (
<Tooltip
content={`Imported secret from the '${importedSecret?.environmentInfo?.name}' environment`}
>
<FontAwesomeIcon icon={faFileImport} />
</Tooltip>
)}
{secret?.isRotatedSecret && (
<Tooltip content="Rotated Secret">
<FontAwesomeIcon icon={faRotate} />
</Tooltip>
)}
{secret?.valueOverride && (
<Tooltip content="Personal Override">
<FontAwesomeIcon icon={faCodeBranch} />
</Tooltip>
)}
</div>
</td>
<td className="col-span-2 h-8 w-full">
<EnvironmentSecretRow
secretPath={secretPath}
isVisible={isSecretVisible}
secretValueHidden={secret?.secretValueHidden || false}
defaultValue={getDefaultValue(secret, importedSecret)}
isOverride={Boolean(secret?.valueOverride)}
isImportedSecret={isImportedSecret}
environment={slug}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1,2 @@
export { SecretNoAccessRow } from "./SecretNoAccessRow";
export { SecretRow } from "./SecretRow";
@@ -0,0 +1,75 @@
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import { faBan, faCheck, faFileImport, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Td, Tooltip } from "@app/components/v2";
export type EnvironmentStatus = "present" | "missing" | "empty" | "imported" | "no-access";
type Props = {
isLast: boolean;
status: EnvironmentStatus;
};
export const EnvironmentStatusCell = ({ isLast, status }: Props) => {
let tooltipContent: string;
let icon: IconDefinition;
let iconClassName: string;
switch (status) {
case "present":
tooltipContent = "Present in environment";
icon = faCheck;
iconClassName = "h-3 w-3";
break;
case "missing":
tooltipContent = "Missing from environment";
icon = faXmark;
iconClassName = "h-3.5 w-3.5";
break;
case "empty":
tooltipContent = "Empty value in environment";
icon = faCircle;
iconClassName = "h-3 w-3";
break;
case "imported":
tooltipContent = "Imported into environment";
icon = faFileImport;
iconClassName = "h-3 w-3";
break;
case "no-access":
tooltipContent = "You do not have permission to view this secret";
icon = faBan;
iconClassName = "h-3 w-3";
break;
default:
throw new Error(`Unhandled environment status: ${status as string}`);
}
return (
<Td
className={twMerge(
"border-b border-mineshaft-500 bg-clip-padding p-0 group-hover:bg-mineshaft-600",
(status === "present" || status === "imported") && "text-green-600",
status === "empty" && "text-yellow",
status === "missing" && "text-red-600",
status === "no-access" && "text-bunker-400"
)}
>
<div
className={twMerge(
"flex h-10 items-center justify-center border-mineshaft-500 px-0 py-3",
!isLast && "border-r"
)}
>
<div className="flex justify-center">
<Tooltip center content={tooltipContent}>
<FontAwesomeIcon className={iconClassName} icon={icon} />
</Tooltip>
</div>
</div>
</Td>
);
};
@@ -0,0 +1,47 @@
import { ReactElement } from "react";
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tooltip } from "@app/components/v2";
type Props = {
isRowExpanded?: boolean;
label: ReactElement | string;
icon: IconDefinition;
iconClassName?: string;
colWidth: number;
tooltipContent?: string;
};
export const ResourceNameCell = ({
isRowExpanded,
label,
icon,
iconClassName,
colWidth,
tooltipContent
}: Props) => {
return (
<Td
className="sticky left-0 z-10 border-b border-mineshaft-500 bg-mineshaft-700 bg-clip-padding p-0 group-hover:bg-mineshaft-600"
style={{
width: colWidth
}}
>
<Tooltip content={tooltipContent} className="max-w-sm">
<div
style={{
width: colWidth
}}
className="flex h-10 items-center space-x-5 border-r border-mineshaft-600 px-4 py-2.5"
>
<div className="w-5 min-w-5">
<FontAwesomeIcon className={iconClassName} icon={isRowExpanded ? faAngleDown : icon} />
</div>
{typeof label === "string" ? <span className="truncate">{label}</span> : label}
</div>
</Tooltip>
</Td>
);
};
@@ -0,0 +1,2 @@
export * from "./EnvironmentStatusCell";
export * from "./ResourceNameCell";
@@ -0,0 +1 @@
export * from "./CompareEnvironments";
@@ -0,0 +1,242 @@
import { useState } from "react";
import { faArrowRightArrowLeft, faEllipsisH, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
Modal,
ModalContent,
Tab,
TabList,
Tabs,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { workspaceKeys } from "@app/hooks/api";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { AddEnvironmentModal } from "@app/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal";
import { CompareEnvironments } from "../CompareEnvironments";
const COMPARE_ENVIRONMENT_TAB = "__COMPARE_ENVIRONMENT_TAB__";
const ADD_ENVIRONMENT_TAB = "__ADD_ENVIRONMENT_TAB__";
const VIEW_MORE_ENVIRONMENT_TAB = "__VIEW_MORE_ENVIRONMENT_TAB__";
type Props = {
secretPath: string;
};
const TABS_TO_SHOW = 5;
export const EnvironmentTabs = ({ secretPath }: Props) => {
const { currentWorkspace } = useWorkspace();
const currentEnv = useParams({
from: ROUTE_PATHS.SecretManager.SecretDashboardPage.id,
select: (el) => el.envSlug
});
const { subscription } = useSubscription();
const isMoreEnvironmentsAllowed =
subscription?.environmentLimit && currentWorkspace?.environments
? currentWorkspace.environments.length < subscription.environmentLimit
: true;
const [isNavigating, setIsNavigating] = useState(false);
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"compareEnvironments",
"createEnvironment",
"upgradePlan"
] as const);
const selectedIndex = currentWorkspace.environments.findIndex((env) => env.slug === currentEnv);
let tabEnvironments: WorkspaceEnv[];
let dropdownEnvironments: WorkspaceEnv[];
if (selectedIndex < TABS_TO_SHOW) {
tabEnvironments = currentWorkspace.environments.slice(0, TABS_TO_SHOW);
dropdownEnvironments = currentWorkspace.environments.slice(TABS_TO_SHOW);
} else {
tabEnvironments = [
...currentWorkspace.environments.slice(0, TABS_TO_SHOW - 1),
currentWorkspace.environments[selectedIndex]
];
dropdownEnvironments = currentWorkspace.environments
.slice(TABS_TO_SHOW - 1)
.filter((env) => env.slug !== currentEnv);
}
const queryClient = useQueryClient();
const handleSelect = async (envSlug: string) => {
if (isNavigating) return;
setIsNavigating(true);
await navigate({
to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path,
params: {
envSlug,
projectId: currentWorkspace.id
},
search: (prev) => prev
});
setIsNavigating(false);
};
const handleAddEnvironment = () => {
if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createEnvironment");
} else {
handlePopUpOpen("upgradePlan");
}
};
return (
<>
<Tabs
value={currentEnv}
onValueChange={(value) => {
if (value === COMPARE_ENVIRONMENT_TAB) {
handlePopUpOpen("compareEnvironments");
return;
}
if (value === ADD_ENVIRONMENT_TAB) {
handleAddEnvironment();
return;
}
handleSelect(value);
}}
defaultValue="environment-tabs"
>
<TabList>
{tabEnvironments.map((environment) => (
<Tab key={environment.slug} className="max-w-[12vw] truncate" value={environment.slug}>
<p className="truncate">{environment.name}</p>
</Tab>
))}
{dropdownEnvironments.length ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Tab value={VIEW_MORE_ENVIRONMENT_TAB} className="p-0">
<Tooltip content="More Environments">
<div className="px-3">
<FontAwesomeIcon icon={faEllipsisH} />
</div>
</Tooltip>
</Tab>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
sideOffset={2}
align="center"
>
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<div className="thin-scrollbar max-h-[40vh] overflow-auto">
{dropdownEnvironments.map((environment) => (
<DropdownMenuItem
key={environment.id}
onClick={(e) => {
e.stopPropagation();
handleSelect(environment.slug);
}}
>
{environment.name}
</DropdownMenuItem>
))}
</div>
<div className="h-1 border-t border-mineshaft-600" />
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<DropdownMenuItem
isDisabled={!isAllowed}
onClick={handleAddEnvironment}
className="data-[highlighted]:bg-mineshaft-900"
>
<Button
size="xs"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
className="w-full"
>
Add Environment
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tab value={ADD_ENVIRONMENT_TAB} className="p-0">
<Tooltip content="Add environment">
<div className="px-3">
<FontAwesomeIcon icon={faPlus} />
</div>
</Tooltip>
</Tab>
)}
{currentWorkspace.environments.length > 1 && (
<Tab className="ml-auto" value={COMPARE_ENVIRONMENT_TAB}>
<div className="flex items-center gap-x-2 whitespace-nowrap">
<FontAwesomeIcon icon={faArrowRightArrowLeft} />
Compare Environments
</div>
</Tab>
)}
</TabList>
</Tabs>
<Modal
isOpen={popUp.compareEnvironments.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("compareEnvironments", isOpen)}
>
<ModalContent
title="Compare Environments"
subTitle="Compare secrets across multiple environments"
className="flex h-full !w-[95vw] max-w-none flex-col"
bodyClassName="flex-1 flex flex-col overflow-hidden"
>
<CompareEnvironments secretPath={secretPath} />
</ModalContent>
</Modal>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
<AddEnvironmentModal
isOpen={popUp.createEnvironment.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createEnvironment", isOpen)}
onComplete={async (env) => {
await queryClient.refetchQueries({
queryKey: workspaceKeys.getWorkspaceById(currentWorkspace.id)
});
handleSelect(env.slug);
}}
/>
</>
);
};
@@ -0,0 +1 @@
export * from "./EnvironmentTabs";
@@ -0,0 +1,52 @@
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
type Props = {
secretPath: string;
};
export const FolderBreadCrumbs = ({ secretPath = "/" }: Props) => {
const navigate = useNavigate({
from: "/projects/secret-management/$projectId/secrets/$envSlug"
});
const onFolderCrumbClick = (index: number) => {
const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
if (secretPath === newSecPath) return;
navigate({
search: (prev) => ({ ...prev, secretPath: newSecPath })
});
};
return (
<div className="mb-3 flex flex-wrap items-center gap-x-2 gap-y-3">
<div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pl-5 pr-2 text-sm hover:bg-mineshaft-600"
onClick={() => onFolderCrumbClick(0)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
<FontAwesomeIcon icon={faFolderOpen} className="text-primary-700" />
</div>
{(secretPath || "")
.split("/")
.filter(Boolean)
.map((path, index, arr) => (
<div
key={`secret-path-${index + 1}`}
className={`breadcrumb relative z-20 ${
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
{path}
</div>
))}
</div>
);
};
@@ -0,0 +1 @@
export { FolderBreadCrumbs } from "./FolderBreadCrumbs";
@@ -36,6 +36,7 @@ type Props = {
workspaceId: string;
secretPath?: string;
onNavigateToFolder: (path: string) => void;
canNavigate: boolean;
};
export const FolderListView = ({
@@ -43,7 +44,8 @@ export const FolderListView = ({
environment,
workspaceId,
secretPath = "/",
onNavigateToFolder
onNavigateToFolder,
canNavigate
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder",
@@ -190,7 +192,7 @@ export const FolderListView = ({
};
const handleFolderClick = (name: string, isPending?: boolean) => {
if (isPending) {
if (isPending || !canNavigate) {
return;
}
const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`;
@@ -56,7 +56,7 @@ import {
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { useBatchModeActions } from "../../SecretMainPage.store";
export const HIDDEN_SECRET_VALUE = "******";
export const HIDDEN_SECRET_VALUE = "*****************************";
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
type Props = {
@@ -2,7 +2,7 @@ import { faFingerprint } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { useSubscription } from "@app/context";
import { useSubscription, useWorkspace } from "@app/context";
import { EventType } from "@app/hooks/api/auditLogs/enums";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
import { LogsSection } from "@app/pages/organization/AuditLogsPage/components/LogsSection";
@@ -19,6 +19,7 @@ type Props = {
export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const auditLogsRetentionDays = subscription?.auditLogsRetentionDays ?? 30;
@@ -36,6 +37,7 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
<LogsSection
refetchInterval={4000}
showFilters={false}
project={currentWorkspace}
presets={{
eventMetadata: { syncId: secretSync.id },
startDate: new Date(
@@ -3,16 +3,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateWsEnvironment } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { slugSchema } from "@app/lib/schemas";
type Props = {
popUp: UsePopUpState<["createEnv"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: (environment: WorkspaceEnv) => void;
};
const schema = z.object({
@@ -24,10 +24,14 @@ const schema = z.object({
export type FormData = z.infer<typeof schema>;
export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
type ContentProps = {
onComplete: (environment: WorkspaceEnv) => void;
};
const Content = ({ onComplete }: ContentProps) => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useCreateWsEnvironment();
const { control, handleSubmit, reset } = useForm<FormData>({
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema)
});
@@ -35,7 +39,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
const env = await mutateAsync({
workspaceId: currentWorkspace.id,
name: environmentName,
slug: environmentSlug
@@ -46,7 +50,7 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
type: "success"
});
handlePopUpClose("createEnv");
onComplete(env);
} catch (err) {
console.error(err);
createNotification({
@@ -57,64 +61,62 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle
};
return (
<Modal
isOpen={popUp?.createEnv?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("createEnv", isOpen);
reset();
}}
>
<ModalContent title="Create a new environment">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="environmentName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="environmentSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Slug"
helperText="Slugs are shorthands used in cli to access environment"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isPending}
isDisabled={isPending}
>
Create
</Button>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="environmentName"
render={({ field, fieldState: { error } }) => (
<FormControl label="Environment Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="environmentSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Slug"
helperText="Slugs are shorthands used in cli to access environment"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isPending}
isDisabled={isPending}
>
Create
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
<Button
onClick={() => handlePopUpClose("createEnv")}
colorSchema="secondary"
variant="plain"
>
Cancel
</Button>
</div>
</form>
export const AddEnvironmentModal = ({ onComplete, ...props }: Props) => {
return (
<Modal {...props}>
<ModalContent title="Create a new environment">
<Content
onComplete={(env) => {
if (onComplete) onComplete(env);
props.onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
@@ -103,9 +103,8 @@ export const EnvironmentSection = () => {
<PermissionDeniedBanner />
)}
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
isOpen={popUp.createEnv.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createEnv", isOpen)}
/>
<UpdateEnvironmentModal
popUp={popUp}