mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-05 12:26:38 +00:00
Merge pull request #697 from Infisical/revamp-project-settings
Standardize styling of Project Settings Page
This commit is contained in:
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
+1
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+35
-13
@@ -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")}
|
||||||
|
|||||||
-51
@@ -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
@@ -1 +0,0 @@
|
|||||||
export { CopyProjectIDSection } from "./CopyProjectIDSection";
|
|
||||||
+84
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||||
+20
-18
@@ -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
|
||||||
|
|||||||
+132
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
-188
@@ -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 ${
|
||||||
|
|||||||
+89
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+135
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
@@ -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";
|
||||||
+57
-15
@@ -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 />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+55
-29
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import { ServiceTokenSection } from "../ServiceTokenSection";
|
||||||
|
|
||||||
|
export const ProjectServiceTokensTab = () => {
|
||||||
|
return (
|
||||||
|
<ServiceTokenSection />
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";
|
||||||
+39
@@ -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";
|
||||||
+120
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+54
-159
@@ -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 ${
|
||||||
|
|||||||
+82
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+343
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
-344
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+88
@@ -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";
|
|
||||||
|
|||||||
Reference in New Issue
Block a user