+ {name}
+ {slug}
+
+
{
- if (plan !== plans.starter || host !== "https://app.infisical.com") {
- handlePopUpOpen('createUpdateEnv', { name, slug })
+ if (plan !== plans.starter || host !== 'https://app.infisical.com') {
+ handlePopUpOpen('createUpdateEnv', { name, slug });
} else {
- handlePopUpOpen('upgradePlan')
+ handlePopUpOpen('upgradePlan');
}
}}
color="mineshaft"
@@ -131,13 +128,13 @@ const EnvironmentTable = ({ data = [], onCreateEnv, onDeleteEnv, onUpdateEnv }:
icon={faPencil}
/>
-
+
{
- if (plan !== plans.starter || host !== "https://app.infisical.com") {
- handlePopUpOpen('deleteEnv', { name, slug })
+ if (plan !== plans.starter || host !== 'https://app.infisical.com') {
+ handlePopUpOpen('deleteEnv', { name, slug });
} else {
- handlePopUpOpen('upgradePlan')
+ handlePopUpOpen('upgradePlan');
}
}}
color="red"
@@ -150,7 +147,7 @@ const EnvironmentTable = ({ data = [], onCreateEnv, onDeleteEnv, onUpdateEnv }:
))
) : (
-
+
No environmants found
@@ -174,7 +171,7 @@ const EnvironmentTable = ({ data = [], onCreateEnv, onDeleteEnv, onUpdateEnv }:
onCreateSubmit={onEnvCreateCB}
onEditSubmit={onEnvUpdateCB}
/>
- handlePopUpClose('upgradePlan')}
text="You can add custom environments if you switch to Infisical's Team plan."
diff --git a/frontend/src/components/dashboard/SideBar.tsx b/frontend/src/components/dashboard/SideBar.tsx
index 1a3f99a29..257d94996 100644
--- a/frontend/src/components/dashboard/SideBar.tsx
+++ b/frontend/src/components/dashboard/SideBar.tsx
@@ -71,7 +71,7 @@ const SideBar = ({
const { t } = useTranslation();
return (
-
+
{isLoading ? (
)}
-
+
+
diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx
index 247d4ac3d..bc3876750 100644
--- a/frontend/src/components/navigation/NavHeader.tsx
+++ b/frontend/src/components/navigation/NavHeader.tsx
@@ -3,8 +3,8 @@ import { useRouter } from 'next/router';
import { faAngleRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { useWorkspace } from '@app/context';
import getOrganization from '@app/pages/api/organization/GetOrg';
-import getProjectInfo from '@app/pages/api/workspace/getProjectInfo';
/**
* This is the component at the top of almost every page.
@@ -23,9 +23,9 @@ export default function NavHeader({
isProjectRelated?: boolean;
}): JSX.Element {
const [orgName, setOrgName] = useState('');
- const [workspaceName, setWorkspaceName] = useState('');
const router = useRouter();
const projectId = String(router.query.id);
+ const { currentWorkspace } = useWorkspace();
useEffect(() => {
(async () => {
@@ -34,29 +34,24 @@ export default function NavHeader({
orgId: orgId || ''
});
setOrgName(org.name);
-
- const workspace = await getProjectInfo({
- projectId
- });
- setWorkspaceName(workspace.name);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
return (
-
-
+
+
{orgName?.charAt(0)}
-
{orgName}
+
{orgName}
{isProjectRelated && (
<>
-
-
{workspaceName}
+
+
{currentWorkspace?.name}
>
)}
-
-
{pageName}
+
+
{pageName}
);
}
diff --git a/frontend/src/const.ts b/frontend/src/const.ts
index 6ec3941a4..4583d1dbb 100644
--- a/frontend/src/const.ts
+++ b/frontend/src/const.ts
@@ -16,11 +16,31 @@ export const publicPaths = [
`/terms`,
`/subprocessors`,
`/verify-email`,
- `/password-reset`,
+ `/password-reset`
];
export const languageMap = {
- en: "English",
- ko: "한국어",
- fr: "Français",
+ en: 'English',
+ ko: '한국어',
+ fr: 'Français'
};
+
+interface Mapping {
+ [key: string]: string;
+}
+
+const plansDev: Mapping = {
+ starter: 'prod_Mb4ATFT5QAHoPM',
+ team: 'prod_NEpD2WMXUS2eDn',
+ professional: 'prod_Mb4CetZ2jE7jdl',
+ enterprise: 'licence_key_required'
+};
+
+const plansProd: Mapping = {
+ starter: 'prod_Mb8oR5XNwyFTul',
+ team: 'prod_NEp7fAB3UJWK6A',
+ professional: 'prod_Mb8pUIpA0OUi5N',
+ enterprise: 'licence_key_required'
+};
+
+export const plans = plansProd || plansDev;
diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx
index 5bfaaa1e3..0d5809093 100644
--- a/frontend/src/pages/_app.tsx
+++ b/frontend/src/pages/_app.tsx
@@ -10,7 +10,9 @@ import Layout from '@app/components/basic/Layout';
import NotificationProvider from '@app/components/context/Notifications/NotificationProvider';
import Telemetry from '@app/components/utilities/telemetry/Telemetry';
import { publicPaths } from '@app/const';
+import { SubscriptionProvider } from '@app/context';
import { AuthProvider } from '@app/context/AuthContext';
+import { WorkspaceProvider } from '@app/context/WorkspaceContext';
import { queryClient } from '@app/reactQuery';
import '@fortawesome/fontawesome-svg-core/styles.css';
@@ -68,11 +70,15 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
diff --git a/frontend/src/pages/activity/[id].tsx b/frontend/src/pages/activity/[id].tsx
index 55e5b1924..181efb4b2 100644
--- a/frontend/src/pages/activity/[id].tsx
+++ b/frontend/src/pages/activity/[id].tsx
@@ -124,7 +124,7 @@ export default function Activity() {
};
return (
-
+
{currentSidebarAction && (
diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx
index b199790a9..99114a765 100644
--- a/frontend/src/pages/dashboard/[id].tsx
+++ b/frontend/src/pages/dashboard/[id].tsx
@@ -518,7 +518,7 @@ export default function Dashboard() {
};
return data ? (
-
+
{t('common:head-title', { title: t('dashboard:title') })}
@@ -526,7 +526,7 @@ export default function Dashboard() {
-
+
{sidebarSecretId !== 'None' && (
([]);
- const [environments, setEnvironments] = useState>([]);
- const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState('');
- // const [isAddOpen, setIsAddOpen] = useState(false);
- const [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] = useState(false);
- const [projectIdCopied, setProjectIdCopied] = useState(false);
- const workspaceId = router.query.id as string;
+import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps';
+import { ProjectSettingsPage } from '@app/views/Settings/ProjectSettingsPage';
+const ProjectSettings = () => {
const { t } = useTranslation();
- /**
- * This function copies the project id to the clipboard
- */
- function copyToClipboard() {
- const copyText = document.getElementById('myInput') as HTMLInputElement;
-
- if (copyText) {
- copyText.select();
- copyText.setSelectionRange(0, 99999); // For mobile devices
-
- navigator.clipboard.writeText(copyText.value);
-
- setProjectIdCopied(true);
- setTimeout(() => setProjectIdCopied(false), 2000);
- }
- }
-
- useEffect(() => {
- const load = async () => {
- const userWorkspaces = await getWorkspaces();
- userWorkspaces.forEach((userWorkspace) => {
- if (userWorkspace._id === workspaceId) {
- setWorkspaceName(userWorkspace.name);
- setEnvironments(userWorkspace.environments);
- }
- });
- const tempServiceTokens = await getServiceTokens({
- workspaceId
- });
- setServiceTokens(tempServiceTokens);
- };
-
- load();
- }, []);
-
- const modifyWorkspaceName = (newName: string) => {
- setButtonReady(true);
- setWorkspaceName(newName);
- };
-
- const submitChanges = (newWorkspaceName: string) => {
- renameWorkspace(workspaceId, newWorkspaceName);
- setButtonReady(false);
- };
-
- const closeAddServiceTokenModal = () => {
- setIsAddServiceTokenDialogOpen(false);
- };
-
- /**
- * This function deleted a workspace.
- * It first checks if there is more than one workspace aviable. Otherwise, it doesn't delete
- * It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete.
- * It then deletes the workspace and forwards the user to another aviable workspace.
- */
- const executeDeletingWorkspace = async () => {
- const userWorkspaces = await getWorkspaces();
-
- if (userWorkspaces.length > 1) {
- if (
- userWorkspaces.filter((workspace) => workspace._id === workspaceId)[0].name ===
- workspaceToBeDeletedName
- ) {
- await deleteWorkspace(workspaceId);
- const ws = await getWorkspaces();
- router.push(`/dashboard/${ws[0]._id}`);
- }
- }
- };
-
- const onCreateEnvironment = async ({ name, slug }: EnvData) => {
- const res = await createEnvironment(workspaceId, {
- environmentName: name,
- environmentSlug: slug
- });
- if (res) {
- // TODO: on react-query migration do an api call to resync
- setEnvironments((env) => [...env, { name, slug }]);
- }
- };
-
- const onUpdateEnvironment = async (oldSlug: string, { name, slug }: EnvData) => {
- const res = await updateEnvironment(workspaceId, {
- oldEnvironmentSlug: oldSlug,
- environmentName: name,
- environmentSlug: slug
- });
- // TODO: on react-query migration do an api call to resync
- if (res) {
- setEnvironments((env) => env.map((el) => (el.slug === oldSlug ? { name, slug } : el)));
- }
- };
-
- const onDeleteEnvironment = async (slugToBeDelete: string) => {
- const res = await deleteEnvironment(workspaceId, slugToBeDelete);
- // TODO: on react-query migration do an api call to resync
- if (res) {
- setEnvironments((env) => env.filter(({ slug }) => slug !== slugToBeDelete));
- }
- };
-
return (
-
+ <>
{t('common:head-title', { title: t('settings-project:title') })}
-
-
-
-
-
-
-
{t('settings-project:title')}
-
- {t('settings-project:description')}
-
-
-
-
-
-
-
-
{t('common:display-name')}
-
-
-
-
-
- submitChanges(workspaceName)}
- color="mineshaft"
- size="md"
- active={buttonReady}
- iconDisabled={faCheck}
- textDisabled="Saved"
- />
-
-
-
-
-
{t('common:project-id')}
-
- {t('settings-project:project-id-description')}
-
-
- {t('settings-project:project-id-description2')}
- {/* eslint-disable-next-line react/jsx-no-target-blank */}
-
- {t('settings-project:docs')}
-
-
-
- {t('settings-project:auto-generated')}
-
-
-
{`${t('common:project-id')}:`}
-
-
-
- {projectIdCopied ? (
-
- ) : (
-
- )}
-
-
- {t('common:click-to-copy')}
-
-
-
-
-
-
-
-
-
-
-
- {t('section-token:service-tokens')}
-
-
- {t('section-token:service-tokens-description')}
-
-
- Please, make sure you are on the
-
- latest version of CLI
-
- .
-
-
-
- {
- setIsAddServiceTokenDialogOpen(true);
- }}
- color="mineshaft"
- icon={faPlus}
- size="md"
- />
-
-
-
-
-
-
-
-
{t('settings-project:danger-zone')}
-
{t('settings-project:danger-zone-note')}
-
-
-
-
- {t('settings-project:delete-project')}
-
-
- {t('settings-project:delete-project-note')}
-
-
-
-
-
-
+
+ >
);
-}
+};
-SettingsBasic.requireAuth = true;
+export default ProjectSettings;
-export const getServerSideProps = getTranslatedServerSideProps([
+ProjectSettings.requireAuth = true;
+
+export const getServerSideProps = getTranslatedStaticProps([
'settings',
'settings-project',
'section-token'
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx
new file mode 100644
index 000000000..02d49764f
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx
@@ -0,0 +1,287 @@
+import crypto from 'crypto';
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
+import NavHeader from '@app/components/navigation/NavHeader';
+// TODO(akhilmhdh):Refactor this into a better utility module package
+import {
+ decryptAssymmetric,
+ encryptSymmetric
+} from '@app/components/utilities/cryptography/crypto';
+import { Button, FormControl, Input } from '@app/components/v2';
+import { plans } from '@app/const';
+import { useSubscription, useWorkspace } from '@app/context';
+import { useToggle } from '@app/hooks';
+import {
+ useCreateServiceToken,
+ useCreateWsEnvironment,
+ useDeleteServiceToken,
+ useDeleteWorkspace,
+ useDeleteWsEnvironment,
+ useGetUserWsKey,
+ useGetUserWsServiceTokens,
+ useRenameWorkspace,
+ useUpdateWsEnvironment
+} from '@app/hooks/api';
+
+import {
+ CopyProjectIDSection,
+ CreateServiceToken,
+ CreateUpdateEnvFormData,
+ EnvironmentSection,
+ ProjectNameChangeSection,
+ ServiceTokenSection
+} from './components';
+
+export const ProjectSettingsPage = () => {
+ const { t } = useTranslation();
+ const { currentWorkspace } = useWorkspace();
+ const { data: serviceTokens } = useGetUserWsServiceTokens({
+ workspaceID: currentWorkspace?._id || ''
+ });
+ const workspaceID = currentWorkspace?._id || '';
+ const { createNotification } = useNotificationContext();
+ // delete action worksapce
+ const [deleteProjectInput, setDeleteProjectInput] = useState('');
+ const [isDeleting, setIsDeleting] = useToggle();
+
+ const renameWorkspace = useRenameWorkspace();
+ const deleteWorkspace = useDeleteWorkspace();
+ // env crud operation
+ const createWsEnv = useCreateWsEnvironment();
+ const updateWsEnv = useUpdateWsEnvironment();
+ const deleteWsEnv = useDeleteWsEnvironment();
+
+ // service token
+ const { data: latestFileKey } = useGetUserWsKey(workspaceID);
+ const createServiceToken = useCreateServiceToken();
+ const deleteServiceToken = useDeleteServiceToken();
+
+ // get user subscription
+ const { subscriptionPlan } = useSubscription();
+ const host = window.location.origin;
+ const isEnvServiceAllowed =
+ subscriptionPlan !== plans.starter || host !== 'https://app.infisical.com';
+
+ 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 onDeleteWorkspace = async () => {
+ setIsDeleting.on();
+ try {
+ await deleteWorkspace.mutateAsync({ workspaceID });
+ 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 }: CreateServiceToken) => {
+ // type guard
+ if (!latestFileKey) return '';
+ try {
+ // crypo calculation to generate the key
+ 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,
+ expiresIn: Number(expiresIn),
+ name,
+ workspaceId: workspaceID
+ });
+ console.log(res);
+ createNotification({
+ text: 'Successfully created API token',
+ type: 'success'
+ });
+ return res.serviceToken;
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: 'Failed to create API token',
+ type: 'error'
+ });
+ }
+ return '';
+ };
+
+ 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'
+ });
+ }
+ };
+
+ return (
+
+ {/* TODO(akhilmhdh): Remove this right when layout is refactored */}
+
+
+
+
+
+
{t('settings-project:title')}
+
+ {t('settings-project:description')}
+
+
+
+
+
+
+
+
+
{t('settings-project:danger-zone')}
+
{t('settings-project:danger-zone-note')}
+
+
+ Type {currentWorkspace?.name} to delete the
+ workspace
+
+ }
+ >
+
setDeleteProjectInput(e.target.value)}
+ value={deleteProjectInput}
+ placeholder="Type the project name to delete"
+ />
+
+
+
+ {t('settings-project:delete-project')}
+
+
+ {t('settings-project:delete-project-note')}
+
+
+
+ );
+};
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx
new file mode 100644
index 000000000..6e18a88f7
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx
@@ -0,0 +1,66 @@
+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 (
+
+
{t('common:project-id')}
+
+ {t('settings-project:project-id-description')}
+
+
+ {t('settings-project:project-id-description2')}
+ {/* eslint-disable-next-line react/jsx-no-target-blank */}
+
+ {t('settings-project:docs')}
+
+
+
{t('settings-project:auto-generated')}
+
+
{`${t('common:project-id')}:`}
+
{workspaceID}
+
copyProjectIdToClipboard()}
+ >
+
+
+ {t('common:click-to-copy')}
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx
new file mode 100644
index 000000000..1e56a1747
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx
@@ -0,0 +1 @@
+export { CopyProjectIDSection } from './CopyProjectIDSection';
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx
new file mode 100644
index 000000000..bb7a0a87e
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx
@@ -0,0 +1,239 @@
+import { Controller, useForm } from 'react-hook-form';
+import { faPencil, faTrashCan } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+
+import {
+ Button,
+ DeleteActionModal,
+ FormControl,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Table,
+ TableContainer,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr,
+ UpgradePlanModal
+} from '@app/components/v2';
+import { usePopUp } from '@app/hooks/usePopUp';
+
+type Props = {
+ environments: Array<{ name: string; slug: string }>;
+ isEnvServiceAllowed: boolean;
+ onCreate: (data: CreateUpdateEnvFormData) => Promise
;
+ onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise;
+ onDelete: (envSlug: string) => Promise;
+};
+
+const createUpdateEnvSchema = yup.object({
+ environmentName: yup.string().label('Environment Name').required(),
+ environmentSlug: yup.string().label('Environment Slug').required()
+});
+
+export type CreateUpdateEnvFormData = yup.InferType;
+
+export const EnvironmentSection = ({
+ environments,
+ isEnvServiceAllowed,
+ onCreate,
+ onDelete,
+ onUpdate
+}: Props): JSX.Element => {
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ 'createUpdateEnv',
+ 'deleteEnv',
+ 'upgradePlan'
+ ] as const);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: yupResolver(createUpdateEnvSchema)
+ });
+
+ const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data);
+ const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug;
+
+ const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => {
+ if (isEnvUpdate) {
+ await onUpdate(oldEnvSlug, data);
+ } else {
+ await onCreate(data);
+ }
+ handlePopUpClose('createUpdateEnv');
+ };
+
+ const onEnvDeleteSubmit = async (envSlug: string) => {
+ await onDelete(envSlug);
+ handlePopUpClose('deleteEnv');
+ };
+
+ return (
+
+
+
+
Project Environments
+
+ Choose which environments will show up in your dashboard like development, staging,
+ production
+
+
+ Note: the text in slugs shows how these environmant should be accessed in CLI.
+
+
+
+ {
+ if (isEnvServiceAllowed) {
+ handlePopUpOpen('createUpdateEnv');
+ } else {
+ handlePopUpOpen('upgradePlan');
+ }
+ }}
+ colorSchema="primary"
+ >
+ Add New Env
+
+
+
+
+
+
+
+ Name
+ Slug
+
+
+
+
+ {environments?.length > 0 ? (
+ environments.map(({ name, slug }) => (
+
+ {name}
+ {slug}
+
+ {
+ if (isEnvServiceAllowed) {
+ handlePopUpOpen('createUpdateEnv', { name, slug });
+ reset({ environmentName: name, environmentSlug: slug });
+ } else {
+ handlePopUpOpen('upgradePlan');
+ }
+ }}
+ colorSchema="secondary"
+ ariaLabel="update"
+ >
+
+
+ {
+ if (isEnvServiceAllowed) {
+ handlePopUpOpen('deleteEnv', { name, slug });
+ } else {
+ handlePopUpOpen('upgradePlan');
+ }
+ }}
+ colorSchema="danger"
+ ariaLabel="update"
+ >
+
+
+
+
+ ))
+ ) : (
+
+
+ No environmants found
+
+
+ )}
+
+
+
+
{
+ handlePopUpToggle('createUpdateEnv', isOpen);
+ reset();
+ }}
+ >
+
+
+
+
+
handlePopUpToggle('deleteEnv', isOpen)}
+ deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ''}
+ onDeleteApproved={() =>
+ onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug)
+ }
+ />
+ handlePopUpToggle('upgradePlan', isOpen)}
+ text="You can add custom environments if you switch to Infisical's Team plan."
+ />
+
+ );
+};
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/index.tsx
new file mode 100644
index 000000000..239cfd747
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/index.tsx
@@ -0,0 +1 @@
+export { EnvironmentSection } from './EnvironmentSection';
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx
new file mode 100644
index 000000000..876564e1f
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx
@@ -0,0 +1,71 @@
+import { useEffect } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+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 * as yup from 'yup';
+
+import { Button, FormControl, Input } from '@app/components/v2';
+
+type Props = {
+ workspaceName?: string;
+ onProjectNameChange: (name: string) => Promise;
+};
+
+const formSchema = yup.object({
+ name: yup.string().required().label('Project Name')
+});
+
+type FormData = yup.InferType;
+
+export const ProjectNameChangeSection = ({
+ workspaceName,
+ onProjectNameChange
+}: Props): JSX.Element => {
+ const {
+ handleSubmit,
+ control,
+ reset,
+ formState: { isDirty, isSubmitting }
+ } = useForm({ resolver: yupResolver(formSchema) });
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ reset({ name: workspaceName });
+ }, [workspaceName]);
+
+ const onFormSubmit = async ({ name }: FormData) => {
+ await onProjectNameChange(name);
+ };
+
+ return (
+
+ );
+};
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/index.tsx
new file mode 100644
index 000000000..c50fe4757
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/index.tsx
@@ -0,0 +1 @@
+export { ProjectNameChangeSection } from './ProjectNameChangeSection';
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx
new file mode 100644
index 000000000..bf83b0b4f
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx
@@ -0,0 +1,307 @@
+import { useEffect, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { faCheck, faCopy, faPlus, faTrashCan } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+
+import {
+ Button,
+ DeleteActionModal,
+ FormControl,
+ IconButton,
+ Input,
+ Modal,
+ ModalClose,
+ ModalContent,
+ ModalTrigger,
+ Select,
+ SelectItem,
+ Table,
+ TableContainer,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from '@app/components/v2';
+import { usePopUp, useToggle } from '@app/hooks';
+import { ServiceToken, WorkspaceEnv } from '@app/hooks/api/types';
+
+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 }
+];
+
+const createServiceTokenSchema = yup.object({
+ name: yup.string().required().label('Service Token Name'),
+ environment: yup.string().required().label('Environment'),
+ expiresIn: yup.string().required().label('Service Token Name')
+});
+
+export type CreateServiceToken = yup.InferType;
+
+type Props = {
+ tokens: ServiceToken[];
+ workspaceName: string;
+ environments: WorkspaceEnv[];
+ onDeleteToken: (serviceTokenID: string) => Promise;
+ onCreateToken: (data: CreateServiceToken) => Promise;
+};
+
+type DeleteModalData = { name: string; id: string };
+
+export const ServiceTokenSection = ({
+ tokens = [],
+ onDeleteToken,
+ workspaceName,
+ environments = [],
+ onCreateToken
+}: Props): JSX.Element => {
+ const [newToken, setToken] = useState('');
+ const { t } = useTranslation();
+ const [isTokenCopied, setIsTokenCopied] = useToggle(false);
+
+ 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([
+ 'createAPIToken',
+ 'deleteAPITokenConfirmation'
+ ] as const);
+
+ const {
+ control,
+ reset,
+ handleSubmit,
+ formState: { isSubmitting }
+ } = useForm({
+ resolver: yupResolver(createServiceTokenSchema)
+ });
+
+ const hasServiceToken = Boolean(newToken);
+
+ const onFormSubmit = async (data: CreateServiceToken) => {
+ const token = await onCreateToken(data);
+ setToken(token);
+ };
+
+ const onDeleteApproved = async () => {
+ await onDeleteToken((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id);
+ handlePopUpClose('deleteAPITokenConfirmation');
+ };
+
+ return (
+
+
+
+
{t('section-token:service-tokens')}
+
{t('section-token:service-tokens-description')}
+
+ Please, make sure you are on the
+
+ latest version of CLI
+
+ .
+
+
+
+
{
+ handlePopUpToggle('createAPIToken', open);
+ reset();
+ setToken('');
+ }}
+ >
+
+ }>
+ {t('section-token:add-new')}
+
+
+
+ {!hasServiceToken ? (
+
+ ) : (
+
+
{newToken}
+
+
+
+ {t('common:click-to-copy')}
+
+
+
+ )}
+
+
+
+
+
handlePopUpToggle('deleteAPITokenConfirmation', isOpen)}
+ deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
+ onClose={() => handlePopUpClose('deleteAPITokenConfirmation')}
+ onDeleteApproved={onDeleteApproved}
+ />
+
+
+
+
+ Token Name
+ Environment
+ Valid Until
+
+
+
+
+ {tokens?.length > 0 ? (
+ tokens.map((row) => (
+
+ {row.name}
+ {row.environment}
+ {new Date(row.expiresAt).toUTCString()}
+
+
+ handlePopUpOpen('deleteAPITokenConfirmation', {
+ name: row.name,
+ id: row._id
+ })
+ }
+ colorSchema="danger"
+ ariaLabel="delete"
+ >
+
+
+
+
+ ))
+ ) : (
+
+
+ No environmants found
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/index.tsx
new file mode 100644
index 000000000..d683dc3b8
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/index.tsx
@@ -0,0 +1 @@
+export {ServiceTokenSection} from './ServiceTokenSection'
\ No newline at end of file
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx
new file mode 100644
index 000000000..163483278
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx
@@ -0,0 +1,6 @@
+export { CopyProjectIDSection } from './CopyProjectIDSection';
+export { EnvironmentSection } from './EnvironmentSection';
+export type { CreateUpdateEnvFormData } from './EnvironmentSection/EnvironmentSection';
+export { ProjectNameChangeSection } from './ProjectNameChangeSection';
+export { ServiceTokenSection } from './ServiceTokenSection';
+export type { CreateServiceToken } from './ServiceTokenSection/ServiceTokenSection';
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/index.tsx
new file mode 100644
index 000000000..4c4efbdb7
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/index.tsx
@@ -0,0 +1 @@
+export { ProjectSettingsPage } from './ProjectSettingsPage';