Merge pull request #697 from Infisical/revamp-project-settings

Standardize styling of Project Settings Page
This commit is contained in:
BlackMagiq
2023-06-30 16:44:02 +07:00
committed by GitHub
28 changed files with 1497 additions and 1254 deletions
+6 -2
View File
@@ -16,7 +16,7 @@ export const createWorkspaceTag = async (req: Request, res: Response) => {
user: new Types.ObjectId(req.user._id), user: new Types.ObjectId(req.user._id),
}; };
const createdTag = await new Tag(tagToCreate); const createdTag = await new Tag(tagToCreate).save();
res.json(createdTag); res.json(createdTag);
}; };
@@ -49,7 +49,11 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => {
export const getWorkspaceTags = async (req: Request, res: Response) => { export const getWorkspaceTags = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const { workspaceId } = req.params;
const workspaceTags = await Tag.find({ workspace: workspaceId });
const workspaceTags = await Tag.find({
workspace: new Types.ObjectId(workspaceId)
});
return res.json({ return res.json({
workspaceTags workspaceTags
}); });
@@ -88,6 +88,7 @@ export const CompanyNameSection = () => {
type="submit" type="submit"
colorSchema="secondary" colorSchema="secondary"
isLoading={isLoading} isLoading={isLoading}
isDisabled={isLoading}
> >
Save Save
</Button> </Button>
@@ -1,443 +1,23 @@
import crypto from "crypto";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
// TODO(akhilmhdh):Refactor this into a better utility module package
import {
decryptAssymmetric,
decryptSymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { Button, FormControl, Input } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateServiceToken,
useCreateWsEnvironment,
useCreateWsTag,
useDeleteServiceToken,
useDeleteWorkspace,
useDeleteWsEnvironment,
useDeleteWsTag,
useGetUserWsKey,
useGetUserWsServiceTokens,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useGetWsTags,
useNameWorkspaceSecrets,
useRenameWorkspace,
useToggleAutoCapitalization,
useUpdateWsEnvironment
} from "@app/hooks/api";
import { AutoCapitalizationSection } from "./components/AutoCapitalizationSection/AutoCapitalizationSection"; import { ProjectTabGroup } from "./components";
import { SecretTagsSection } from "./components/SecretTagsSection";
import {
CopyProjectIDSection,
CreateServiceToken,
CreateUpdateEnvFormData,
CreateWsTag,
E2EESection,
EnvironmentSection,
ProjectIndexSecretsSection,
ProjectNameChangeSection,
ServiceTokenSection} from "./components";
export const ProjectSettingsPage = () => { export const ProjectSettingsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentWorkspace, workspaces, isLoading: isWorkspaceLoading } = useWorkspace();
const router = useRouter();
const workspaceID = currentWorkspace?._id || "";
const { createNotification } = useNotificationContext();
// delete action worksapce
const [deleteProjectInput, setDeleteProjectInput] = useState("");
const [isDeleting, setIsDeleting] = useToggle();
const renameWorkspace = useRenameWorkspace();
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const toggleAutoCapitalization = useToggleAutoCapitalization();
const deleteWorkspace = useDeleteWorkspace();
// env crud operation
const createWsEnv = useCreateWsEnvironment();
const updateWsEnv = useUpdateWsEnvironment();
const deleteWsEnv = useDeleteWsEnvironment();
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } =
useGetWorkspaceIndexStatus(workspaceID);
// service token
const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?._id || ""
});
const { data: latestFileKey } = useGetUserWsKey(workspaceID);
const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID);
const createServiceToken = useCreateServiceToken();
const deleteServiceToken = useDeleteServiceToken();
// tag
const { data: wsTags, isLoading: isTagLoading } = useGetWsTags(workspaceID);
const createWsTag = useCreateWsTag();
const deleteWsTag = useDeleteWsTag();
// get user subscription
const { subscription } = useSubscription();
const isEnvServiceAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
const onRenameWorkspace = async (name: string) => {
try {
await renameWorkspace.mutateAsync({ workspaceID, newWorkspaceName: name });
createNotification({
text: "Successfully renamed workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename workspace",
type: "error"
});
}
};
const onAutoCapitalizationToggle = async (state: boolean) => {
try {
await toggleAutoCapitalization.mutateAsync({
workspaceID,
state
});
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
createNotification({
text,
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update auto capitalization",
type: "error"
});
}
};
const onDeleteWorkspace = async () => {
setIsDeleting.on();
try {
await deleteWorkspace.mutateAsync({ workspaceID });
// redirect user to first workspace user is part of
const ws = workspaces.find(({ _id }) => _id !== workspaceID);
if (!ws) {
router.push("/noprojects");
}
router.push(`/dashboard/${ws?._id}`);
createNotification({
text: "Successfully deleted workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete workspace",
type: "error"
});
} finally {
setIsDeleting.off();
}
};
// workspace environment operation
const onCreateWsEnv = async ({ environmentName, environmentSlug }: CreateUpdateEnvFormData) => {
try {
await createWsEnv.mutateAsync({ workspaceID, environmentName, environmentSlug });
createNotification({
text: "Successfully created environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create environment",
type: "error"
});
}
};
const onUpdateWsEnv = async (
oldEnvironmentSlug: string,
{ environmentName, environmentSlug }: CreateUpdateEnvFormData
) => {
try {
await updateWsEnv.mutateAsync({
workspaceID,
environmentName,
environmentSlug,
oldEnvironmentSlug
});
createNotification({
text: "Successfully updated environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update environment",
type: "error"
});
}
};
const onDeleteWsEnv = async (environmentSlug: string) => {
try {
await deleteWsEnv.mutateAsync({
workspaceID,
environmentSlug
});
createNotification({
text: "Successfully deleted environment",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
};
const onCreateServiceToken = async ({
environment,
expiresIn,
name,
permissions,
secretPath
}: CreateServiceToken) => {
// type guard
if (!latestFileKey) return "";
try {
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
});
const res = await createServiceToken.mutateAsync({
encryptedKey: ciphertext,
iv,
tag,
environment,
secretPath,
expiresIn: Number(expiresIn),
name,
workspaceId: workspaceID,
randomBytes,
permissions: Object.entries(permissions)
.filter(([, permissionsValue]) => permissionsValue)
.map(([permissionsKey]) => permissionsKey)
});
createNotification({
text: "Successfully created a service token",
type: "success"
});
return res.serviceToken;
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a service token",
type: "error"
});
}
return "";
};
const onCreateWsTag = async ({ name }: CreateWsTag) => {
try {
const res = await createWsTag.mutateAsync({
workspaceID,
tagName: name,
tagSlug: name.replace(" ", "_")
});
createNotification({
text: "Successfully created a tag",
type: "success"
});
return res.name;
} catch (error) {
console.error(error);
createNotification({
text: "Failed to create a tag",
type: "error"
});
}
return "";
};
const onDeleteTag = async (tagID: string) => {
try {
await deleteWsTag.mutateAsync({ tagID });
createNotification({
text: "Successfully deleted tag",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete the tag",
type: "error"
});
}
};
const onDeleteServiceToken = async (tokenID: string) => {
try {
await deleteServiceToken.mutateAsync(tokenID);
createNotification({
text: "Successfully revoked service token",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete service token",
type: "error"
});
}
};
const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.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,
_id: encryptedSecret._id
};
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace._id,
secretsToUpdate
});
};
return ( return (
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
{/* TODO(akhilmhdh): Remove this right when layout is refactored */} <div className="max-w-screen-lg w-full">
<div className="relative right-5 ml-4"> <div className="relative right-5 ml-4">
<NavHeader pageName={t("settings.project.title")} isProjectRelated /> <NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
<div className="my-8 flex max-w-5xl flex-row items-center justify-between text-xl">
<div className="flex flex-col items-start justify-start text-3xl">
<p className="mr-4 font-semibold text-gray-200">{t("settings.project.title")}</p>
<p className="mr-4 text-base font-normal text-gray-400">
{t("settings.project.description")}
</p>
</div> </div>
</div> <div className="my-8">
<ProjectNameChangeSection <p className="text-3xl font-semibold text-gray-200">
workspaceName={currentWorkspace?.name} {t("settings.project.title")}
onProjectNameChange={onRenameWorkspace} </p>
/>
<CopyProjectIDSection workspaceID={currentWorkspace?._id || ""} />
<EnvironmentSection
isLoading={isWorkspaceLoading}
environments={currentWorkspace?.environments || []}
onCreate={onCreateWsEnv}
onDelete={onDeleteWsEnv}
onUpdate={onUpdateWsEnv}
isEnvServiceAllowed={isEnvServiceAllowed}
/>
<ServiceTokenSection
isLoading={isServiceTokenLoading}
tokens={serviceTokens || []}
environments={currentWorkspace?.environments || []}
onDeleteToken={onDeleteServiceToken}
workspaceName={currentWorkspace?.name || ""}
onCreateToken={onCreateServiceToken}
/>
<SecretTagsSection
isLoading={isTagLoading}
tags={wsTags || []}
onDeleteTag={onDeleteTag}
workspaceName={currentWorkspace?.name || ""}
onCreateTag={onCreateWsTag}
/>
<AutoCapitalizationSection
workspaceAutoCapitalization={currentWorkspace?.autoCapitalization}
onAutoCapitalizationChange={onAutoCapitalizationToggle}
/>
{!isBlindIndexedLoading && !isBlindIndexed && (
<ProjectIndexSecretsSection
onEnableBlindIndices={onEnableBlindIndices}
/>
)}
<E2EESection
workspaceId={currentWorkspace?._id || ""}
/>
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md border-l border-red bg-mineshaft-900 px-6 pl-6 pb-4 pt-4">
<p className="text-xl font-bold text-red">{t("settings.project.danger-zone")}</p>
<p className="text-md mt-2 text-gray-400">{t("settings.project.danger-zone-note")}</p>
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
<FormControl
label={
<div className="mb-0.5 text-sm font-normal text-gray-400">
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
workspace
</div>
}
>
<Input
onChange={(e) => setDeleteProjectInput(e.target.value)}
value={deleteProjectInput}
placeholder="Type the project name to delete"
className="bg-mineshaft-800"
/>
</FormControl>
</div> </div>
<Button <ProjectTabGroup />
colorSchema="danger"
onClick={onDeleteWorkspace}
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
isLoading={isDeleting}
>
{t("settings.project.delete-project")}
</Button>
<p className="mt-3 ml-0.5 text-xs text-gray-500">
{t("settings.project.delete-project-note")}
</p>
</div> </div>
</div> </div>
); );
@@ -1,26 +1,48 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Checkbox } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggleAutoCapitalization } from "@app/hooks/api";
type Props = { export const AutoCapitalizationSection = () => {
workspaceAutoCapitalization?: boolean;
onAutoCapitalizationChange: (state: boolean) => Promise<void>;
};
export const AutoCapitalizationSection = ({
workspaceAutoCapitalization,
onAutoCapitalizationChange
}: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { createNotification } = useNotificationContext();
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 ( return (
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2"> <div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="mb-4 mt-2 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p> <p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
<Checkbox <Checkbox
className="data-[state=checked]:bg-primary" className="data-[state=checked]:bg-primary"
id="autoCapitalization" id="autoCapitalization"
isChecked={workspaceAutoCapitalization} isChecked={currentWorkspace?.autoCapitalization ?? false}
onCheckedChange={(state) => { onCheckedChange={(state) => {
onAutoCapitalizationChange(state as boolean); handleToggleCapitalizationToggle(state as boolean);
}} }}
> >
{t("settings.project.auto-capitalization-description")} {t("settings.project.auto-capitalization-description")}
@@ -1,51 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconButton } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
workspaceID: string;
};
export const CopyProjectIDSection = ({ workspaceID }: Props): JSX.Element => {
const { t } = useTranslation();
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isProjectIdCopied) {
timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
const copyProjectIdToClipboard = () => {
navigator.clipboard.writeText(workspaceID);
setIsProjectIdCopied.on();
};
return (
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pt-4 pb-2">
<p className="self-start text-xl font-semibold">{t("common.project-id")}</p>
<p className="mt-4 text-sm text-bunker-300 mb-2">{t("settings.project.auto-generated")}</p>
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] text-base text-gray-400">
<p className="mr-2 pl-4 font-bold">{`${t("common.project-id")}:`}</p>
<p className="mr-4">{workspaceID}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => copyProjectIdToClipboard()}
>
<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { CopyProjectIDSection } from "./CopyProjectIDSection";
@@ -0,0 +1,84 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useDeleteWorkspace
} from "@app/hooks/api";
export const DeleteProjectSection = () => {
const { t } = useTranslation();
const router = useRouter();
const { createNotification } = useNotificationContext();
const { currentWorkspace, workspaces } = useWorkspace();
const [isDeleting, setIsDeleting] = useToggle();
const [deleteProjectInput, setDeleteProjectInput] = useState("");
const deleteWorkspace = useDeleteWorkspace();
const onDeleteWorkspace = async () => {
setIsDeleting.on();
try {
if (!currentWorkspace?._id) return;
await deleteWorkspace.mutateAsync({
workspaceID: currentWorkspace?._id
});
// redirect user to first workspace user is part of
const ws = workspaces.find(({ _id }) => _id !== currentWorkspace?._id);
if (!ws) {
router.push("/noprojects");
}
router.push(`/dashboard/${ws?._id}`);
createNotification({
text: "Successfully deleted workspace",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to delete workspace",
type: "error"
});
} finally {
setIsDeleting.off();
}
};
return (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-red">
<p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p>
<p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p>
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
<FormControl
label={
<div className="mb-0.5 text-sm font-normal text-gray-400">
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
workspace
</div>
}
>
<Input
onChange={(e) => setDeleteProjectInput(e.target.value)}
value={deleteProjectInput}
placeholder="Type the project name to delete"
className="bg-mineshaft-800"
/>
</FormControl>
</div>
<Button
colorSchema="danger"
onClick={onDeleteWorkspace}
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
isLoading={isDeleting}
>
{t("settings.project.delete-project")}
</Button>
<p className="mt-3 ml-0.5 text-xs text-gray-500">
{t("settings.project.delete-project-note")}
</p>
</div>
);
}
@@ -0,0 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";
@@ -4,29 +4,27 @@ import {
decryptAssymmetric, decryptAssymmetric,
encryptAssymmetric encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { import { Checkbox } from "@app/components/v2";
Checkbox import { useWorkspace } from "@app/context";
} from "@app/components/v2";
import getBot from "../../../../../pages/api/bot/getBot"; import getBot from "../../../../../pages/api/bot/getBot";
import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus"; import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus";
import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey"; import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey";
type Props = { export const E2EESection = () => {
workspaceId: string; const { currentWorkspace } = useWorkspace();
}
export const E2EESection = ({
workspaceId
}: Props) => {
const [bot, setBot] = useState<any>(null); const [bot, setBot] = useState<any>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// get project bot if (currentWorkspace) {
setBot(await getBot({ workspaceId })); // get project bot
setBot(await getBot({
workspaceId: currentWorkspace._id
}));
}
})(); })();
}, []); }, [currentWorkspace]);
/** /**
* Activate bot for project by performing the following steps: * Activate bot for project by performing the following steps:
@@ -38,12 +36,16 @@ export const E2EESection = ({
const toggleBotActivate = async () => { const toggleBotActivate = async () => {
let botKey; let botKey;
try { try {
if (!currentWorkspace?._id) return;
if (bot) { if (bot) {
// case: there is a bot // case: there is a bot
if (!bot.isActive) { if (!bot.isActive) {
// bot is not active -> activate bot // bot is not active -> activate bot
const key = await getLatestFileKey({ workspaceId }); const key = await getLatestFileKey({
workspaceId: currentWorkspace._id
});
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) { if (!PRIVATE_KEY) {
@@ -91,12 +93,12 @@ export const E2EESection = ({
}; };
return bot ? ( return bot ? (
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2"> <div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="mb-4 mt-2 text-xl font-semibold">End-to-End Encryption</p> <p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
<p className="text-md my-2 text-gray-400"> <p className="text-gray-400 mb-8">
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values. Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
</p> </p>
<p className="text-md my-2 mb-4 text-gray-400"> <p className="text-gray-400 mb-8">
Note that, even with E2EE disabled, your secrets are always encrypted at rest. Note that, even with E2EE disabled, your secrets are always encrypted at rest.
</p> </p>
<Checkbox <Checkbox
@@ -0,0 +1,132 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
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";
type Props = {
popUp: UsePopUpState<["createEnv"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void;
};
const schema = yup.object({
environmentName: yup.string().label("Environment Name").required(),
environmentSlug: yup.string().label("Environment Slug").required()
});
export type FormData = yup.InferType<typeof schema>;
export const AddEnvironmentModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isLoading } = useCreateWsEnvironment();
const {
control,
handleSubmit,
reset
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const onFormSubmit = async ({
environmentName,
environmentSlug
}: FormData) => {
try {
if (!currentWorkspace?._id) return;
await mutateAsync({
workspaceID: currentWorkspace._id,
environmentName,
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={isLoading}
isDisabled={isLoading}
>
Create
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}
@@ -1,224 +1,100 @@
import { Controller, useForm } from "react-hook-form"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faPencil, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription,useWorkspace } from "@app/context";
import {
useDeleteWsEnvironment
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
type Props = { import { AddEnvironmentModal } from "./AddEnvironmentModal";
environments: Array<{ name: string; slug: string }>; import { EnvironmentTable } from "./EnvironmentTable";
isLoading?: boolean; import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
isEnvServiceAllowed: boolean;
onCreate: (data: CreateUpdateEnvFormData) => Promise<void>;
onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise<void>;
onDelete: (envSlug: string) => Promise<void>;
};
const createUpdateEnvSchema = yup.object({ export const EnvironmentSection = () => {
environmentName: yup.string().label("Environment Name").required(), const { createNotification } = useNotificationContext();
environmentSlug: yup.string().label("Environment Slug").required() const { subscription } = useSubscription();
}); const { currentWorkspace } = useWorkspace();
export type CreateUpdateEnvFormData = yup.InferType<typeof createUpdateEnvSchema>; const deleteWsEnvironment = useDeleteWsEnvironment();
export const EnvironmentSection = ({ const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
environments,
isEnvServiceAllowed,
onCreate,
onDelete,
isLoading,
onUpdate
}: Props): JSX.Element => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"createUpdateEnv", "createEnv",
"updateEnv",
"deleteEnv", "deleteEnv",
"upgradePlan" "upgradePlan"
] as const); ] as const);
const { const onEnvDeleteSubmit = async (environmentSlug: string) => {
control, try {
handleSubmit, if (!currentWorkspace?._id) return;
reset,
formState: { isSubmitting } await deleteWsEnvironment.mutateAsync({
} = useForm<CreateUpdateEnvFormData>({ workspaceID: currentWorkspace._id,
resolver: yupResolver(createUpdateEnvSchema) environmentSlug
}); });
const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data); createNotification({
const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug; text: "Successfully deleted environment",
type: "success"
const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => { });
if (isEnvUpdate) {
await onUpdate(oldEnvSlug, data); handlePopUpClose("deleteEnv");
} else { } catch (err) {
await onCreate(data); console.error(err);
createNotification({
text: "Failed to delete environment",
type: "error"
});
} }
handlePopUpClose("createUpdateEnv");
};
const onEnvDeleteSubmit = async (envSlug: string) => {
await onDelete(envSlug);
handlePopUpClose("deleteEnv");
}; };
return ( return (
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6"> <div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="mb-2 flex w-full flex-row justify-between"> <div className="flex justify-between mb-8">
<div className="flex w-full flex-col"> <p className="text-xl font-semibold text-mineshaft-100">
<p className="mb-3 text-xl font-semibold">Project Environments</p> Environments
<p className="mb-4 text-base text-gray-400"> </p>
Choose which environments will show up in your dashboard like development, staging,
production
</p>
<p className="mr-1 self-start text-sm text-gray-500">
Note: the text in slugs shows how these environmant should be accessed in CLI.
</p>
</div>
<div> <div>
<Button <Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (isEnvServiceAllowed) { if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createUpdateEnv"); handlePopUpOpen("createEnv");
} else { } else {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
} }
}} }}
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
> >
Add New Environment Create environment
</Button> </Button>
</div> </div>
</div> </div>
<TableContainer> <p className="text-gray-400 mb-8">
<Table> Choose which environments will show up in your dashboard like development, staging, production
<THead> </p>
<Tr> <EnvironmentTable
<Th>Name</Th> handlePopUpOpen={handlePopUpOpen}
<Th>Slug</Th> />
<Th aria-label="button" /> <AddEnvironmentModal
</Tr> popUp={popUp}
</THead> handlePopUpClose={handlePopUpClose}
<TBody> handlePopUpToggle={handlePopUpToggle}
{isLoading && <TableSkeleton columns={3} key="project-envs" />} />
{!isLoading && <UpdateEnvironmentModal
environments.map(({ name, slug }) => ( popUp={popUp}
<Tr key={name}> handlePopUpClose={handlePopUpClose}
<Td>{name}</Td> handlePopUpToggle={handlePopUpToggle}
<Td>{slug}</Td> />
<Td className="flex items-center justify-end">
<IconButton
className="mr-3 py-2"
onClick={() => {
handlePopUpOpen("createUpdateEnv", { name, slug });
reset({ environmentName: name, environmentSlug: slug });
}}
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && environments?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No environments found" />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.createUpdateEnv?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("createUpdateEnv", isOpen);
reset();
}}
>
<ModalContent title={isEnvUpdate ? "Update environment" : "Create a new environment"}>
<form onSubmit={handleSubmit(onEnvModalSubmit)}>
<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={isSubmitting}
isDisabled={isSubmitting}
>
{isEnvUpdate ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteEnv.isOpen} isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${ title={`Are you sure want to delete ${
@@ -0,0 +1,89 @@
import { faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr,
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "deleteEnv", "upgradePlan"]>,
{
name,
slug
}: {
name: string;
slug: string;
}
) => void;
};
export const EnvironmentTable = ({
handlePopUpOpen
}: Props) => {
const { currentWorkspace, isLoading } = useWorkspace();
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
{!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => (
<Tr key={name}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<IconButton
className="mr-3 py-2"
onClick={() => {
handlePopUpOpen("updateEnv", { name, slug });
}}
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No environments found" />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -0,0 +1,135 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
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";
type Props = {
popUp: UsePopUpState<["updateEnv"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["updateEnv"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void;
};
const schema = yup.object({
environmentName: yup.string().label("Environment Name").required(),
environmentSlug: yup.string().label("Environment Slug").required()
});
export type FormData = yup.InferType<typeof schema>;
export const UpdateEnvironmentModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isLoading } = useUpdateWsEnvironment();
const {
control,
handleSubmit,
reset
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const oldEnvironmentSlug = (popUp?.updateEnv?.data as { slug: string })?.slug;
const onFormSubmit = async ({
environmentName,
environmentSlug
}: FormData) => {
try {
if (!currentWorkspace?._id) return;
await mutateAsync({
workspaceID: currentWorkspace._id,
environmentName,
environmentSlug,
oldEnvironmentSlug
});
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="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={isLoading}
isDisabled={isLoading}
>
Update
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,21 @@
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
import { DeleteProjectSection } from "../DeleteProjectSection";
import { E2EESection } from "../E2EESection";
import { EnvironmentSection } from "../EnvironmentSection";
import { ProjectIndexSecretsSection } from "../ProjectIndexSecretsSection";
import { ProjectNameChangeSection } from "../ProjectNameChangeSection";
import { SecretTagsSection } from "../SecretTagsSection";
export const ProjectGeneralTab = () => {
return (
<div>
<ProjectNameChangeSection />
<EnvironmentSection />
<SecretTagsSection />
<AutoCapitalizationSection />
<ProjectIndexSecretsSection />
<E2EESection />
<DeleteProjectSection />
</div>
);
}
@@ -0,0 +1 @@
export { ProjectGeneralTab } from "./ProjectGeneralTab";
@@ -1,24 +1,64 @@
import {
decryptAssymmetric,
decryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2"; import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useGetUserWsKey,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useNameWorkspaceSecrets
} from "@app/hooks/api";
// TODO: add check so that this only shows up if user is // TODO: add check so that this only shows up if user is
// an admin in the workspace // an admin in the workspace
type Props = {
onEnableBlindIndices: () => Promise<void>;
}
export const ProjectIndexSecretsSection = ({ export const ProjectIndexSecretsSection = () => {
onEnableBlindIndices const { currentWorkspace } = useWorkspace();
}: Props) => { const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? "");
return ( const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
<div className="rounded-md bg-mineshaft-900 p-6 my-2"> const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
<p className="mb-4 text-xl font-semibold">Blind Indices</p> const nameWorkspaceSecrets = useNameWorkspaceSecrets();
<p className="mb-4 text-sm text-gray-400">
const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.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,
_id: encryptedSecret._id
};
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace._id,
secretsToUpdate
});
};
return (!isBlindIndexedLoading && !isBlindIndexed) ? (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">Blind Indices</p>
<p className="text-gray-400 mb-8">
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing. Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
</p> </p>
<p className="mb-4 text-sm text-gray-400">
Learn more about it here.
</p>
<Button <Button
onClick={onEnableBlindIndices} onClick={onEnableBlindIndices}
color="mineshaft" color="mineshaft"
@@ -28,5 +68,7 @@ export const ProjectIndexSecretsSection = ({
Enable Blind Indexing Enable Blind Indexing
</Button> </Button>
</div> </div>
); ) : (
<div />
)
} }
@@ -1,17 +1,15 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FormControl, Input } from "@app/components/v2";
import { useWorkspace } from "@app/context";
type Props = { import {
workspaceName?: string; useRenameWorkspace
onProjectNameChange: (name: string) => Promise<void>; } from "@app/hooks/api";
};
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name") name: yup.string().required().label("Project Name")
@@ -19,36 +17,68 @@ const formSchema = yup.object({
type FormData = yup.InferType<typeof formSchema>; type FormData = yup.InferType<typeof formSchema>;
export const ProjectNameChangeSection = ({ export const ProjectNameChangeSection = () => {
workspaceName, const { createNotification } = useNotificationContext();
onProjectNameChange const { currentWorkspace } = useWorkspace();
}: Props): JSX.Element => { const { mutateAsync, isLoading } = useRenameWorkspace();
const { const {
handleSubmit, handleSubmit,
control, control,
reset, reset
formState: { isDirty, isSubmitting }
} = useForm<FormData>({ resolver: yupResolver(formSchema) }); } = useForm<FormData>({ resolver: yupResolver(formSchema) });
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
reset({ name: workspaceName }); if (currentWorkspace) {
}, [workspaceName]); reset({
name: currentWorkspace.name
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name }: FormData) => { const onFormSubmit = async ({ name }: FormData) => {
await onProjectNameChange(name); try {
if (!currentWorkspace?._id) return;
await mutateAsync({
workspaceID: currentWorkspace._id,
newWorkspaceName: name
});
createNotification({
text: "Successfully renamed workspace",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to rename workspace",
type: "error"
});
}
}; };
return ( return (
<form onSubmit={handleSubmit(onFormSubmit)}> <form
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-3"> onSubmit={handleSubmit(onFormSubmit)}
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p> className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
<div className="mb-2 w-full max-w-lg"> >
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
{t("common.display-name")}
</h2>
<div className="max-w-md">
<Controller <Controller
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Type your project name" {...field} className="bg-mineshaft-800" /> <Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
/>
</FormControl> </FormControl>
)} )}
control={control} control={control}
@@ -56,17 +86,13 @@ export const ProjectNameChangeSection = ({
/> />
</div> </div>
<Button <Button
isLoading={isSubmitting} colorSchema="secondary"
color="primary"
variant="outline_bg"
size="sm"
type="submit" type="submit"
isDisabled={!isDirty || isSubmitting} isLoading={isLoading}
leftIcon={<FontAwesomeIcon icon={faCheck} />} isDisabled={isLoading}
> >
{t("common.save-changes")} Save
</Button> </Button>
</div>
</form> </form>
); );
}; };
@@ -0,0 +1,7 @@
import { ServiceTokenSection } from "../ServiceTokenSection";
export const ProjectServiceTokensTab = () => {
return (
<ServiceTokenSection />
);
}
@@ -0,0 +1 @@
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";
@@ -0,0 +1,39 @@
import { Fragment } from "react"
import { Tab } from "@headlessui/react"
import { ProjectGeneralTab } from "../ProjectGeneralTab";
import { ProjectServiceTokensTab } from "../ProjectServiceTokensTab";
const tabs = [
{ name: "General", key: "tab-project-general" },
{ name: "Service Tokens", key: "tab-project-service-tokens" }
];
export const ProjectTabGroup = () => {
return (
<Tab.Group>
<Tab.List className="mb-6 border-b-2 border-mineshaft-800 w-full">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 p-4 font-semibold outline-none ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
}
@@ -0,0 +1 @@
export { ProjectTabGroup } from "./ProjectTabGroup";
@@ -0,0 +1,120 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
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";
const schema = yup.object({
name: yup.string().required().label("Tag Name")
});
export type FormData = yup.InferType<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 { createNotification } = useNotificationContext();
const { currentWorkspace }= useWorkspace();
const createWsTag = useCreateWsTag();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const onFormSubmit = async ({
name
}: FormData) => {
try {
if (!currentWorkspace?._id) return;
await createWsTag.mutateAsync({
workspaceID: currentWorkspace?._id,
tagName: name,
tagSlug: name.replace(" ", "_")
});
handlePopUpClose("CreateSecretTag");
createNotification({
text: "Successfully created a tag",
type: "success"
});
} 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="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tag Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your tag name" />
</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>
);
}
@@ -1,182 +1,77 @@
import { Controller, useForm } from "react-hook-form"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faPlus, faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal
EmptyState, } from "@app/components/v2";
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
ModalTrigger,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr} from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { WorkspaceTag } from "@app/hooks/api/types"; import { useDeleteWsTag } from "@app/hooks/api";
const createTagSchema = yup.object({ import { AddSecretTagModal } from "./AddSecretTagModal";
name: yup.string().required().label("Tag Name") import { SecretTagsTable } from "./SecretTagsTable";
});
export type CreateWsTag = yup.InferType<typeof createTagSchema>;
type Props = {
tags: WorkspaceTag[];
isLoading?: boolean;
workspaceName: string;
onDeleteTag: (tagID: string) => Promise<void>;
onCreateTag: (data: CreateWsTag) => Promise<string>;
};
type DeleteModalData = { name: string; id: string }; type DeleteModalData = { name: string; id: string };
export const SecretTagsSection = ({ export const SecretTagsSection = (): JSX.Element => {
tags = [], const { createNotification } = useNotificationContext();
isLoading,
onDeleteTag,
workspaceName,
onCreateTag
}: Props): JSX.Element => {
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"CreateSecretTag", "CreateSecretTag",
"deleteTagConfirmation" "deleteTagConfirmation"
] as const); ] as const);
const { const deleteWsTag = useDeleteWsTag();
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<CreateWsTag>({
resolver: yupResolver(createTagSchema)
});
const onFormSubmit = async (data: CreateWsTag) => {
await onCreateTag(data);
handlePopUpClose("CreateSecretTag");
};
const onDeleteApproved = async () => { const onDeleteApproved = async () => {
await onDeleteTag((popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id); try {
handlePopUpClose("deleteTagConfirmation"); await deleteWsTag.mutateAsync({
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 ( return (
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6"> <div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex w-full flex-row justify-between"> <div className="flex justify-between mb-8">
<div className="flex w-full flex-col"> <p className="mb-3 text-xl font-semibold">Secret Tags</p>
<p className="mb-3 text-xl font-semibold">Secret Tags</p> <Button
<p className="text-sm text-gray-400"> colorSchema="secondary"
Every secret can be assigned to one or more tags. Here you can add and remove tags for leftIcon={<FontAwesomeIcon icon={faPlus} />}
the current project. onClick={() => {
</p> console.log("x");
</div> handlePopUpOpen("CreateSecretTag");
<div> console.log("x2");
<Modal }}
isOpen={popUp?.CreateSecretTag?.isOpen} >
onOpenChange={(open) => { Create tag
handlePopUpToggle("CreateSecretTag", open); </Button>
reset();
}}
>
<ModalTrigger asChild>
<Button color="mineshaft" leftIcon={<FontAwesomeIcon icon={faPlus} />}>
Add New Tag
</Button>
</ModalTrigger>
<ModalContent
title={`Add a tag for ${workspaceName}`}
subTitle="Specify your tag name, and the slug will be created automatically."
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Tag Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your tag name" />
</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>
</div>
</div> </div>
<TableContainer className="mt-4"> <p className="text-gray-400 mb-8">
<Table> Every secret can be assigned to one or more tags. Here you can add and remove tags for
<THead> the current project.
<Tr> </p>
<Th>Tag</Th> <SecretTagsTable
<Th>Slug</Th> handlePopUpOpen={handlePopUpOpen}
<Th aria-label="button" /> />
</Tr> <AddSecretTagModal
</THead> popUp={popUp}
<TBody> handlePopUpClose={handlePopUpClose}
{isLoading && <TableSkeleton columns={3} key="secret-tags" />} handlePopUpToggle={handlePopUpToggle}
{!isLoading && />
tags.map(({ _id, name, slug }) => (
<Tr key={name}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name,
id: _id
})
}
colorSchema="danger"
ariaLabel="update"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && tags?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No secret tags found" icon={faTags} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen} isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${ title={`Delete ${
@@ -0,0 +1,82 @@
import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWsTags } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
};
export const SecretTagsTable = ({
handlePopUpOpen
}: Props) => {
const { currentWorkspace }= useWorkspace();
const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? "");
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Tag</Th>
<Th>Slug</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} key="secret-tags" />}
{!isLoading && data && data.map(({ _id, name, slug }) => (
<Tr key={name}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td className="flex items-center justify-end">
<IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name,
id: _id
})
}
colorSchema="danger"
ariaLabel="update"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No secret tags found" icon={faTags} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -0,0 +1,343 @@
import crypto from "crypto";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateServiceToken,
useGetUserWsKey
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const apiTokenExpiry = [
{ label: "1 Day", value: 86400 },
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: null }
];
const schema = yup.object({
name: yup.string().max(100).required().label("Service Token Name"),
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup.string().required().default("/").label("Secret Path"),
expiresIn: yup.string().optional().label("Service Token Expiration"),
permissions: yup
.object()
.shape({
read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["createAPIToken"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({
popUp,
handlePopUpToggle
}: Props) => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema)
});
const [newToken, setToken] = useState("");
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const createServiceToken = useCreateServiceToken();
const hasServiceToken = Boolean(newToken);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newToken);
setIsTokenCopied.on();
};
const onFormSubmit = async ({
name,
environment,
secretPath,
expiresIn,
permissions
}: FormData) => {
try {
if (!currentWorkspace?._id) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
});
const { serviceToken } = await createServiceToken.mutateAsync({
encryptedKey: ciphertext,
iv,
tag,
environment,
secretPath,
expiresIn: Number(expiresIn),
name,
workspaceId: currentWorkspace._id,
randomBytes,
permissions: Object.entries(permissions)
.filter(([, permissionsValue]) => permissionsValue)
.map(([permissionsKey]) => permissionsKey)
});
setToken(serviceToken);
createNotification({
text: "Successfully created a service token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create a service token",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
title={
t("section.token.add-dialog.title", {
target: currentWorkspace?.name
}) as string
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Environment"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secrets Path"
isError={Boolean(error)}
helperText="Tokens can be scoped to a folder path. Default path is /"
errorText={error?.message}
>
<Input {...field} placeholder="Provide a path, default is /" />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</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>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
}
@@ -1,379 +1,82 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import {
Button, Button,
Checkbox,
DeleteActionModal, DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
ModalTrigger,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { ServiceToken, WorkspaceEnv } from "@app/hooks/api/types"; import {
useDeleteServiceToken
} from "@app/hooks/api";
const apiTokenExpiry = [ import { AddServiceTokenModal } from "./AddServiceTokenModal";
{ label: "1 Day", value: 86400 }, import { ServiceTokenTable } from "./ServiceTokenTable";
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: null }
];
const createServiceTokenSchema = yup.object({
name: yup.string().max(100).required().label("Service Token Name"),
environment: yup.string().max(50).required().label("Environment"),
secretPath: yup.string().required().default("/").label("Secret Path"),
expiresIn: yup.string().optional().label("Service Token Expiration"),
permissions: yup
.object()
.shape({
read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
});
export type CreateServiceToken = yup.InferType<typeof createServiceTokenSchema>;
type Props = {
tokens: ServiceToken[];
isLoading?: boolean;
workspaceName: string;
environments: WorkspaceEnv[];
onDeleteToken: (serviceTokenID: string) => Promise<void>;
onCreateToken: (data: CreateServiceToken) => Promise<string>;
};
type DeleteModalData = { name: string; id: string }; type DeleteModalData = { name: string; id: string };
export const ServiceTokenSection = ({ export const ServiceTokenSection = () => {
tokens = [],
isLoading,
onDeleteToken,
workspaceName,
environments = [],
onCreateToken
}: Props): JSX.Element => {
const [newToken, setToken] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
const [isTokenCopied, setIsTokenCopied] = useToggle(false); const { createNotification } = useNotificationContext();
const deleteServiceToken = useDeleteServiceToken();
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newToken);
setIsTokenCopied.on();
};
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"createAPIToken", "createAPIToken",
"deleteAPITokenConfirmation" "deleteAPITokenConfirmation"
] as const); ] as const);
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<CreateServiceToken>({
resolver: yupResolver(createServiceTokenSchema)
});
const hasServiceToken = Boolean(newToken);
const onFormSubmit = async (data: CreateServiceToken) => {
const token = await onCreateToken(data);
setToken(token);
};
const onDeleteApproved = async () => { const onDeleteApproved = async () => {
await onDeleteToken((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id); try {
handlePopUpClose("deleteAPITokenConfirmation"); deleteServiceToken.mutateAsync((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id);
createNotification({
text: "Successfully deleted service token",
type: "success"
});
handlePopUpClose("deleteAPITokenConfirmation");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete service token",
type: "error"
});
}
}; };
return ( return (
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6"> <div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex w-full flex-row justify-between"> <div className="flex justify-between mb-8">
<div className="flex w-full flex-col"> <p className="text-xl font-semibold text-mineshaft-100">{t("section.token.service-tokens")}</p>
<p className="mb-3 text-xl font-semibold">{t("section.token.service-tokens")}</p> <Button
<p className="text-sm text-gray-400 mb-4">{t("section.token.service-tokens-description")}</p> colorSchema="secondary"
</div> leftIcon={<FontAwesomeIcon icon={faPlus} />}
<div> onClick={() => {
<Modal handlePopUpOpen("createAPIToken");
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}} }}
> >
<ModalTrigger asChild> Create token
<Button color="mineshaft" leftIcon={<FontAwesomeIcon icon={faPlus} />}> </Button>
{t("section.token.add-new")}
</Button>
</ModalTrigger>
<ModalContent
title={
t("section.token.add-dialog.title", {
target: workspaceName
}) as string
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Environment"
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}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secrets Path"
isError={Boolean(error)}
helperText="Tokens can be scoped to a folder path. Default path is /"
errorText={error?.message}
>
<Input {...field} placeholder="Provide a path, default is /" />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
// TODO: refactor
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</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>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
</div>
</div> </div>
<p className="text-gray-400 mb-8">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable
handlePopUpOpen={handlePopUpOpen}
/>
<AddServiceTokenModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteAPITokenConfirmation.isOpen} isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${ title={
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " " `Delete ${(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "} service token?`
} service token?`} }
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
onDeleteApproved={onDeleteApproved} onDeleteApproved={onDeleteApproved}
/> />
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} key="project-service-tokens" />}
{!isLoading &&
tokens.map((row) => (
<Tr key={row._id}>
<Td>{row.name}</Td>
<Td>{row.environment}</Td>
<Td>{row.secretPath}</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td className="flex items-center justify-end">
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && tokens?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div> </div>
); );
}; };
@@ -0,0 +1,88 @@
import { faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
};
export const ServiceTokenTable = ({
handlePopUpOpen
}: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?._id || ""
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} key="project-service-tokens" />}
{!isLoading && data && data.map((row) => (
<Tr key={row._id}>
<Td>{row.name}</Td>
<Td>{row.environment}</Td>
<Td>{row.secretPath}</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td className="flex items-center justify-end">
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
</Td>
</Tr>
))}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,9 +1,9 @@
export { CopyProjectIDSection } from "./CopyProjectIDSection"; export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
export { DeleteProjectSection } from "./DeleteProjectSection";
export { E2EESection } from "./E2EESection"; export { E2EESection } from "./E2EESection";
export { EnvironmentSection } from "./EnvironmentSection"; export { EnvironmentSection } from "./EnvironmentSection";
export type { CreateUpdateEnvFormData } from "./EnvironmentSection/EnvironmentSection";
export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection"; export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection";
export { ProjectNameChangeSection } from "./ProjectNameChangeSection"; export { ProjectNameChangeSection } from "./ProjectNameChangeSection";
export type { CreateWsTag } from "./SecretTagsSection/SecretTagsSection"; export { ProjectTabGroup } from "./ProjectTabGroup";
export { SecretTagsSection } from "./SecretTagsSection";
export { ServiceTokenSection } from "./ServiceTokenSection"; export { ServiceTokenSection } from "./ServiceTokenSection";
export type { CreateServiceToken } from "./ServiceTokenSection/ServiceTokenSection";