mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 11:28:43 +00:00
feat: renamed old frontend and fixed some minor bugs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
+129
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AuditLogsRetentionSection } from "./AuditLogsRetentionSection";
|
||||
+60
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
|
||||
+44
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection";
|
||||
+192
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
+349
@@ -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'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'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";
|
||||
+121
@@ -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>
|
||||
);
|
||||
};
|
||||
+131
@@ -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>
|
||||
);
|
||||
};
|
||||
+172
@@ -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>
|
||||
);
|
||||
};
|
||||
+123
@@ -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";
|
||||
+93
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { PointInTimeVersionLimitSection } from "./PointInTimeVersionLimitSection";
|
||||
+33
@@ -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";
|
||||
+51
@@ -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>
|
||||
);
|
||||
};
|
||||
+168
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
|
||||
+93
@@ -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>
|
||||
);
|
||||
};
|
||||
+109
@@ -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>
|
||||
);
|
||||
};
|
||||
+98
@@ -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>
|
||||
);
|
||||
};
|
||||
+166
@@ -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";
|
||||
+200
@@ -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>
|
||||
);
|
||||
};
|
||||
+321
@@ -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";
|
||||
+351
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user