Merge remote-tracking branch 'origin/main' into feat/gitlabSecretSync

This commit is contained in:
carlosmonastyrski
2025-06-24 22:42:28 -03:00
213 changed files with 8222 additions and 1633 deletions
@@ -26,7 +26,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-1 right-1"
className="relative bottom-px right-1"
/>
</Tooltip>
}
@@ -59,7 +59,6 @@ export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...prop
onPointerDownOutside={(e) => e.preventDefault()}
className="max-w-2xl"
subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
@@ -0,0 +1,95 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import {
TCloudflareProject,
useCloudflareConnectionListPagesProjects
} from "@app/hooks/api/appConnections/cloudflare";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
const CLOUDFLARE_ENVIRONMENTS = [
{
name: "Preview",
value: "preview"
},
{
name: "Production",
value: "production"
}
];
export const CloudflarePagesSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.CloudflarePages }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: projects = [], isPending: isProjectsPending } =
useCloudflareConnectionListPagesProjects(connectionId, {
enabled: Boolean(connectionId)
});
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.projectName", "");
setValue("destinationConfig.environment", "preview");
}}
/>
<Controller
name="destinationConfig.projectName"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Project">
<FilterableSelect
isLoading={isProjectsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={projects ? (projects.find((project) => project.name === value) ?? []) : []}
onChange={(option) => {
onChange((option as SingleValue<TCloudflareProject>)?.name ?? null);
}}
options={projects}
placeholder="Select a project..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.environment"
control={control}
defaultValue="preview"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Environment"
tooltipClassName="max-w-lg py-3"
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an environment..."
dropdownContainerClassName="max-w-none"
>
{CLOUDFLARE_ENVIRONMENTS.map(({ name, value: envValue }) => (
<SelectItem className="capitalize" value={envValue} key={envValue}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
);
};
@@ -10,6 +10,7 @@ import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFiel
import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields";
import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields";
import { CamundaSyncFields } from "./CamundaSyncFields";
import { CloudflarePagesSyncFields } from "./CloudflarePagesSyncFields";
import { DatabricksSyncFields } from "./DatabricksSyncFields";
import { FlyioSyncFields } from "./FlyioSyncFields";
import { GcpSyncFields } from "./GcpSyncFields";
@@ -73,6 +74,8 @@ export const SecretSyncDestinationFields = () => {
return <FlyioSyncFields />;
case SecretSync.GitLab:
return <GitLabSyncFields />;
case SecretSync.CloudflarePages:
return <CloudflarePagesSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
@@ -56,6 +56,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.Render:
case SecretSync.Flyio:
case SecretSync.GitLab:
case SecretSync.CloudflarePages:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:
@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const CloudflarePagesSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.CloudflarePages }>();
const projectName = watch("destinationConfig.projectName");
const environment = watch("destinationConfig.environment");
return (
<>
<GenericFieldLabel label="Project">{projectName}</GenericFieldLabel>
<GenericFieldLabel label="Environment">{environment}</GenericFieldLabel>
</>
);
};
@@ -19,6 +19,7 @@ import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSy
import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields";
import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields";
import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields";
import { CloudflarePagesSyncReviewFields } from "./CloudflarePagesReviewFields";
import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
@@ -120,6 +121,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.GitLab:
DestinationFieldsComponent = <GitLabSyncReviewFields />;
break;
case SecretSync.CloudflarePages:
DestinationFieldsComponent = <CloudflarePagesSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
@@ -0,0 +1,14 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const CloudflarePagesSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.CloudflarePages),
destinationConfig: z.object({
projectName: z.string().trim().min(1, "Project name is required"),
environment: z.string().trim().min(1, "Environment is required")
})
})
);
@@ -7,6 +7,7 @@ import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configur
import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destination-schema";
import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema";
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
import { CloudflarePagesSyncDestinationSchema } from "./cloudflare-pages-sync-destination-schema";
import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema";
import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema";
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
@@ -43,7 +44,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
HerokuSyncDestinationSchema,
RenderSyncDestinationSchema,
FlyioSyncDestinationSchema,
GitlabSyncDestinationSchema
GitlabSyncDestinationSchema,
CloudflarePagesSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
@@ -16,7 +16,7 @@ export interface IsCliLoginSuccessful {
loginResponse?: {
email: string;
privateKey: string;
JWTToken: string;
JTWToken: string;
};
success: boolean;
}
@@ -131,7 +131,7 @@ const attemptLogin = async ({
loginResponse: {
email,
privateKey,
JWTToken: token
JTWToken: token
},
success: true
});
@@ -14,7 +14,7 @@ interface IsMfaLoginSuccessful {
success: boolean;
loginResponse: {
privateKey: string;
JWTToken: string;
JTWToken: string;
};
}
@@ -95,7 +95,7 @@ const attemptLoginMfa = async ({
success: true,
loginResponse: {
privateKey,
JWTToken: token
JTWToken: token
}
});
} catch (err) {
@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className,
isDisabled ? "pointer-events-none opacity-50" : ""
isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""
)}
>
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
+6
View File
@@ -16,6 +16,12 @@ export const ROUTE_PATHS = Object.freeze({
PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset"),
PasswordSetupPage: setRoute("/password-setup", "/_authenticate/password-setup")
},
Admin: {
IntegrationsPage: setRoute(
"/admin/integrations",
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations"
)
},
Organization: {
Settings: {
OauthCallbackPage: setRoute(
+4 -1
View File
@@ -18,6 +18,7 @@ import {
AzureDevOpsConnectionMethod,
AzureKeyVaultConnectionMethod,
CamundaConnectionMethod,
CloudflareConnectionMethod,
DatabricksConnectionMethod,
FlyioConnectionMethod,
GcpConnectionMethod,
@@ -86,7 +87,8 @@ export const APP_CONNECTION_MAP: Record<
[AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" },
[AppConnection.Render]: { name: "Render", image: "Render.png" },
[AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" },
[AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" }
[AppConnection.Gitlab]: { name: "GitLab", image: "GitLab.png" },
[AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -117,6 +119,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case TerraformCloudConnectionMethod.ApiToken:
case VercelConnectionMethod.ApiToken:
case OnePassConnectionMethod.ApiToken:
case CloudflareConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
+13 -5
View File
@@ -1,12 +1,20 @@
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faArrowRightToBracket, faEdit } from "@fortawesome/free-solid-svg-icons";
import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
export const policyDetails: Record<
PolicyType,
{ name: string; className: string; icon: IconDefinition }
> = {
[PolicyType.AccessPolicy]: {
className: "bg-lime-900 text-lime-100",
name: "Access Policy"
className: "bg-green/20 text-green",
name: "Access Policy",
icon: faArrowRightToBracket
},
[PolicyType.ChangePolicy]: {
className: "bg-indigo-900 text-indigo-100",
name: "Change Policy"
className: "bg-yellow/20 text-yellow",
name: "Change Policy",
icon: faEdit
}
};
+6 -1
View File
@@ -77,6 +77,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
[SecretSync.GitLab]: {
name: "GitLab",
image: "GitLab.png"
},
[SecretSync.CloudflarePages]: {
name: "Cloudflare Pages",
image: "Cloudflare.png"
}
};
@@ -101,7 +105,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Heroku]: AppConnection.Heroku,
[SecretSync.Render]: AppConnection.Render,
[SecretSync.Flyio]: AppConnection.Flyio,
[SecretSync.GitLab]: AppConnection.Gitlab
[SecretSync.GitLab]: AppConnection.Gitlab,
[SecretSync.CloudflarePages]: AppConnection.Cloudflare
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<
@@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests
const fetchApprovalRequests = async ({
projectSlug,
envSlug,
authorProjectMembershipId
authorUserId
}: TGetAccessApprovalRequestsDTO) => {
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
"/api/v1/access-approvals/requests",
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
{ params: { projectSlug, envSlug, authorUserId } }
);
return data.requests.map((request) => ({
@@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({
export const useGetAccessApprovalPolicies = ({
projectSlug,
envSlug,
authorProjectMembershipId,
authorUserId,
options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }),
...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
});
@@ -122,16 +122,13 @@ export const useGetAccessApprovalPolicies = ({
export const useGetAccessApprovalRequests = ({
projectSlug,
envSlug,
authorProjectMembershipId,
authorUserId,
options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envSlug,
authorProjectMembershipId
),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }),
...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
enabled: Boolean(projectSlug) && (options?.enabled ?? true),
placeholderData: (previousData) => previousData
});
@@ -35,7 +35,7 @@ export type Approver = {
id: string;
type: ApproverType;
sequence?: number;
approvals?: number;
approvalsRequired?: number;
};
export type Bypasser = {
@@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = {
export type TGetAccessApprovalRequestsDTO = {
projectSlug: string;
envSlug?: string;
authorProjectMembershipId?: string;
authorUserId?: string;
};
export type TGetAccessPolicyApprovalCountDTO = {
+12
View File
@@ -56,6 +56,11 @@ export type TUpdateServerConfigDTO = {
microsoftTeamsAppId?: string;
microsoftTeamsClientSecret?: string;
microsoftTeamsBotId?: string;
gitHubAppConnectionClientId?: string;
gitHubAppConnectionClientSecret?: string;
gitHubAppConnectionSlug?: string;
gitHubAppConnectionId?: string;
gitHubAppConnectionPrivateKey?: string;
} & Partial<TServerConfig>;
export type TCreateAdminUserDTO = {
@@ -100,6 +105,13 @@ export type AdminIntegrationsConfig = {
clientSecret: string;
botId: string;
};
gitHubAppConnection: {
clientId: string;
clientSecret: string;
appSlug: string;
appId: string;
privateKey: string;
};
};
export type TGetServerRootKmsEncryptionDetails = {
@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";
@@ -0,0 +1,37 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries";
import { TCloudflareProject } from "./types";
const cloudflareConnectionKeys = {
all: [...appConnectionKeys.all, "cloudflare"] as const,
listPagesProjects: (connectionId: string) =>
[...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const
};
export const useCloudflareConnectionListPagesProjects = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TCloudflareProject[],
unknown,
TCloudflareProject[],
ReturnType<typeof cloudflareConnectionKeys.listPagesProjects>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: cloudflareConnectionKeys.listPagesProjects(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TCloudflareProject[]>(
`/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-pages-projects`
);
return data;
},
...options
});
};
@@ -0,0 +1,4 @@
export type TCloudflareProject = {
id: string;
name: string;
};
@@ -26,5 +26,6 @@ export enum AppConnection {
Heroku = "heroku",
Render = "render",
Flyio = "flyio",
Gitlab = "gitlab"
Gitlab = "gitlab",
Cloudflare = "cloudflare"
}
@@ -128,6 +128,10 @@ export type TGitlabConnectionOption = TAppConnectionOptionBase & {
oauthClientId?: string;
};
export type TCloudflareConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Cloudflare;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -154,7 +158,8 @@ export type TAppConnectionOption =
| THerokuConnectionOption
| TRenderConnectionOption
| TFlyioConnectionOption
| TGitlabConnectionOption;
| TGitlabConnectionOption
| TCloudflareConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -185,4 +190,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.Render]: TRenderConnectionOption;
[AppConnection.Flyio]: TFlyioConnectionOption;
[AppConnection.Gitlab]: TGitlabConnectionOption;
[AppConnection.Cloudflare]: TCloudflareConnectionOption;
};
@@ -0,0 +1,14 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum CloudflareConnectionMethod {
ApiToken = "api-token"
}
export type TCloudflareConnection = TRootAppConnection & { app: AppConnection.Cloudflare } & {
method: CloudflareConnectionMethod.ApiToken;
credentials: {
apiToken: string;
accountId: string;
};
};
@@ -8,6 +8,7 @@ import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection
import { TAzureDevOpsConnection } from "./azure-devops-connection";
import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
import { TCamundaConnection } from "./camunda-connection";
import { TCloudflareConnection } from "./cloudflare-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TFlyioConnection } from "./flyio-connection";
import { TGcpConnection } from "./gcp-connection";
@@ -37,6 +38,7 @@ export * from "./azure-client-secrets-connection";
export * from "./azure-devops-connection";
export * from "./azure-key-vault-connection";
export * from "./camunda-connection";
export * from "./cloudflare-connection";
export * from "./databricks-connection";
export * from "./flyio-connection";
export * from "./gcp-connection";
@@ -86,7 +88,8 @@ export type TAppConnection =
| THerokuConnection
| TRenderConnection
| TFlyioConnection
| TGitlabConnection;
| TGitlabConnection
| TCloudflareConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -142,4 +145,5 @@ export type TAppConnectionMap = {
[AppConnection.Render]: TRenderConnection;
[AppConnection.Flyio]: TFlyioConnection;
[AppConnection.Gitlab]: TGitlabConnection;
[AppConnection.Cloudflare]: TCloudflareConnection;
};
-1
View File
@@ -73,7 +73,6 @@ export const selectOrganization = async (data: {
}) => {
const { data: res } = await apiRequest.post<{
token: string;
refreshToken: string;
isMfaEnabled: boolean;
mfaMethod?: MfaMethod;
}>("/api/v3/auth/select-organization", data);
+15 -2
View File
@@ -12,9 +12,10 @@ export type TDynamicSecret = {
defaultTTL: string;
status?: DynamicSecretStatus;
statusDetails?: string;
maxTTL: string;
maxTTL?: string;
usernameTemplate?: string | null;
metadata?: { key: string; value: string }[];
tags?: { key: string; value: string }[];
};
export enum DynamicSecretProviders {
@@ -35,7 +36,8 @@ export enum DynamicSecretProviders {
SapAse = "sap-ase",
Kubernetes = "kubernetes",
Vertica = "vertica",
GcpIam = "gcp-iam"
GcpIam = "gcp-iam",
Github = "github"
}
export enum KubernetesDynamicSecretCredentialType {
@@ -89,6 +91,7 @@ export type TDynamicSecretProvider =
}
| {
type: DynamicSecretProviders.AwsIam;
tags?: { key: string; value: string }[];
inputs:
| {
method: DynamicSecretAwsIamAuth.AccessKey;
@@ -333,6 +336,14 @@ export type TDynamicSecretProvider =
inputs: {
serviceAccountEmail: string;
};
}
| {
type: DynamicSecretProviders.Github;
inputs: {
appId: number;
installationId: number;
privateKey: string;
};
};
export type TCreateDynamicSecretDTO = {
@@ -345,6 +356,7 @@ export type TCreateDynamicSecretDTO = {
name: string;
metadata?: { key: string; value: string }[];
usernameTemplate?: string;
tags?: { key: string; value: string }[];
};
export type TUpdateDynamicSecretDTO = {
@@ -359,6 +371,7 @@ export type TUpdateDynamicSecretDTO = {
maxTTL?: string | null;
inputs?: unknown;
usernameTemplate?: string | null;
tags?: { key: string; value: string }[];
};
};
@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import {
decryptAssymmetric,
@@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = {
status,
committer,
offset,
limit
limit,
search
}: TGetSecretApprovalRequestList) =>
[
{ workspaceId, environment, status, committer, offset, limit },
{ workspaceId, environment, status, committer, offset, limit, search },
"secret-approval-requests"
] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
@@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({
committer,
status = "open",
limit = 20,
offset
offset = 0,
search = ""
}: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>(
"/api/v1/secret-approval-requests",
{
params: {
workspaceId,
environment,
committer,
status,
limit,
offset
}
const { data } = await apiRequest.get<{
approvals: TSecretApprovalRequest[];
totalCount: number;
}>("/api/v1/secret-approval-requests", {
params: {
workspaceId,
environment,
committer,
status,
limit,
offset,
search
}
);
});
return data.approvals;
return data;
};
export const useGetSecretApprovalRequests = ({
@@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({
options = {},
status,
limit = 20,
offset = 0,
search,
committer
}: TGetSecretApprovalRequestList & TReactQueryOptions) =>
useInfiniteQuery({
initialPageParam: 0,
useQuery({
queryKey: secretApprovalRequestKeys.list({
workspaceId,
environment,
committer,
status
status,
limit,
search,
offset
}),
queryFn: ({ pageParam }) =>
queryFn: () =>
fetchSecretApprovalRequestList({
workspaceId,
environment,
status,
committer,
limit,
offset: pageParam
offset,
search
}),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
getNextPageParam: (lastPage, pages) => {
if (lastPage.length && lastPage.length < limit) return undefined;
return lastPage?.length !== 0 ? pages.length * limit : undefined;
}
placeholderData: (previousData) => previousData
});
const fetchSecretApprovalRequestDetails = async ({
@@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = {
committer?: string;
limit?: number;
offset?: number;
search?: string;
};
export type TGetSecretApprovalRequestCount = {
+2 -1
View File
@@ -19,7 +19,8 @@ export enum SecretSync {
Heroku = "heroku",
Render = "render",
Flyio = "flyio",
GitLab = "gitlab"
GitLab = "gitlab",
CloudflarePages = "cloudflare-pages"
}
export enum SecretSyncStatus {
@@ -0,0 +1,16 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TCloudflarePagesSync = TRootSecretSync & {
destination: SecretSync.CloudflarePages;
destinationConfig: {
projectName: string;
environment: string;
};
connection: {
app: AppConnection.Cloudflare;
name: string;
id: string;
};
};
@@ -9,6 +9,7 @@ import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync";
import { TAzureDevOpsSync } from "./azure-devops-sync";
import { TAzureKeyVaultSync } from "./azure-key-vault-sync";
import { TCamundaSync } from "./camunda-sync";
import { TCloudflarePagesSync } from "./cloudflare-pages-sync";
import { TDatabricksSync } from "./databricks-sync";
import { TFlyioSync } from "./flyio-sync";
import { TGcpSync } from "./gcp-sync";
@@ -51,7 +52,8 @@ export type TSecretSync =
| THerokuSync
| TRenderSync
| TFlyioSync
| TGitlabSync;
| TGitlabSync
| TCloudflarePagesSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };
@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount
) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black">
<Badge variant="primary" className="ml-1.5">
{pendingRequestsCount}
</span>
</Badge>
)}
</MenuItem>
)}
@@ -0,0 +1,222 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaGithub } from "react-icons/fa";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
TextArea
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useUpdateServerConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
const gitHubAppFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
appSlug: z.string(),
appId: z.string(),
privateKey: z.string()
});
type TGitHubAppConnectionForm = z.infer<typeof gitHubAppFormSchema>;
type Props = {
adminIntegrationsConfig?: AdminIntegrationsConfig;
};
export const GitHubAppConnectionForm = ({ adminIntegrationsConfig }: Props) => {
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isGitHubAppClientSecretFocused, setIsGitHubAppClientSecretFocused] = useToggle();
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TGitHubAppConnectionForm>({
resolver: zodResolver(gitHubAppFormSchema)
});
const onSubmit = async (data: TGitHubAppConnectionForm) => {
await updateAdminServerConfig({
gitHubAppConnectionClientId: data.clientId,
gitHubAppConnectionClientSecret: data.clientSecret,
gitHubAppConnectionSlug: data.appSlug,
gitHubAppConnectionId: data.appId,
gitHubAppConnectionPrivateKey: data.privateKey
});
createNotification({
text: "Updated GitHub app connection configuration. It can take up to 5 minutes to take effect.",
type: "success"
});
};
useEffect(() => {
if (adminIntegrationsConfig) {
setValue("clientId", adminIntegrationsConfig.gitHubAppConnection.clientId);
setValue("clientSecret", adminIntegrationsConfig.gitHubAppConnection.clientSecret);
setValue("appSlug", adminIntegrationsConfig.gitHubAppConnection.appSlug);
setValue("appId", adminIntegrationsConfig.gitHubAppConnection.appId);
setValue("privateKey", adminIntegrationsConfig.gitHubAppConnection.privateKey);
}
}, [adminIntegrationsConfig]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="github-app-integration" className="data-[state=open]:border-none">
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<FaGithub className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">GitHub App</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<div className="flex w-full flex-col justify-start rounded-md rounded-t-none border border-t-0 border-mineshaft-500 bg-mineshaft-700 px-4 py-4">
<div className="mb-2 max-w-lg text-sm text-mineshaft-300">
Step 1: Create and configure GitHub App. Please refer to the documentation below for
more information.
</div>
<div className="mb-6">
<a
href="https://infisical.com/docs/integrations/app-connections/github#self-hosted-instance"
target="_blank"
rel="noopener noreferrer"
>
<Button colorSchema="secondary">Documentation</Button>
</a>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable GitHub App connections. Copy
the credentials from your GitHub App&apos;s settings page.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
tooltipText="You can find your Client Secret in the GitHub App's settings under 'Client secrets'."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isGitHubAppClientSecretFocused ? "text" : "password"}
onFocus={() => setIsGitHubAppClientSecretFocused.on()}
onBlur={() => setIsGitHubAppClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="appSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Slug"
tooltipText="The GitHub App slug from the app's URL (e.g., 'my-app' from github.com/apps/my-app)."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
tooltipText="The numeric App ID found in your GitHub App's settings."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Private Key"
tooltipText="The private key generated for your GitHub App (PEM format)."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea
{...field}
value={field.value || ""}
className="min-h-32"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
);
};
@@ -1,24 +1,94 @@
import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
import { GitHubAppConnectionForm } from "./GitHubAppConnectionForm";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
enum IntegrationTabSections {
Workflow = "workflow",
AppConnections = "app-connections"
}
interface WorkflowTabProps {
adminIntegrationsConfig: AdminIntegrationsConfig;
}
interface AppConnectionsTabProps {
adminIntegrationsConfig: AdminIntegrationsConfig;
}
const WorkflowTab = ({ adminIntegrationsConfig }: WorkflowTabProps) => (
<div className="flex flex-col gap-2">
<SlackIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
<MicrosoftTeamsIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
);
const AppConnectionsTab = ({ adminIntegrationsConfig }: AppConnectionsTabProps) => (
<div className="flex flex-col gap-2">
<GitHubAppConnectionForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
);
export const IntegrationsPageForm = () => {
const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig();
const navigate = useNavigate({
from: ROUTE_PATHS.Admin.IntegrationsPage.path
});
const selectedTab = useSearch({
from: ROUTE_PATHS.Admin.IntegrationsPage.id,
select: (el: { selectedTab?: string }) => el.selectedTab || IntegrationTabSections.Workflow,
structuralSharing: true
});
const updateSelectedTab = (tab: string) => {
navigate({
search: { selectedTab: tab }
});
};
const tabSections = [
{
key: IntegrationTabSections.Workflow,
label: "Workflows",
component: WorkflowTab
},
{
key: IntegrationTabSections.AppConnections,
label: "App Connections",
component: AppConnectionsTab
}
];
return (
<div className="mb-6 min-h-64 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<div className="text-xl font-semibold text-mineshaft-100">Integrations</div>
<div className="text-sm text-mineshaft-300">
Configure your instance-wide settings to enable integration with Slack and Microsoft
Teams.
Configure your instance-wide settings to enable integration with third-party services.
</div>
</div>
<div className="flex flex-col gap-2">
<SlackIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
<MicrosoftTeamsIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
{tabSections.map((section) => (
<Tab value={section.key} key={`integration-tab-${section.key}`}>
{section.label}
</Tab>
))}
</TabList>
{tabSections.map(({ key, component: Component }) => (
<TabPanel value={key} key={`integration-tab-panel-${key}`}>
<Component adminIntegrationsConfig={adminIntegrationsConfig!} />
</TabPanel>
))}
</Tabs>
</div>
);
};
@@ -75,7 +75,7 @@ export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsMicrosoftTeams className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Microsoft Teams Integration</div>
<div className="text-[15px] font-semibold">Microsoft Teams</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
@@ -108,7 +108,7 @@ export const SlackIntegrationForm = ({ adminIntegrationsConfig }: Props) => {
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsSlack className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Slack Integration</div>
<div className="text-[15px] font-semibold">Slack</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
@@ -76,9 +76,7 @@ export const PasswordStep = ({
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrganization({
organizationId
});
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
if (isMfaEnabled) {
SecurityClient.setMfaToken(token);
@@ -96,11 +94,10 @@ export const PasswordStep = ({
const payload = {
privateKey,
email,
JWTToken: token,
refreshToken
JTWToken: token
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in session storage
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
@@ -190,7 +187,7 @@ export const PasswordStep = ({
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
if (organizationId) {
const finishWithOrgWorkflow = async () => {
const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrganization({
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
organizationId
});
@@ -209,11 +206,10 @@ export const PasswordStep = ({
const instance = axios.create();
const payload = {
...isCliLoginSuccessful.loginResponse,
JWTToken: token,
refreshToken
JTWToken: token
};
await instance.post(cliUrl, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in session storage
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
@@ -112,7 +112,7 @@ export const SelectOrganizationSection = () => {
return;
}
const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrg
const { token, isMfaEnabled, mfaMethod } = await selectOrg
.mutateAsync({
organizationId: organization.id,
userAgent: callbackPort ? UserAgentType.CLI : undefined
@@ -149,16 +149,15 @@ export const SelectOrganizationSection = () => {
}
const payload = {
JWTToken: token,
JTWToken: token,
email: user?.email,
privateKey,
refreshToken
privateKey
} as IsCliLoginSuccessful["loginResponse"];
// send request to server endpoint
const instance = axios.create();
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
// if error happens to communicate we set the token with an expiry in session storage
// if error happens to communicate we set the token with an expiry in sessino storage
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
sessionStorage.setItem(
SessionStorageKeys.CLI_TERMINAL_TOKEN,
@@ -17,6 +17,7 @@ import { AzureClientSecretsConnectionForm } from "./AzureClientSecretsConnection
import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm";
import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm";
import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { CloudflareConnectionForm } from "./CloudflareConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { FlyioConnectionForm } from "./FlyioConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
@@ -131,6 +132,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <FlyioConnectionForm onSubmit={onSubmit} />;
case AppConnection.Gitlab:
return <GitLabConnectionForm onSubmit={onSubmit} />;
case AppConnection.Cloudflare:
return <CloudflareConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -223,6 +226,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <FlyioConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Gitlab:
return <GitLabConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Cloudflare:
return <CloudflareConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,152 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { CloudflareConnectionMethod, TCloudflareConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TCloudflareConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Cloudflare)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(CloudflareConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "API Token required"),
accountId: z.string().trim().min(1, "Account ID required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const CloudflareConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Cloudflare,
method: CloudflareConnectionMethod.ApiToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Cloudflare].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(CloudflareConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.accountId"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Account ID"
>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="802fcff12d4340f8e22feb5dd1d6ecac"
/>
</FormControl>
)}
/>
<Controller
name="credentials.apiToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="API Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Cloudflare"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -111,10 +111,10 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
? `Credentials have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See Docs to configure GitHub ${methodDetails.name} Connections.`
: `See Docs to configure Github ${methodDetails.name} Connections.`
}`
: error?.message
}
@@ -19,41 +19,38 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const taxIDTypes = [
{ label: "Australia ABN", value: "au_abn" },
{ label: "Australia ARN", value: "au_arn" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Brazil CNPJ", value: "br_cnpj" },
{ label: "Brazil CPF", value: "br_cpf" },
{ label: "Bulgaria UIC", value: "bg_uic" },
{ label: "Canada BN", value: "ca_bn" },
{ label: "Canada GST/HST", value: "ca_gst_hst" },
{ label: "Canada PST BC", value: "ca_pst_bc" },
{ label: "Canada PST MB", value: "ca_pst_mb" },
{ label: "Canada PST SK", value: "ca_pst_sk" },
{ label: "Canada QST", value: "ca_qst" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Chile TIN", value: "cl_tin" },
{ label: "Egypt TIN", value: "eg_tin" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "EU OSS VAT", value: "eu_oss_vat" },
{ label: "EU VAT", value: "eu_vat" },
{ label: "GB VAT", value: "gb_vat" },
{ label: "Georgia VAT", value: "ge_vat" },
{ label: "Hong Kong BR", value: "hk_br" },
{ label: "Hungary TIN", value: "hu_tin" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Indonesia NPWP", value: "id_npwp" },
{ label: "Israel VAT", value: "il_vat" },
{ label: "India GST", value: "in_gst" },
{ label: "Iceland VAT", value: "is_vat" },
{ label: "Japan CN", value: "jp_cn" },
{ label: "Japan RN", value: "jp_rn" },
{ label: "Japan TRN", value: "jp_trn" },
{ label: "Kenya PIN", value: "ke_pin" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Liechtenstein UID", value: "li_uid" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "Malaysia FRP", value: "my_frp" },
{ label: "Malaysia ITN", value: "my_itn" },
{ label: "Malaysia SST", value: "my_sst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "Mexico RFC", value: "mx_rfc" },
{ label: "New Zealand GST", value: "nz_gst" },
{ label: "Norway VAT", value: "no_vat" },
{ label: "Philippines TIN", value: "ph_tin" },
{ label: "Russia INN", value: "ru_inn" },
{ label: "Russia KPP", value: "ru_kpp" },
@@ -61,12 +58,15 @@ const taxIDTypes = [
{ label: "Singapore GST", value: "sg_gst" },
{ label: "Singapore UEN", value: "sg_uen" },
{ label: "Slovenia TIN", value: "si_tin" },
{ label: "South Africa VAT", value: "za_vat" },
{ label: "South Korea BRN", value: "kr_brn" },
{ label: "Spain CIF", value: "es_cif" },
{ label: "Switzerland VAT", value: "ch_vat" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Thailand VAT", value: "th_vat" },
{ label: "Turkey TIN", value: "tr_tin" },
{ label: "Taiwan VAT", value: "tw_vat" },
{ label: "Ukraine VAT", value: "ua_vat" },
{ label: "US EIN", value: "us_ein" },
{ label: "South Africa VAT", value: "za_vat" }
{ label: "Ukraine VAT", value: "ua_vat" }
];
const schema = z
@@ -0,0 +1,14 @@
import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TCloudflarePagesSync;
};
export const CloudflarePagesSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -7,6 +7,7 @@ import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfiguration
import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol";
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol";
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
@@ -70,6 +71,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <FlyioSyncDestinationCol secretSync={secretSync} />;
case SecretSync.GitLab:
return <GitLabSyncDestinationCol secretSync={secretSync} />;
case SecretSync.CloudflarePages:
return <CloudflarePagesSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -140,6 +140,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
throw new Error(`Unhandled GitLab Scope Destination Col Values ${destination}`);
}
break;
case SecretSync.CloudflarePages:
primaryText = destinationConfig.projectName;
secondaryText = destinationConfig.environment;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -1,7 +1,5 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
<PageHeader
title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders."
>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</PageHeader>
/>
<Tabs defaultValue={defaultTab}>
<TabList>
<Tab value={TabSection.SecretApprovalRequests}>
@@ -2,15 +2,25 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useMemo, useState } from "react";
import {
faArrowUpRightFromSquare,
faBan,
faBookOpen,
faCheck,
faCheckCircle,
faChevronDown,
faClipboardCheck,
faLock,
faPlus
faMagnifyingGlass,
faPlus,
faSearch,
faStopwatch,
faUser,
IconDefinition
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import {
@@ -21,6 +31,8 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Input,
Pagination,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -32,7 +44,12 @@ import {
useUser,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import {
accessApprovalKeys,
@@ -48,28 +65,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => {
const generateRequestText = (request: TAccessApprovalRequest) => {
const { isTemporary } = request;
return (
<div className="flex w-full items-center justify-between text-sm">
<div className="flex items-center justify-between text-sm">
<div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.policy.secretPath}
</code>
in
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
</code>{" "}
in{" "}
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.environmentName}
</code>
</div>
<div>
{request.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div>
);
};
@@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({
projectSlug
});
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
const {
data: requests,
refetch: refetchRequests,
isPending: areRequestsPending
} = useGetAccessApprovalRequests({
projectSlug,
authorProjectMembershipId: requestedByFilter,
authorUserId: requestedByFilter,
envSlug: envFilter
});
const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", {
initPerPage: getUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRequests = useMemo(() => {
let accessRequests: typeof requests;
if (statusFilter === "open")
return requests?.filter(
accessRequests = requests?.filter(
(request) =>
!request.policy.deletedAt &&
!request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
if (statusFilter === "close")
return requests?.filter(
accessRequests = requests?.filter(
(request) =>
request.policy.deletedAt ||
request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
return requests;
}, [requests, statusFilter, requestedByFilter, envFilter]);
return (
accessRequests?.filter((request) => {
const { environmentName, requestedByUser } = request;
const searchValue = search.trim().toLowerCase();
return (
environmentName?.toLowerCase().includes(searchValue) ||
`${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}`
.toLowerCase()
.includes(searchValue)
);
}) ?? []
);
}, [requests, statusFilter, requestedByFilter, envFilter, search]);
useResetPageHelper({
totalCount: filteredRequests.length,
offset,
setPage
});
const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => {
@@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({
const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
let displayData: {
label: string;
type: "primary" | "danger" | "success";
tooltipContent?: string;
icon: IconDefinition | null;
} = {
label: "",
type: "primary"
type: "primary",
icon: null
};
const isExpired =
@@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({
request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
if (isExpired)
displayData = {
label: "Access Expired",
type: "danger",
icon: faStopwatch,
tooltipContent: request.privilege?.temporaryAccessEndTime
? `Expired ${format(request.privilege.temporaryAccessEndTime, "M/d/yyyy h:mm aa")}`
: undefined
};
else if (isAccepted)
displayData = {
label: "Access Granted",
type: "success",
icon: faCheck,
tooltipContent: `Granted ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
else if (isRejectedByAnyone)
displayData = {
label: "Rejected",
type: "danger",
icon: faBan,
tooltipContent: `Rejected ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`,
type: "primary"
type: "primary",
icon: faClipboardCheck
};
} else if (!isReviewedByUser)
displayData = {
label: "Review Required",
type: "primary"
type: "primary",
icon: faClipboardCheck
};
return {
@@ -225,47 +297,71 @@ export const AccessApprovalRequest = ({
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
);
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
<div className="mt-2 text-sm text-bunker-300">
Request access to secrets in sensitive environments and folders.
</div>
</div>
<div>
<Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length}
>
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("requestAccess");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length}
>
Request access
</Button>
</Tooltip>
</div>
</div>
const isFiltered = Boolean(search || envFilter || requestedByFilter);
<AnimatePresence>
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
return (
<AnimatePresence mode="wait">
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
<a
href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] 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>
<p className="text-sm text-bunker-300">
Request and review access to secrets in sensitive environments and folders
</p>
</div>
<Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length}
>
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("requestAccess");
}}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length}
>
Request Access
</Button>
</Tooltip>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search approval requests by requesting user or environment..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div
role="button"
tabIndex={0}
@@ -273,17 +369,19 @@ export const AccessApprovalRequest = ({
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
className={twMerge(
"font-medium",
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
>
<FontAwesomeIcon icon={faLock} className="mr-2" />
{!!requestCount && requestCount?.pendingCount} Pending
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
className={twMerge(
"font-medium",
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
@@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{!!requestCount && requestCount.finalizedCount} Completed
{!!requestCount && requestCount.finalizedCount} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({
<Button
variant="plain"
colorSchema="secondary"
className="text-bunker-300"
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({
Requested By
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select Requesting User
</DropdownMenuLabel>
{members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem
onClick={() =>
setRequestedByFilter((state) => (state === id ? undefined : id))
setRequestedByFilter((state) =>
state === membershipUser.id ? undefined : membershipUser.id
)
}
key={`request-filter-member-${id}`}
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
icon={
requestedByFilter === membershipUser.id && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
{membershipUser.username}
@@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && (
{filteredRequests?.length === 0 && !isFiltered && (
<div className="py-12">
<EmptyState title="No more access requests pending." />
<EmptyState
title={`No ${statusFilter === "open" ? "Pending" : "Closed"} Access Requests`}
/>
</div>
)}
{Boolean(!filteredRequests?.length && isFiltered && !areRequestsPending) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
</div>
)}
{!!filteredRequests?.length &&
filteredRequests?.map((request) => {
filteredRequests?.slice(offset, perPage * page).map((request) => {
const details = generateRequestDetails(request);
return (
<div
key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
className="flex w-full cursor-pointer border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
tabIndex={0}
onClick={() => handleSelectRequest(request)}
@@ -379,14 +502,18 @@ export const AccessApprovalRequest = ({
}
}}
>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col justify-between">
<div className="mb-1 flex w-full items-center">
<FontAwesomeIcon icon={faLock} className="mr-2" />
{generateRequestText(request, user.id)}
<FontAwesomeIcon
icon={faLock}
size="xs"
className="mr-1.5 text-mineshaft-300"
/>
{generateRequestText(request)}
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
<div className="text-xs leading-3 text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && (
<>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
@@ -397,61 +524,86 @@ export const AccessApprovalRequest = ({
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{request.requestedByUserId === user.id && (
<div className="flex items-center gap-1.5 whitespace-nowrap text-xs text-bunker-300">
<FontAwesomeIcon icon={faUser} size="sm" />
<span>Requested By You</span>
</div>
)}
<Tooltip content={details.displayData.tooltipContent}>
<div>
<Badge variant={details.displayData.type}>
{details.displayData.label}
<Badge
className="flex items-center gap-1.5 whitespace-nowrap"
variant={details.displayData.type}
>
{details.displayData.icon && (
<FontAwesomeIcon icon={details.displayData.icon} />
)}
<span>{details.displayData.label}</span>
</Badge>
</div>
</div>
</Tooltip>
</div>
</div>
</div>
);
})}
{Boolean(filteredRequests.length) && (
<Pagination
className="border-none"
count={filteredRequests.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
{!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
/>
)}
{!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/>
)}
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/>
</div>
</motion.div>
</AnimatePresence>
);
};
@@ -185,7 +185,7 @@ export const ReviewAccessRequestModal = ({
return acc;
}
const approvals = curr.approvals || policy.approvals;
const approvals = curr.approvalsRequired || policy.approvals;
const sequence = curr.sequence || 1;
acc.push(
@@ -1,11 +1,19 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle,
faChevronDown,
faFileShield,
faPlus
faFilter,
faMagnifyingGlass,
faPlus,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
@@ -19,6 +27,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -36,7 +47,12 @@ import {
useWorkspace
} from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy,
@@ -45,6 +61,7 @@ import {
useListWorkspaceGroups
} from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
@@ -55,6 +72,18 @@ interface IProps {
workspaceId: string;
}
enum PolicyOrderBy {
Name = "name",
Environment = "environment",
SecretPath = "secret-path",
Type = "type"
}
type PolicyFilters = {
type: null | PolicyType;
environmentIds: string[];
};
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{
@@ -110,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
currentWorkspace
);
const [filterType, setFilterType] = useState<string | null>(null);
const [filters, setFilters] = useState<PolicyFilters>({
type: null,
environmentIds: []
});
const filteredPolicies = useMemo(() => {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
}, [policies, filterType]);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<PolicyOrderBy>(PolicyOrderBy.Name, {
initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredPolicies = useMemo(
() =>
policies
.filter(({ policyType, environment, name, secretPath }) => {
if (filters.type && policyType !== filters.type) return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
return false;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
environment.name.toLowerCase().includes(searchValue) ||
(secretPath ?? "*").toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PolicyOrderBy.Type:
return policyOne.policyType
.toLowerCase()
.localeCompare(policyTwo.policyType.toLowerCase());
case PolicyOrderBy.Environment:
return policyOne.environment.name
.toLowerCase()
.localeCompare(policyTwo.environment.name.toLowerCase());
case PolicyOrderBy.SecretPath:
return (policyOne.secretPath ?? "*")
.toLowerCase()
.localeCompare((policyTwo.secretPath ?? "*").toLowerCase());
case PolicyOrderBy.Name:
default:
return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase());
}
}),
[policies, filters, search, orderBy, orderDirection]
);
useResetPageHelper({
totalCount: filteredPolicies.length,
offset,
setPage
});
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
@@ -149,121 +246,280 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
}
};
const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length);
const handleSort = (column: PolicyOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: PolicyOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement granular policies for access requests and secrets management.
<AnimatePresence mode="wait">
<motion.div
key="approval-changes-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Policies</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] 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>
<p className="text-sm text-bunker-300">
Implement granular policies for access requests and secrets management
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create Policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
<div className="mb-4 flex items-center gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search policies by name, type, environment or secret path..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter findings"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
>
Create Policy
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className="text-xs font-semibold uppercase text-bunker-300"
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: null
}))
}
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.AccessPolicy
}))
}
icon={
filters.type === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.ChangePolicy
}))
}
icon={
filters.type === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{currentWorkspace.environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Environment
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Secret Path
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setFilterType(null)}
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)}
icon={
filterType === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)}
icon={
filterType === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton
columns={5}
innerKey="secret-policies"
className="bg-mineshaft-700"
/>
)}
{!isPoliciesLoading && !policies?.length && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No Policies Found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
filteredPolicies
?.slice(offset, perPage * page)
.map((policy) => (
<ApprovalPolicyRow
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
{Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && (
<EmptyState title="No Policies Match Search" icon={faSearch} />
)}
{!isPoliciesLoading && !filteredPolicies?.length && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
{Boolean(filteredPolicies.length) && (
<Pagination
count={filteredPolicies.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!!currentWorkspace &&
filteredPolicies?.map((policy) => (
<ApprovalPolicyRow
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
</TableContainer>
</div>
</motion.div>
<AccessPolicyForm
projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug}
@@ -284,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</div>
</AnimatePresence>
);
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { RefObject, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -112,21 +112,20 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({
isOpen,
const Form = ({
onToggle,
members = [],
projectId,
projectSlug,
editValues
}: Props) => {
editValues,
modalContainer,
isEditMode
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const modalContainer = useRef<HTMLDivElement>(null);
const {
control,
handleSubmit,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
@@ -153,34 +152,34 @@ export const AccessPolicyForm = ({
.map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [],
approvals: editValues?.approvals,
allowedSelfApprovals: editValues?.allowedSelfApprovals,
sequenceApprovers: editValues.approvers
?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0))
.reduce(
(acc, curr) => {
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || editValues.approvals;
acc.push(
curr.type === ApproverType.User
? {
user: [curr],
group: [],
sequence: 1,
approvals
}
: { group: [curr], user: [], sequence: 1, approvals }
);
sequenceApprovers: editValues.approvers?.reduce(
(acc, curr) => {
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
)
}
const approvals = curr.approvalsRequired || editValues.approvals;
acc.push(
curr.type === ApproverType.User
? {
user: [curr],
group: [],
sequence: 1,
approvals
}
: { group: [curr], user: [], sequence: 1, approvals }
);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
)
} as TFormSchema)
: undefined,
defaultValues: {
sequenceApprovers: [{ approvals: 1 }]
}
defaultValues: !editValues
? {
sequenceApprovers: [{ approvals: 1 }]
}
: undefined
});
const sequenceApproversFieldArray = useFieldArray({
control,
@@ -191,20 +190,14 @@ export const AccessPolicyForm = ({
const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
const enforcementLevel = watch("enforcementLevel");
const formUserBypassers = watch("userBypassers");
@@ -392,444 +385,452 @@ export const AccessPolicyForm = ({
setDragOverItem(null);
};
return (
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Self Approvals" isError={Boolean(error)} errorText={error?.message}>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
className="mb-3"
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) => onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)}
>
Allow certain users to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
{enforcementLevel === EnforcementLevel.Soft && (
<>
<div className="flex gap-2">
<Controller
control={control}
name="userBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={bypasserMemberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={bypasserGroupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
{bypasserCount <= 0 && (
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
Not selecting specific users or groups will allow anyone to bypass this policy.
</div>
)}
</>
)}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
);
};
export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => {
const modalContainer = useRef<HTMLDivElement>(null);
const isEditMode = Boolean(editValues);
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-3xl"
ref={modalContainer}
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
title={isEditMode ? "Edit Policy" : "Create Policy"}
>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="mb-2">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"
defaultValue
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Self Approvals"
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
id="self-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Allow approvers to review their own requests
</Switch>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Bypass Approvals"
isError={Boolean(error)}
errorText={error?.message}
className="mb-3"
>
<Switch
id="bypass-approvals"
thumbClassName="bg-mineshaft-800"
isChecked={value === EnforcementLevel.Soft}
onCheckedChange={(v) =>
onChange(v ? EnforcementLevel.Soft : EnforcementLevel.Hard)
}
>
Allow certain users to bypass policy in break-glass situations
</Switch>
</FormControl>
)}
/>
{enforcementLevel === EnforcementLevel.Soft && (
<>
<div className="flex gap-2">
<Controller
control={control}
name="userBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={bypasserMemberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupBypassers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Bypassers"
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={bypasserGroupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
{bypasserCount <= 0 && (
<div className="mt-1 flex rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm text-bunker-300">
Not selecting specific users or groups will allow anyone to bypass this policy.
</div>
)}
</>
)}
<div className="mt-8 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
Save
</Button>
<Button onClick={() => onToggle(false)} variant="outline_bg">
Close
</Button>
</div>
</form>
</div>
<Form
{...props}
isOpen={isOpen}
onToggle={onToggle}
editValues={editValues}
modalContainer={modalContainer}
isEditMode={isEditMode}
/>
</ModalContent>
</Modal>
);
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -9,6 +9,8 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td,
Tr
} from "@app/components/v2";
@@ -59,11 +61,11 @@ export const ApprovalPolicyRow = ({
const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0));
const entityInSameSequence = sortedSteps?.reduce(
(acc, curr) => {
if (acc.length && acc[acc.length - 1].sequence === curr.sequence) {
if (acc.length && acc[acc.length - 1].sequence === (curr.sequence || 1)) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || policy.approvals;
const approvals = curr.approvalsRequired || policy.approvals;
acc.push(
curr.type === ApproverType.User
? { user: [curr], group: [], sequence: 1, approvals }
@@ -80,11 +82,11 @@ export const ApprovalPolicyRow = ({
userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(","),
.join(", "),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
.join(","),
.join(", "),
approvals: el.approvals
};
});
@@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({
}}
onClick={() => setIsExpanded.toggle()}
>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
<Td>{policy.environment.name}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
<Badge
className={twMerge(
policyDetails[policy.policyType].className,
"flex w-min items-center gap-1.5 whitespace-nowrap"
)}
>
<FontAwesomeIcon icon={policyDetails[policy.policyType].icon} />
<span>{policyDetails[policy.policyType].name}</span>
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Policy
</DropdownMenuItem>
@@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Policy
</DropdownMenuItem>
@@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
</DropdownMenu>
</Td>
</Tr>
{isExpanded && (
<Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
<div className="text-lg">{index + 1}</div>
<Tr>
<Td colSpan={6} className="!border-none p-0">
<div
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ease-in-out ${
isExpanded ? "thin-scrollbar max-h-[26rem] !overflow-y-auto opacity-100" : "max-h-0"
}`}
>
<div className="p-4">
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
>
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
<div>{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
)}
{index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
<div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>{el.userLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
<div className="grid flex-grow grid-cols-3">
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
</div>
</div>
</div>
))}
</Td>
</Tr>
)}
))}
</div>
</div>
</Td>
</Tr>
</>
);
};
@@ -1,14 +1,19 @@
import { Fragment, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck,
faCheckCircle,
faChevronDown,
faCodeBranch
faCodeBranch,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import {
Button,
@@ -18,6 +23,8 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Input,
Pagination,
Skeleton
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
@@ -28,6 +35,12 @@ import {
useUser,
useWorkspace
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination } from "@app/hooks";
import {
useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests,
@@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => {
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const {
data: secretApprovalRequests,
isFetchingNextPage: isFetchingNextApprovalRequest,
fetchNextPage: fetchNextApprovalRequest,
hasNextPage: hasNextApprovalPage,
debouncedSearch: debouncedSearchFilter,
search: searchFilter,
setSearch: setSearchFilter,
setPage,
page,
perPage,
setPerPage,
offset,
limit
} = usePagination("", {
initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const {
data,
isPending: isApprovalRequestLoading,
refetch
} = useGetSecretApprovalRequests({
workspaceId,
status: statusFilter,
environment: envFilter,
committer: committerFilter
committer: committerFilter,
search: debouncedSearchFilter,
limit,
offset
});
const totalApprovalCount = data?.totalCount ?? 0;
const secretApprovalRequests = data?.approvals ?? [];
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser();
@@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => {
refetch();
};
const isRequestListEmpty =
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
return (
<AnimatePresence mode="wait">
@@ -116,178 +153,233 @@ export const SecretApprovalRequest = () => {
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Change Requests</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<div className="ml-1 mt-[0.32rem] 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>
<p className="text-sm text-bunker-300">Review pending and closed change requests</p>
</div>
</div>
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search change requests by author, environment or policy path..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={twMerge(
"font-medium",
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={twMerge(
"font-medium",
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("close");
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Author
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{user.username}
{name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Author
</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
{user.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && !isFiltered && (
<div className="py-12">
<EmptyState
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
/>
</div>
)}
{secretApprovalRequests.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1 text-sm">
<FontAwesomeIcon
icon={faCodeBranch}
size="sm"
className="mr-1.5 text-mineshaft-300"
/>
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs leading-3 text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
{Boolean(
!secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading
) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
</div>
)}
{Boolean(totalApprovalCount) && (
<Pagination
className="border-none"
count={totalApprovalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{isApprovalRequestLoading && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && (
<div className="py-12">
<EmptyState title="No more requests pending." />
</div>
)}
{secretApprovalRequests?.pages?.map((group, i) => (
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
</Fragment>
))}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)}
</div>
{hasNextApprovalPage && (
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div>
)}
</AnimatePresence>
@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.CREATE])
text.push(
<span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#60DD00" }}> created</span>
{score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
<span className="text-green-600"> Created</span>
</span>
);
if (score[CommitType.UPDATE])
text.push(
<span key="updated-commit">
{Boolean(text.length) && ","}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F8EB30" }} className="text-orange-600">
{" "}
updated
</span>
{Boolean(text.length) && ", "}
{score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
<span className="text-yellow-600"> Updated</span>
</span>
);
if (score[CommitType.DELETE])
text.push(
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F83030" }}> deleted</span>
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span className="text-red-600"> Deleted</span>
</span>
);
return text;
@@ -21,6 +21,8 @@ import {
} from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
const formSchema = z.object({
provider: z.discriminatedUnion("method", [
z.object({
@@ -32,7 +34,15 @@ const formSchema = z.object({
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
policyArns: z.string().trim().optional(),
tags: z
.array(
z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
)
.optional()
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -42,7 +52,15 @@ const formSchema = z.object({
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
policyArns: z.string().trim().optional(),
tags: z
.array(
z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
)
.optional()
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
@@ -398,6 +416,7 @@ export const AwsIamInputForm = ({
</FormControl>
)}
/>
<MetadataForm control={control} name="provider.tags" title="Tags" isValueRequired />
{!isSingleEnvironmentMode && (
<Controller
control={control}
@@ -11,7 +11,7 @@ import {
SiSnowflake
} from "react-icons/si";
import { VscAzure } from "react-icons/vsc";
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faAws, faGithub, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -26,6 +26,7 @@ import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { GcpIamInputForm } from "./GcpIamInputForm";
import { GithubInputForm } from "./GithubInputForm";
import { KubernetesInputForm } from "./KubernetesInputForm";
import { LdapInputForm } from "./LdapInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -143,6 +144,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <FontAwesomeIcon icon={faGoogle} size="lg" />,
provider: DynamicSecretProviders.GcpIam,
title: "GCP IAM"
},
{
icon: <FontAwesomeIcon icon={faGithub} size="lg" />,
provider: DynamicSecretProviders.Github,
title: "GitHub"
}
];
@@ -548,6 +554,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Github && (
<motion.div
key="dynamic-github-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<GithubInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -0,0 +1,234 @@
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 { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
Input,
SecretInput,
Tooltip
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const formSchema = z.object({
provider: z.object({
appId: z.coerce.number().min(1, "Required"),
installationId: z.coerce.number().min(1, "Required"),
privateKey: z
.string()
.trim()
.min(1, "Required")
.refine(
(val) =>
/^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test(
val
),
"Invalid PEM format for private key"
)
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const GithubInputForm = ({
onCompleted,
onCancel,
environments,
secretPath,
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
environment: isSingleEnvironmentMode && environments.length > 0 ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({ name, provider, environment }: TForm) => {
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: {
type: DynamicSecretProviders.Github,
inputs: {
...provider
}
},
defaultTTL: "1h", // Github is limited to 1 hour tokens
name,
path: secretPath,
projectSlug,
environmentSlug: environment.slug
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<FormControl
label={
<FormLabel
label="Default TTL"
icon={
<Tooltip content="Github token TTL is fixed to 1 hour">
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-px right-1"
/>
</Tooltip>
}
/>
}
>
<Input disabled value="1h" className="pointer-events-none opacity-50" />
</FormControl>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<Controller
control={control}
name="provider.appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="0000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.installationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Installation ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="00000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Private Key PEM"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -368,6 +368,22 @@ const renderOutputForm = (
);
}
if (provider === DynamicSecretProviders.Github) {
const { TOKEN } = data as {
TOKEN: string;
};
return (
<div>
<OutputDisplay
label="Token"
value={TOKEN}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};
@@ -608,6 +624,9 @@ export const CreateDynamicSecretLease = ({
return <Spinner className="mx-auto h-40 text-mineshaft-700" />;
}
// Github tokens are fixed to 1 hour
const fixedTtl = provider === DynamicSecretProviders.Github;
return (
<div>
<AnimatePresence>
@@ -629,8 +648,11 @@ export const CreateDynamicSecretLease = ({
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
fixedTtl ? `This provider has a fixed TTL of ${field.value}` : undefined
}
>
<Input {...field} />
<Input {...field} isDisabled={fixedTtl} />
</FormControl>
)}
/>
@@ -29,7 +29,7 @@ import {
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { DynamicSecretProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
@@ -44,6 +44,8 @@ type Props = {
onClose: () => void;
};
const DYNAMIC_SECRETS_WITHOUT_RENEWAL = [DynamicSecretProviders.Github];
export const DynamicSecretLease = ({
projectSlug,
dynamicSecretName,
@@ -94,6 +96,8 @@ export const DynamicSecretLease = ({
}
};
const canRenew = !DYNAMIC_SECRETS_WITHOUT_RENEWAL.includes(dynamicSecret.type);
return (
<div>
<TableContainer>
@@ -141,29 +145,31 @@ export const DynamicSecretLease = ({
</Td>
<Td>
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faRepeat} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
{canRenew && (
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
{(isAllowed) => (
<IconButton
ariaLabel="renew-lease"
variant="plain"
size="sm"
className="p-0"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("renewSecret", { leaseId: id })}
>
<FontAwesomeIcon icon={faRepeat} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
@@ -10,6 +10,8 @@ import { useUpdateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretAwsIamAuth, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { slugSchema } from "@app/lib/schemas";
import { MetadataForm } from "../MetadataForm";
const formSchema = z.object({
inputs: z.discriminatedUnion("method", [
z.object({
@@ -21,7 +23,10 @@ const formSchema = z.object({
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
policyArns: z.string().trim().optional(),
tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional()
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -31,7 +36,10 @@ const formSchema = z.object({
permissionBoundaryPolicyArn: z.string().trim().optional(),
policyDocument: z.string().trim().optional(),
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional()
policyArns: z.string().trim().optional(),
tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional()
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
@@ -380,6 +388,7 @@ export const EditDynamicSecretAwsIamForm = ({
</FormControl>
)}
/>
<MetadataForm control={control} name="inputs.tags" title="Tags" isValueRequired />
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
@@ -10,6 +10,7 @@ import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntra
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm";
import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm";
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
@@ -366,6 +367,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Github && (
<motion.div
key="github-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretGithubForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,199 @@
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 { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, Input, SecretInput, Tooltip } from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: z.object({
appId: z.coerce.number().min(1, "Required"),
installationId: z.coerce.number().min(1, "Required"),
privateKey: z
.string()
.trim()
.min(1, "Required")
.refine(
(val) =>
/^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test(
val
),
"Invalid PEM format for private key"
)
}),
newName: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretGithubForm = ({
onClose,
dynamicSecret,
secretPath,
environment,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => {
if (updateDynamicSecret.isPending) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<FormControl
label={
<FormLabel
label="Default TTL"
icon={
<Tooltip content="Github token TTL is fixed to 1 hour">
<FontAwesomeIcon
icon={faQuestionCircle}
size="sm"
className="relative bottom-px right-1"
/>
</Tooltip>
}
/>
}
>
<Input disabled value="1h" className="pointer-events-none opacity-50" />
</FormControl>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<Controller
control={control}
name="inputs.appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="0000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.installationId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Installation ID"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<Input placeholder="00000000" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Private Key PEM"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
isRequired
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -4,14 +4,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
export const MetadataForm = ({ control }: { control: Control<any> }) => {
export const MetadataForm = ({
control,
name = "metadata",
title = "Metadata",
isValueRequired = false
}: {
control: Control<any>;
name?: string;
title?: string;
isValueRequired?: boolean;
}) => {
const metadataFormFields = useFieldArray({
control,
name: "metadata"
name
});
return (
<FormControl label="Metadata">
<FormControl label={title}>
<div className="flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
@@ -19,7 +29,7 @@ export const MetadataForm = ({ control }: { control: Control<any> }) => {
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
name={`${name}.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
@@ -33,11 +43,15 @@ export const MetadataForm = ({ control }: { control: Control<any> }) => {
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
<FormLabel
label="Value"
className="text-xs text-mineshaft-400"
isOptional={!isValueRequired}
/>
)}
<Controller
control={control}
name={`metadata.${i}.value`}
name={`${name}.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
@@ -28,7 +28,7 @@ export const RenewDynamicSecretLease = ({
environment,
dynamicSecret
}: Props) => {
const maxTtlMs = ms(dynamicSecret.maxTTL);
const maxTtlMs = dynamicSecret.maxTTL ? ms(dynamicSecret.maxTTL) : undefined;
const formSchema = z.object({
ttl: z.string().superRefine((val, ctx) => {
@@ -39,7 +39,7 @@ export const RenewDynamicSecretLease = ({
code: z.ZodIssueCode.custom,
message: "TTL must be greater than 1 second"
});
if (valMs > maxTtlMs)
if (maxTtlMs && valMs > maxTtlMs)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `TTL must be less than ${dynamicSecret.maxTTL}`
@@ -0,0 +1,19 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TCloudflarePagesSync } from "@app/hooks/api/secretSyncs/types/cloudflare-pages-sync";
type Props = {
secretSync: TCloudflarePagesSync;
};
export const CloudflarePagesSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { projectName, environment }
} = secretSync;
return (
<>
<GenericFieldLabel label="Project">{projectName}</GenericFieldLabel>
<GenericFieldLabel label="Environment">{environment}</GenericFieldLabel>
</>
);
};
@@ -18,6 +18,7 @@ import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigura
import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationSection";
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection";
import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection";
import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
@@ -110,6 +111,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.GitLab:
DestinationComponents = <GitLabSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.CloudflarePages:
DestinationComponents = <CloudflarePagesSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
@@ -59,6 +59,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Render:
case SecretSync.Flyio:
case SecretSync.GitLab:
case SecretSync.CloudflarePages:
AdditionalSyncOptionsComponent = null;
break;
default: