feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
@@ -0,0 +1,72 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
import { EncryptionTab } from "./components/EncryptionTab";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { WebhooksTab } from "./components/WebhooksTab";
import { WorkflowIntegrationTab } from "./components/WorkflowIntegrationSection";
export const SettingsPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const tabs = [
{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab },
{
name: "Encryption",
key: "tab-project-encryption",
isHidden:
currentWorkspace?.version !== ProjectVersion.V3 ||
currentWorkspace?.type !== ProjectType.SecretManager,
Component: EncryptionTab
},
{
name: "Workflow Integrations",
key: "tab-workflow-integrations",
isHidden: currentWorkspace?.type !== ProjectType.SecretManager,
Component: WorkflowIntegrationTab
},
{
name: "Webhooks",
key: "tab-project-webhooks",
isHidden: currentWorkspace?.type !== ProjectType.SecretManager,
Component: WebhooksTab
}
];
return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="w-full max-w-7xl px-6">
<div className="my-6">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tabs defaultValue={tabs[0].key}>
<TabList>
{tabs
.filter((el) => !el.isHidden)
.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
</TabList>
{tabs
.filter((el) => !el.isHidden)
.map(({ key, Component }) => (
<TabPanel value={key} key={key}>
<Component />
</TabPanel>
))}
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useProjectPermission, useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { useUpdateWorkspaceAuditLogsRetention } from "@app/hooks/api/workspace/queries";
const formSchema = z.object({
auditLogsRetentionDays: z.coerce.number().min(0)
});
type TForm = z.infer<typeof formSchema>;
export const AuditLogsRetentionSection = () => {
const { mutateAsync: updateAuditLogsRetention } = useUpdateWorkspaceAuditLogsRetention();
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
auditLogsRetentionDays:
currentWorkspace?.auditLogsRetentionDays ?? subscription?.auditLogsRetentionDays ?? 0
}
});
if (!currentWorkspace) return null;
const handleAuditLogsRetentionSubmit = async ({ auditLogsRetentionDays }: TForm) => {
try {
if (!subscription?.auditLogs) {
handlePopUpOpen("upgradePlan", {
description: "You can only configure audit logs retention if you upgrade your plan."
});
return;
}
if (subscription && auditLogsRetentionDays > subscription?.auditLogsRetentionDays) {
handlePopUpOpen("upgradePlan", {
description:
"To update your audit logs retention period to a higher value, upgrade your plan."
});
return;
}
await updateAuditLogsRetention({
auditLogsRetentionDays,
projectSlug: currentWorkspace.slug
});
createNotification({
text: "Successfully updated audit logs retention period",
type: "success"
});
} catch {
createNotification({
text: "Failed updating audit logs retention period",
type: "error"
});
}
};
// render only for dedicated/self-hosted instances of Infisical
if (
window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com")
) {
return null;
}
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Audit Logs Retention</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
Set the number of days to keep your project audit logs.
</p>
<form onSubmit={handleSubmit(handleAuditLogsRetentionSubmit)} autoComplete="off">
<div className="max-w-xs">
<Controller
control={control}
defaultValue={0}
name="auditLogsRetentionDays"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Number of days"
>
<Input {...field} type="number" min={1} step={1} isDisabled={!isAdmin} />
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isAdmin || !isDirty}
>
Save
</Button>
</form>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</>
);
};
@@ -0,0 +1 @@
export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection";
@@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Checkbox } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggleAutoCapitalization } from "@app/hooks/api";
export const AutoCapitalizationSection = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const { mutateAsync } = useToggleAutoCapitalization();
const handleToggleCapitalizationToggle = async (state: boolean) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
workspaceID: currentWorkspace.id,
state
});
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
createNotification({
text,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update auto capitalization",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<Checkbox
className="data-[state=checked]:bg-primary"
id="autoCapitalization"
isDisabled={!isAllowed}
isChecked={currentWorkspace?.autoCapitalization ?? false}
onCheckedChange={(state) => {
handleToggleCapitalizationToggle(state as boolean);
}}
>
{t("settings.project.auto-capitalization-description")}
</Checkbox>
</div>
)}
</ProjectPermissionCan>
</div>
);
};
@@ -0,0 +1 @@
export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
@@ -0,0 +1,44 @@
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { useBackfillSecretReference } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
export const BackfillSecretReferenceSecretion = () => {
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const backfillSecretReferences = useBackfillSecretReference();
if (!currentWorkspace) return null;
const handleBackfill = async () => {
if (backfillSecretReferences.isPending) return;
try {
await backfillSecretReferences.mutateAsync({ projectId: currentWorkspace.id || "" });
createNotification({ text: "Successfully re-indexed secret references", type: "success" });
} catch {
createNotification({ text: "Failed to re-index secret references", type: "error" });
}
};
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Index Secret References</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This will index all secret references, enabling integrations to be triggered when their
values change going forward. This happens automatically when secrets are created or updated.
</p>
<Button
variant="outline_bg"
isLoading={backfillSecretReferences.isPending}
onClick={handleBackfill}
isDisabled={!isAdmin}
>
Index Secret References
</Button>
</div>
);
};
@@ -0,0 +1 @@
export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection";
@@ -0,0 +1,192 @@
import { useMemo } from "react";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { LeaveProjectModal } from "@app/components/v2/LeaveProjectModal";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useDeleteWorkspace, useGetWorkspaceUsers, useLeaveProject } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const DeleteProjectSection = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteWorkspace",
"leaveWorkspace"
] as const);
const { currentOrg } = useOrganization();
const { hasProjectRole, membership } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const [isDeleting, setIsDeleting] = useToggle();
const [isLeaving, setIsLeaving] = useToggle();
const deleteWorkspace = useDeleteWorkspace();
const leaveProject = useLeaveProject();
const { data: members, isPending: isMembersLoading } = useGetWorkspaceUsers(
currentWorkspace?.id || ""
);
// If isNoAccessMember is true, then the user can't read the workspace members. So we need to handle this case separately.
const isNoAccessMember = hasProjectRole("no-access");
const isOnlyAdminMember = useMemo(() => {
if (!members || !membership || !hasProjectRole("admin")) return false;
const adminMembers = members.filter(
(member) => member.roles.map((r) => r.role).includes("admin") && member.id !== membership.id // exclude the current user
);
return !adminMembers.length;
}, [members, membership]);
const handleDeleteWorkspaceSubmit = async () => {
setIsDeleting.on();
try {
if (!currentWorkspace?.id) return;
await deleteWorkspace.mutateAsync({
workspaceID: currentWorkspace?.id
});
createNotification({
text: "Successfully deleted project",
type: "success"
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
handlePopUpClose("deleteWorkspace");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete project",
type: "error"
});
} finally {
setIsDeleting.off();
}
};
const handleLeaveWorkspaceSubmit = async () => {
console.log({
currentWorkspace,
currentOrg,
members,
isNoAccessMember,
membership
});
try {
setIsLeaving.on();
if (!currentWorkspace?.id || !currentOrg?.id) return;
// If there's no members, and the user has access to read members, something went wrong.
if (!members && !isNoAccessMember) return;
// If the user has elevated permissions and can read members:
if (!isNoAccessMember) {
if (!members) return;
if (members.length < 2) {
createNotification({
text: "You can't leave the project as you are the only member",
type: "error"
});
return;
}
// If the user has access to read members, and there's less than 1 admin member excluding the current user, they can't leave the project.
if (isOnlyAdminMember) {
createNotification({
text: "You can't leave a project with no admin members left. Promote another member to admin first.",
type: "error"
});
return;
}
}
// If it's actually a no-access member, then we don't really care about the members.
await leaveProject.mutateAsync({
workspaceId: currentWorkspace.id
});
navigate({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to leave project",
type: "error"
});
} finally {
setIsLeaving.off();
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
<div className="space-x-4">
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
isLoading={isDeleting}
isDisabled={!isAllowed || isDeleting}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteWorkspace")}
>
{`Delete ${currentWorkspace?.name}`}
</Button>
)}
</ProjectPermissionCan>
{!isOnlyAdminMember && (
<Button
disabled={isMembersLoading || (members && members?.length < 2)}
isLoading={isLeaving}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("leaveWorkspace")}
>
{`Leave ${currentWorkspace?.name}`}
</Button>
)}
</div>
<DeleteActionModal
isOpen={popUp.deleteWorkspace.isOpen}
title="Are you sure want to delete this project?"
subTitle={`Permanently delete ${currentWorkspace?.name} and all of its data. This action is not reversible, so please be careful.`}
onChange={(isOpen) => handlePopUpToggle("deleteWorkspace", isOpen)}
deleteKey="confirm"
buttonText="Delete Project"
onDeleteApproved={handleDeleteWorkspaceSubmit}
/>
<LeaveProjectModal
isOpen={popUp.leaveWorkspace.isOpen}
title="Are you sure want to leave this project?"
subTitle={`If you leave ${currentWorkspace?.name} you will lose access to the project and it's contents.`}
onChange={(isOpen) => handlePopUpToggle("leaveWorkspace", isOpen)}
deleteKey="confirm"
buttonText="Leave Project"
onLeaveApproved={handleLeaveWorkspaceSubmit}
/>
</div>
);
};
@@ -0,0 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";
@@ -0,0 +1,349 @@
import { ChangeEvent, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import FileSaver from "file-saver";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useGetActiveProjectKms,
useGetExternalKmsList,
useLoadProjectKmsBackup,
useUpdateProjectKms
} from "@app/hooks/api";
import { fetchProjectKmsBackup } from "@app/hooks/api/kms/queries";
import { INTERNAL_KMS_KEY_ID, KmsType } from "@app/hooks/api/kms/types";
import { Organization, Workspace } from "@app/hooks/api/types";
const formSchema = z.object({
kmsKeyId: z.string()
});
type TForm = z.infer<typeof formSchema>;
const BackupConfirmationModal = ({
isOpen,
onOpenChange,
org,
workspace
}: {
isOpen: boolean;
onOpenChange: (state: boolean) => void;
org?: Organization;
workspace?: Workspace;
}) => {
const [isGeneratingBackup, setGeneratingBackup] = useToggle();
const downloadKmsBackup = async () => {
if (!workspace || !org) {
return;
}
setGeneratingBackup.on();
try {
const { secretManager } = await fetchProjectKmsBackup(workspace.id);
const [, , kmsFunction] = secretManager.split(".");
const file = secretManager;
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
FileSaver.saveAs(
blob,
`kms-backup-${org.slug}-${workspace.slug}-${kmsFunction}.infisical.txt`
);
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create KMS backup",
type: "error"
});
}
setGeneratingBackup.off();
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Create KMS backup">
<p className="mb-6 text-bunker-300">
In case of interruptions with your configured external KMS, you can load a backup to set
the project&apos;s KMS back to the default Infisical KMS.
</p>
<Button onClick={downloadKmsBackup} isLoading={isGeneratingBackup}>
Generate
</Button>
<Button
onClick={() => onOpenChange(false)}
colorSchema="secondary"
variant="plain"
className="ml-4"
>
Cancel
</Button>
</ModalContent>
</Modal>
);
};
const LoadBackupModal = ({
isOpen,
onOpenChange,
org,
workspace
}: {
isOpen: boolean;
onOpenChange: (state: boolean) => void;
org?: Organization;
workspace: Workspace;
}) => {
const fileUploadRef = useRef<HTMLInputElement>(null);
const { mutateAsync: loadKmsBackup, isPending } = useLoadProjectKmsBackup(workspace.id);
const [backupContent, setBackupContent] = useState("");
const [backupFileName, setBackupFileName] = useState("");
const uploadKmsBackup = async () => {
if (!workspace || !org) {
return;
}
try {
await loadKmsBackup(backupContent);
createNotification({
text: "Successfully loaded KMS backup",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
}
};
const parseFile = (file?: File) => {
if (!file) {
createNotification({
text: "Failed to parse uploaded file.",
type: "error"
});
return;
}
const reader = new FileReader();
reader.onload = (event) => {
if (!event?.target?.result) return;
const data = event.target.result.toString();
setBackupContent(data);
};
try {
reader.readAsText(file);
} catch (error) {
console.log(error);
}
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
parseFile(e.target?.files?.[0]);
setBackupFileName(e.target?.files?.[0]?.name || "");
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(state: boolean) => {
setBackupContent("");
setBackupFileName("");
onOpenChange(state);
}}
>
<ModalContent title="Load KMS backup">
<p className="mb-6 text-bunker-300">
By loading a backup, the project&apos;s KMS will be switched to the default Infisical KMS.
</p>
<div className="flex justify-center">
<input
id="fileSelect"
className="hidden"
type="file"
accept=".txt"
onChange={handleFileUpload}
ref={fileUploadRef}
/>
<IconButton
className="p-10"
ariaLabel="upload-backup"
colorSchema="secondary"
onClick={() => {
fileUploadRef?.current?.click();
}}
>
<FontAwesomeIcon icon={faUpload} size="3x" />
</IconButton>
</div>
{backupFileName && (
<div className="mt-2 flex justify-center px-4 text-center">{backupFileName}</div>
)}
{backupContent && (
<Button
onClick={uploadKmsBackup}
className="mt-10 w-fit"
disabled={isPending}
isLoading={isPending}
>
Continue
</Button>
)}
</ModalContent>
</Modal>
);
};
export const EncryptionTab = () => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { data: externalKmsList } = useGetExternalKmsList(currentOrg.id);
const { data: activeKms } = useGetActiveProjectKms(currentWorkspace.id);
const { mutateAsync: updateProjectKms, isPending: isUpdatingProjectKms } = useUpdateProjectKms(
currentWorkspace.id
);
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"createBackupConfirmation",
"loadBackup"
] as const);
const kmsKeyId = activeKms?.isExternal ? activeKms.id : INTERNAL_KMS_KEY_ID;
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
kmsKeyId
}
});
const onUpdateProjectKms = async (data: TForm) => {
try {
await updateProjectKms(
data.kmsKeyId === INTERNAL_KMS_KEY_ID
? { type: KmsType.Internal }
: { type: KmsType.External, kmsId: data.kmsKeyId }
);
createNotification({
text: "Successfully updated project KMS",
type: "success"
});
} catch (err) {
console.error(err);
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Key Management</h2>
{kmsKeyId !== INTERNAL_KMS_KEY_ID && (
<div className="space-x-2">
<Button colorSchema="secondary" onClick={() => handlePopUpOpen("loadBackup")}>
Load Backup
</Button>
<Button
colorSchema="secondary"
onClick={() => {
handlePopUpOpen("createBackupConfirmation");
}}
>
Create Backup
</Button>
</div>
)}
</div>
<p className="mb-4 text-gray-400">
Select which Key Management System to use for encrypting your project data
</p>
<div className="mb-6 max-w-md">
<form onSubmit={handleSubmit(onUpdateProjectKms)}>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Kms}>
{(isAllowed) => (
<Controller
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Select
{...field}
isDisabled={!isAllowed || isUpdatingProjectKms}
onValueChange={onChange}
isLoading={isUpdatingProjectKms}
className="w-3/4 bg-mineshaft-600"
>
<SelectItem value={INTERNAL_KMS_KEY_ID} key="kms-internal">
Default Infisical KMS
</SelectItem>
{externalKmsList?.map((kms) => (
<SelectItem value={kms.id} key={`kms-${kms.id}`}>
{kms.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
control={control}
name="kmsKeyId"
/>
)}
</ProjectPermissionCan>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isDisabled={!isAllowed || isSubmitting || !isDirty}
isLoading={isSubmitting}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
<BackupConfirmationModal
isOpen={popUp.createBackupConfirmation.isOpen}
onOpenChange={(state: boolean) => handlePopUpToggle("createBackupConfirmation", state)}
org={currentOrg}
workspace={currentWorkspace}
/>
<LoadBackupModal
isOpen={popUp.loadBackup.isOpen}
onOpenChange={(state: boolean) => handlePopUpToggle("loadBackup", state)}
org={currentOrg}
workspace={currentWorkspace}
/>
</div>
);
};
@@ -0,0 +1 @@
export { EncryptionTab } from "./EncryptionTab";
@@ -0,0 +1,121 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateWsEnvironment } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
type Props = {
popUp: UsePopUpState<["createEnv"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void;
};
const schema = z.object({
environmentName: z
.string()
.min(1, { message: "Environment Name field must be at least 1 character" }),
environmentSlug: slugSchema()
});
export type FormData = z.infer<typeof schema>;
export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useCreateWsEnvironment();
const { control, handleSubmit, reset } = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ environmentName, environmentSlug }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
workspaceId: currentWorkspace.id,
name: environmentName,
slug: environmentSlug
});
createNotification({
text: "Successfully created environment",
type: "success"
});
handlePopUpClose("createEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create environment",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.createEnv?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("createEnv", isOpen);
reset();
}}
>
<ModalContent title="Create a new environment">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="environmentName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="environmentSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Slug"
helperText="Slugs are shorthands used in cli to access environment"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isPending}
isDisabled={isPending}
>
Create
</Button>
<Button
onClick={() => handlePopUpClose("createEnv")}
colorSchema="secondary"
variant="plain"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,131 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { useDeleteWsEnvironment } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddEnvironmentModal } from "./AddEnvironmentModal";
import { EnvironmentTable } from "./EnvironmentTable";
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
export const EnvironmentSection = () => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const deleteWsEnvironment = useDeleteWsEnvironment();
const isMoreEnvironmentsAllowed =
subscription?.environmentLimit && currentWorkspace?.environments
? currentWorkspace.environments.length < subscription.environmentLimit
: true;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"createEnv",
"updateEnv",
"deleteEnv",
"upgradePlan"
] as const);
const onEnvDeleteSubmit = async (id: string) => {
try {
if (!currentWorkspace?.id) return;
await deleteWsEnvironment.mutateAsync({
workspaceId: currentWorkspace.id,
id
});
createNotification({
text: "Successfully deleted environment",
type: "success"
});
handlePopUpClose("deleteEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
};
return (
<div
id="environments"
className="mb-6 scroll-m-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="mb-8 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Environments</p>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createEnv");
} else {
handlePopUpOpen("upgradePlan");
}
}}
isDisabled={!isAllowed}
>
Create environment
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<p className="mb-8 text-gray-400">
Choose which environments will show up in your dashboard like development, staging,
production
</p>
{permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments) ? (
<EnvironmentTable handlePopUpOpen={handlePopUpOpen} />
) : (
<PermissionDeniedBanner />
)}
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteEnv", isOpen)}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""}
onDeleteApproved={() => onEnvDeleteSubmit((popUp?.deleteEnv?.data as { id: string })?.id)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div>
);
};
@@ -0,0 +1,172 @@
import { faArrowDown, faArrowUp, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateWsEnvironment } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>,
{
name,
slug,
id
}: {
name: string;
slug: string;
id: string;
}
) => void;
};
export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const updateEnvironment = useUpdateWsEnvironment();
const handleReorderEnv = async (id: string, position: number) => {
try {
if (!currentWorkspace?.id) return;
await updateEnvironment.mutateAsync({
workspaceId: currentWorkspace.id,
id,
position
});
createNotification({
text: "Successfully re-ordered environments",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to re-order environments",
type: "error"
});
}
};
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{currentWorkspace.environments.map(({ name, slug, id }, pos) => (
<Tr key={id}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<IconButton
className="mr-3 py-2"
onClick={() =>
handleReorderEnv(
id,
Math.min(currentWorkspace.environments.length, pos + 2)
)
}
colorSchema="primary"
variant="plain"
ariaLabel="update"
isDisabled={pos === currentWorkspace.environments.length - 1 || !isAllowed}
>
<FontAwesomeIcon icon={faArrowDown} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<IconButton
className="mr-3 py-2"
onClick={() => handleReorderEnv(id, Math.max(1, pos))}
colorSchema="primary"
variant="plain"
ariaLabel="update"
isDisabled={pos === 0 || !isAllowed}
>
<FontAwesomeIcon icon={faArrowUp} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<IconButton
className="mr-3 py-2"
onClick={() => {
handlePopUpOpen("updateEnv", { name, slug, id });
}}
isDisabled={!isAllowed}
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug, id });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{currentWorkspace.environments?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No environments found" />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1,123 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useUpdateWsEnvironment } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
type Props = {
popUp: UsePopUpState<["updateEnv"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["updateEnv"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void;
};
const schema = z.object({
name: z.string(),
slug: slugSchema({ min: 1 })
});
export type FormData = z.infer<typeof schema>;
export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateWsEnvironment();
const { control, handleSubmit, reset } = useForm<FormData>({
resolver: zodResolver(schema),
values: popUp.updateEnv.data as FormData
});
const oldEnvId = (popUp?.updateEnv?.data as { id: string })?.id;
const onFormSubmit = async ({ name, slug }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
workspaceId: currentWorkspace.id,
name,
slug,
id: oldEnvId
});
createNotification({
text: "Successfully updated environment",
type: "success"
});
handlePopUpClose("updateEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update environment",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.updateEnv?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("updateEnv", isOpen);
reset();
}}
>
<ModalContent title="Update environment">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Environment Slug"
helperText="Slugs are shorthands used in cli to access environment"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isPending}
isDisabled={isPending}
>
Update
</Button>
<Button
onClick={() => handlePopUpClose("updateEnv")}
colorSchema="secondary"
variant="plain"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { EnvironmentSection } from "./EnvironmentSection";
@@ -0,0 +1,93 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { useUpdateWorkspaceVersionLimit } from "@app/hooks/api/workspace/queries";
const formSchema = z.object({
pitVersionLimit: z.coerce.number().min(1).max(100)
});
type TForm = z.infer<typeof formSchema>;
export const PointInTimeVersionLimitSection = () => {
const { mutateAsync: updatePitVersion } = useUpdateWorkspaceVersionLimit();
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const {
control,
formState: { isSubmitting, isDirty },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
pitVersionLimit: currentWorkspace?.pitVersionLimit || 10
}
});
if (!currentWorkspace) return null;
const handleVersionLimitSubmit = async ({ pitVersionLimit }: TForm) => {
try {
await updatePitVersion({
pitVersionLimit,
projectSlug: currentWorkspace.slug
});
createNotification({
text: "Successfully updated version limit",
type: "success"
});
} catch {
createNotification({
text: "Failed updating project's version limit",
type: "error"
});
}
};
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Version Retention</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This defines the maximum number of recent secret versions to keep per folder. Excess
versions will be removed at midnight (UTC) each day.
</p>
<form onSubmit={handleSubmit(handleVersionLimitSubmit)} autoComplete="off">
<div className="max-w-xs">
<Controller
control={control}
defaultValue={0}
name="pitVersionLimit"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Recent versions to keep"
>
<Input {...field} type="number" min={1} step={1} isDisabled={!isAdmin} />
</FormControl>
)}
/>
</div>
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
disabled={!isAdmin || !isDirty}
>
Save
</Button>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { PointInTimeVersionLimitSection } from "./PointInTimeVersionLimitSection";
@@ -0,0 +1,33 @@
import { useWorkspace } from "@app/context";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSection";
import { DeleteProjectSection } from "../DeleteProjectSection";
import { EnvironmentSection } from "../EnvironmentSection";
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
import { SecretTagsSection } from "../SecretTagsSection";
export const ProjectGeneralTab = () => {
const { currentWorkspace } = useWorkspace();
const isSecretManager = currentWorkspace?.type === ProjectType.SecretManager;
return (
<div>
<ProjectOverviewChangeSection />
{isSecretManager && <EnvironmentSection />}
{isSecretManager && <SecretTagsSection />}
{isSecretManager && <AutoCapitalizationSection />}
{isSecretManager && <PointInTimeVersionLimitSection />}
<AuditLogsRetentionSection />
{isSecretManager && <BackfillSecretReferenceSecretion />}
{currentWorkspace?.version !== ProjectVersion.V3 && isSecretManager && (
<RebuildSecretIndicesSection />
)}
<DeleteProjectSection />
</div>
);
};
@@ -0,0 +1 @@
export { ProjectGeneralTab } from "./ProjectGeneralTab";
@@ -0,0 +1,51 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};
@@ -0,0 +1,168 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -0,0 +1,93 @@
import { createNotification } from "@app/components/notifications";
import {
decryptAssymmetric,
decryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetUserWsKey, useNameWorkspaceSecrets } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { fetchWorkspaceSecrets } from "@app/hooks/api/workspace/queries";
export const RebuildSecretIndicesSection = () => {
const { currentWorkspace } = useWorkspace();
const { membership } = useProjectPermission();
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const [isIndexing, setIsIndexing] = useToggle();
const { data: decryptFileKey } = useGetUserWsKey(currentWorkspace.id);
if (!currentWorkspace) return null;
const onRebuildIndices = async () => {
if (!currentWorkspace?.id) return;
setIsIndexing.on();
try {
const encryptedSecrets = await fetchWorkspaceSecrets(currentWorkspace.id);
if (!currentWorkspace || !decryptFileKey) {
return;
}
const key = decryptAssymmetric({
ciphertext: decryptFileKey.encryptedKey,
nonce: decryptFileKey.nonce,
publicKey: decryptFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
});
return {
secretName,
secretId: encryptedSecret.id
};
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace.id,
secretsToUpdate
});
createNotification({
text: "Successfully rebuilt secret indices",
type: "success"
});
} catch (err) {
console.log(err);
} finally {
setIsIndexing.off();
}
};
const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin);
if (!isAdmin) {
return null;
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex w-full items-center justify-between">
<p className="text-xl font-semibold">Rebuild Secret Indices</p>
</div>
<p className="mb-4 mt-2 max-w-2xl text-sm text-gray-400">
This will rebuild indices of all secrets in the project.
</p>
<Button
variant="outline_bg"
isLoading={isIndexing}
onClick={onRebuildIndices}
isDisabled={!isAdmin}
>
Rebuild Secret Indices
</Button>
</div>
);
};
@@ -0,0 +1,109 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateWsTag } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
const schema = z.object({
slug: slugSchema({ min: 1, field: "Tag Slug" })
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>;
handlePopUpClose: (
popUpName: keyof UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>,
state?: boolean
) => void;
};
export const AddSecretTagModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const createWsTag = useCreateWsTag();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ slug }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await createWsTag.mutateAsync({
workspaceID: currentWorkspace?.id,
tagSlug: slug,
tagColor: ""
});
handlePopUpClose("CreateSecretTag");
createNotification({
text: "Successfully created a tag",
type: "success"
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create a tag",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.CreateSecretTag?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("CreateSecretTag", open);
reset();
}}
>
<ModalContent
title={`Add a tag for ${currentWorkspace?.name ?? ""}`}
subTitle="Specify your tag name, and the slug will be created automatically."
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="slug"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Tag Slug" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your tag slug" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,98 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteWsTag } from "@app/hooks/api";
import { AddSecretTagModal } from "./AddSecretTagModal";
import { SecretTagsTable } from "./SecretTagsTable";
type DeleteModalData = { name: string; id: string };
export const SecretTagsSection = (): JSX.Element => {
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"CreateSecretTag",
"deleteTagConfirmation"
] as const);
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const deleteWsTag = useDeleteWsTag();
const onDeleteApproved = async () => {
try {
await deleteWsTag.mutateAsync({
projectId: currentWorkspace?.id || "",
tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id
});
createNotification({
text: "Successfully deleted tag",
type: "success"
});
handlePopUpClose("deleteTagConfirmation");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete the tag",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex justify-between">
<p className="mb-3 text-xl font-semibold">Secret Tags</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Tags}>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("CreateSecretTag");
}}
isDisabled={!isAllowed}
>
Create Tag
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">
Every secret can be assigned to one or more tags. Here you can add and remove tags for the
current project.
</p>
{permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? (
<SecretTagsTable handlePopUpOpen={handlePopUpOpen} />
) : (
<PermissionDeniedBanner />
)}
<AddSecretTagModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name || " "
} api key?`}
onChange={(isOpen) => handlePopUpToggle("deleteTagConfirmation", isOpen)}
deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteTagConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
};
@@ -0,0 +1,166 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faTag,
faTrashCan
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWsTags } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
};
enum TagsOrderBy {
Slug = "slug"
}
export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: tags = [], isPending } = useGetWsTags(currentWorkspace?.id ?? "");
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(TagsOrderBy.Slug, { initPerPage: 10 });
const filteredTags = useMemo(
() =>
tags
.filter((tag) => tag.slug.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => {
const [tagOne, tagTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return tagOne.slug.toLowerCase().localeCompare(tagTwo.slug.toLowerCase());
}),
[tags, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredTags.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search tags..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-full">
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="secret-tags" />}
{!isPending &&
filteredTags.slice(offset, perPage * page).map(({ id, slug }) => (
<Tr key={id}>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name: slug,
id
})
}
size="xs"
colorSchema="danger"
ariaLabel="update"
variant="plain"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
</TBody>
</Table>
{Boolean(filteredTags.length) && (
<Pagination
count={filteredTags.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isPending && !filteredTags?.length && (
<EmptyState
title={tags.length ? "No tags match search..." : "No tags found for project"}
icon={tags.length ? faSearch : faTag}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { SecretTagsSection } from "./SecretTagsSection";
@@ -0,0 +1,200 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import {
Button,
FormControl,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { WebhookType } from "@app/hooks/api/webhooks/types";
const formSchema = z
.object({
environment: z.string().trim().describe("Environment"),
webhookUrl: z.string().url().trim().describe("Webhook URL"),
webhookSecretKey: z.string().trim().optional().describe("Secret Key"),
secretPath: z.string().trim().describe("Secret Path"),
type: z.nativeEnum(WebhookType).describe("Type").default(WebhookType.GENERAL)
})
.superRefine((data, ctx) => {
if (data.type === WebhookType.SLACK && !data.webhookUrl.includes("hooks.slack.com")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Incoming Webhook URL is invalid.",
path: ["webhookUrl"]
});
}
});
export type TFormSchema = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onCreateWebhook: (data: TFormSchema) => void;
environments?: Array<{ slug: string; name: string }>;
};
export const AddWebhookForm = ({
isOpen,
onOpenChange,
onCreateWebhook,
environments = []
}: Props) => {
const {
control,
handleSubmit,
register,
reset,
watch,
formState: { errors, isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
type: WebhookType.GENERAL
}
});
const selectedWebhookType = watch("type");
const selectedEnvironment = watch("environment");
const generalFormFields = (
<>
<FormControl
label="Secret Key"
isError={Boolean(errors?.webhookSecretKey)}
errorText={errors?.webhookSecretKey?.message}
helperText="To generate webhook signature for verification"
>
<Input placeholder="Provided during webhook setup" {...register("webhookSecretKey")} />
</FormControl>
<FormControl
label="Webhook URL"
isRequired
isError={Boolean(errors?.webhookUrl)}
errorText={errors?.webhookUrl?.message}
>
<Input {...register("webhookUrl")} />
</FormControl>
</>
);
const slackFormFields = (
<FormControl
label="Incoming Webhook URL"
isRequired
isError={Boolean(errors?.webhookUrl)}
errorText={errors?.webhookUrl?.message}
>
<Input placeholder="https://hooks.slack.com/services/..." {...register("webhookUrl")} />
</FormControl>
);
useEffect(() => {
if (!isOpen) {
reset();
}
}, [isOpen]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Create a new webhook">
<form onSubmit={handleSubmit(onCreateWebhook)}>
<div>
<Controller
control={control}
name="type"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Type"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
<SelectItem value={WebhookType.GENERAL} key={WebhookType.GENERAL}>
General
</SelectItem>
<SelectItem value={WebhookType.SLACK} key={WebhookType.SLACK}>
Slack
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Path"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<SecretPathInput {...field} environment={selectedEnvironment} placeholder="/" />
</FormControl>
)}
/>
{selectedWebhookType === WebhookType.SLACK ? slackFormFields : generalFormFields}
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,321 @@
import { useTranslation } from "react-i18next";
import { faInfoCircle, faPlug, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
useCreateWebhook,
useDeleteWebhook,
useGetWebhooks,
useTestWebhook,
useUpdateWebhook
} from "@app/hooks/api";
import { AddWebhookForm, TFormSchema } from "./AddWebhookForm";
export const WebhooksTab = withProjectPermission(
() => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"addWebhook",
"deleteWebhook"
] as const);
const { data: webhooks, isPending: isWebhooksLoading } = useGetWebhooks(workspaceId);
// mutation
const { mutateAsync: createWebhook } = useCreateWebhook();
const {
mutateAsync: testWebhook,
variables: testWebhookVars,
isPending: isTestWebhookSubmitting
} = useTestWebhook();
const {
mutateAsync: updateWebhook,
variables: updateWebhookVars,
isPending: isUpdateWebhookSubmitting
} = useUpdateWebhook();
const { mutateAsync: deleteWebhook } = useDeleteWebhook();
const handleWebhookCreate = async (data: TFormSchema) => {
try {
await createWebhook({
...data,
workspaceId
});
handlePopUpClose("addWebhook");
createNotification({
type: "success",
text: "Successfully created webhook"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create webhook"
});
}
};
const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => {
try {
await updateWebhook({
webhookId,
workspaceId,
isDisabled
});
createNotification({
type: "success",
text: "Successfully updated webhook"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update webhook"
});
}
};
const handleWebhookDelete = async () => {
try {
const webhookId = popUp?.deleteWebhook?.data as string;
await deleteWebhook({
webhookId,
workspaceId
});
handlePopUpClose("deleteWebhook");
createNotification({
type: "success",
text: "Successfully deleted webhook"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete webhook"
});
}
};
const handleWebhookTest = async (webhookId: string) => {
try {
await testWebhook({
webhookId,
workspaceId
});
createNotification({
type: "success",
text: "Successfully triggered webhook"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to trigger webhook"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">{t("settings.webhooks.title")}</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
onClick={() => handlePopUpOpen("addWebhook")}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("settings.webhooks.description")}</p>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>URL</Td>
<Td>Environment</Td>
<Td>Secret Path</Td>
<Td>Status</Td>
<Td className="text-right">Action</Td>
</Tr>
</THead>
<TBody>
{isWebhooksLoading && <TableSkeleton columns={5} innerKey="webhooks-loading" />}
{!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No webhooks found" icon={faPlug} />
</Td>
</Tr>
)}
{!isWebhooksLoading &&
webhooks?.map(
({
id,
url,
environment,
secretPath,
lastStatus,
isDisabled,
updatedAt,
lastRunErrorMessage
}) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>{environment.slug}</Td>
<Td>{secretPath}</Td>
<Td>
{!lastStatus ? (
"-"
) : (
<div className="inline-flex w-min items-center rounded bg-mineshaft-600 px-2 py-0.5 text-sm">
{lastStatus}{" "}
<Tooltip
content={
<div className="text-xs">
<div>
Updated At:{" "}
{format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")}
</div>
{lastRunErrorMessage && (
<div className="mt-2 text-red">
Error: {lastRunErrorMessage}
</div>
)}
</div>
}
>
<FontAwesomeIcon
className={`ml-1 ${
lastStatus === "failed" ? "text-red" : "text-green"
}`}
icon={faInfoCircle}
/>
</Tooltip>
</div>
)}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="star"
size="xs"
onClick={() => handleWebhookTest(id)}
isDisabled={
(isTestWebhookSubmitting &&
testWebhookVars?.webhookId === id) ||
!isAllowed
}
isLoading={
isTestWebhookSubmitting && testWebhookVars?.webhookId === id
}
>
Test
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
onClick={() => handleWebhookDisable(id, !isDisabled)}
isDisabled={
(isUpdateWebhookSubmitting &&
updateWebhookVars?.webhookId === id) ||
!isAllowed
}
isLoading={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
>
{isDisabled ? "Enable" : "Disable"}
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteWebhook", id)}
>
Delete
</Button>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
)
)}
</TBody>
</Table>
</TableContainer>
</div>
<AddWebhookForm
environments={currentWorkspace?.environments}
isOpen={popUp?.addWebhook?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addWebhook", isOpen)}
onCreateWebhook={handleWebhookCreate}
/>
<DeleteActionModal
isOpen={popUp.deleteWebhook.isOpen}
deleteKey="remove"
title="Are you sure you want to delete this webhook?"
onChange={(isOpen) => handlePopUpToggle("deleteWebhook", isOpen)}
onClose={() => handlePopUpClose("deleteWebhook")}
onDeleteApproved={handleWebhookDelete}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Webhooks }
);
@@ -0,0 +1 @@
export { WebhooksTab } from "./WebhooksTab";
@@ -0,0 +1,351 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
ContentLoader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FormControl,
Input,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetSlackIntegrationChannels,
useGetSlackIntegrations,
useGetWorkspaceSlackConfig,
useUpdateProjectSlackConfig
} from "@app/hooks/api";
const formSchema = z.object({
slackIntegrationId: z.string(),
isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(),
accessRequestChannels: z.string().array()
});
type TSlackConfigForm = z.infer<typeof formSchema>;
export const WorkflowIntegrationTab = () => {
const { currentWorkspace } = useWorkspace();
const { data: slackConfig, isPending: isSlackConfigLoading } = useGetWorkspaceSlackConfig({
workspaceId: currentWorkspace?.id ?? ""
});
const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId);
const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig();
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
accessRequestChannels: [],
isSecretRequestNotificationEnabled: false,
secretRequestChannels: []
}
});
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
const sortedSlackChannels = slackChannels?.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
const handleIntegrationSave = async (data: TSlackConfigForm) => {
if (!currentWorkspace) {
return;
}
await updateProjectSlackConfig({
workspaceId: currentWorkspace.id,
...data,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
});
createNotification({
type: "success",
text: "Successfully updated slack integration"
});
};
useEffect(() => {
if (slackConfig) {
setValue("slackIntegrationId", slackConfig.slackIntegrationId);
setValue(
"isSecretRequestNotificationEnabled",
slackConfig.isSecretRequestNotificationEnabled
);
setValue(
"isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled
);
if (slackChannels) {
setValue(
"secretRequestChannels",
slackConfig.secretRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"accessRequestChannels",
slackConfig.accessRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
}
}
}, [slackConfig, slackChannels]);
if (isSlackConfigLoading) {
return <ContentLoader />;
}
return !slackIntegrations?.length ? (
<EmptyState title="You do not have any integrations configured.">
<Link
to="/organization/settings"
search={{
selectedTab: "workflow-integrations"
}}
>
<div className="mt-2 underline decoration-primary-800 underline-offset-4 duration-200 hover:cursor-pointer hover:text-mineshaft-100 hover:decoration-primary-600">
Create one now
</div>
</Link>
</EmptyState>
) : (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Slack Integration</h2>
</div>
<p className="mb-4 text-gray-400">
This integration allows you to send notifications to your Slack workspace in response to
events in your project.
</p>
<form onSubmit={handleSubmit(handleIntegrationSave)}>
<div className="max-w-md">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<Controller
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Select
{...field}
isDisabled={!isAllowed}
placeholder="None"
onValueChange={onChange}
defaultValue={slackConfig?.slackIntegrationId}
className="w-3/4 bg-mineshaft-600"
>
{slackIntegrations?.map((slackIntegration) => (
<SelectItem
value={slackIntegration.id}
key={`slack-integration-${slackIntegration.id}`}
>
{slackIntegration.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
control={control}
name="slackIntegrationId"
/>
)}
</ProjectPermissionCan>
</div>
{selectedSlackIntegrationId && (
<>
<h2 className="mb-2 flex-1 text-lg font-semibold text-mineshaft-100">Events</h2>
<Controller
control={control}
name="isSecretRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 mt-3"
>
<Switch
id="secret-approval-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Approval Requests</p>
</Switch>
</FormControl>
);
}}
/>
{secretRequestNotifState && (
<Controller
control={control}
name="secretRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Slack channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value
?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`secret-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="isAccessRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="access-request-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Access Requests</p>
</Switch>
</FormControl>
);
}}
/>
{accessRequestNotifState && (
<Controller
control={control}
name="accessRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Slack channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value
?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`access-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
)}
<Button
colorSchema="secondary"
className="mt-4"
type="submit"
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</>
)}
</form>
</div>
);
};
@@ -0,0 +1 @@
export * from "./WorkflowIntegrationTab";
@@ -0,0 +1,6 @@
export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection";
export { DeleteProjectSection } from "./DeleteProjectSection";
export { EnvironmentSection } from "./EnvironmentSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
export { SecretTagsSection } from "./SecretTagsSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "./SettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/settings"
)({
component: SettingsPage
});