+ {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,8 +147,8 @@ const EnvironmentTable = ({ data = [], onCreateEnv, onDeleteEnv, onUpdateEnv }:
))
) : (
-
- No environmants found
+
+ No environments 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/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx
new file mode 100644
index 000000000..9d278bdc4
--- /dev/null
+++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx
@@ -0,0 +1,299 @@
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import { faX } from '@fortawesome/free-solid-svg-icons';
+import { plans } from 'public/data/frequentConstants';
+
+import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
+import { Select, SelectItem } from '@app/components/v2';
+import updateUserProjectPermission from '@app/ee/api/memberships/UpdateUserProjectPermission';
+import getOrganizationSubscriptions from '@app/pages/api/organization/GetOrgSubscription';
+import changeUserRoleInWorkspace from '@app/pages/api/workspace/changeUserRoleInWorkspace';
+import deleteUserFromWorkspace from '@app/pages/api/workspace/deleteUserFromWorkspace';
+import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey';
+import getProjectInfo from '@app/pages/api/workspace/getProjectInfo';
+import uploadKeys from '@app/pages/api/workspace/uploadKeys';
+
+import { decryptAssymmetric, encryptAssymmetric } from '../../utilities/cryptography/crypto';
+import guidGenerator from '../../utilities/randomId';
+import Button from '../buttons/Button';
+import UpgradePlanModal from '../dialog/UpgradePlan';
+
+// const roles = ['admin', 'user'];
+// TODO: Set type for this
+type Props = {
+ userData: any[];
+ changeData: (users: any[]) => void;
+ myUser: string;
+ filter: string;
+};
+
+type EnvironmentProps = {
+ name: string;
+ slug: string;
+}
+
+/**
+ * This is the component that shows the users of a certin project
+ * #TODO: add the possibility of choosing and doing operations on multiple users.
+ * @param {*} props
+ * @returns
+ */
+const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
+ const [roleSelected, setRoleSelected] = useState(
+ Array(userData?.length).fill(userData.map((user) => user.role))
+ );
+ const host = window.location.origin;
+ const router = useRouter();
+ const [myRole, setMyRole] = useState('member');
+ const [currentPlan, setCurrentPlan] = useState('');
+ const [workspaceEnvs, setWorkspaceEnvs] = useState([]);
+ const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
+ const { createNotification } = useNotificationContext();
+
+ const workspaceId = router.query.id as string;
+ // Delete the row in the table (e.g. a user)
+ // #TODO: Add a pop-up that warns you that the user is going to be deleted.
+ const handleDelete = (membershipId: string, index: number) => {
+ // setUserIdToBeDeleted(userId);
+ // onClick();
+ deleteUserFromWorkspace(membershipId);
+ changeData(userData.filter((v, i) => i !== index));
+ setRoleSelected([
+ ...roleSelected.slice(0, index),
+ ...roleSelected.slice(index + 1, userData?.length)
+ ]);
+ };
+
+ // Update the rold of a certain user
+ const handleRoleUpdate = (index: number, e: string) => {
+ changeUserRoleInWorkspace(userData[index].membershipId, e.toLowerCase());
+ changeData([
+ ...userData.slice(0, index),
+ ...[
+ {
+ key: userData[index].key,
+ firstName: userData[index].firstName,
+ lastName: userData[index].lastName,
+ email: userData[index].email,
+ role: e.toLocaleLowerCase(),
+ status: userData[index].status,
+ userId: userData[index].userId,
+ membershipId: userData[index].membershipId,
+ publicKey: userData[index].publicKey,
+ deniedPermissions: userData[index].deniedPermissions
+ }
+ ],
+ ...userData.slice(index + 1, userData?.length)
+ ]);
+ createNotification({
+ text: `Successfully changed user role.`,
+ type: 'success'
+ });
+ };
+
+ const handlePermissionUpdate = (index: number, val: string, membershipId: string, slug: string ) => {
+ let denials: { ability: string; environmentSlug: string; }[];
+ if (val === "Read Only") {
+ denials = [{
+ ability: "write",
+ environmentSlug: slug
+ }];
+ } else if (val === "No Access") {
+ denials = [{
+ ability: "write",
+ environmentSlug: slug
+ }, {
+ ability: "read",
+ environmentSlug: slug
+ }];
+ } else {
+ denials = [];
+ }
+
+ if (currentPlan !== plans.professional && host === 'https://app.infisical.com') {
+ setIsUpgradeModalOpen(true);
+ } else {
+ const allDenials = userData[index].deniedPermissions.filter((perm: { ability: string; environmentSlug: string; }) => perm.environmentSlug !== slug).concat(denials);
+ updateUserProjectPermission({ membershipId, denials: allDenials});
+ changeData([
+ ...userData.slice(0, index),
+ ...[
+ {
+ key: userData[index].key,
+ firstName: userData[index].firstName,
+ lastName: userData[index].lastName,
+ email: userData[index].email,
+ role: userData[index].role,
+ status: userData[index].status,
+ userId: userData[index].userId,
+ membershipId: userData[index].membershipId,
+ publicKey: userData[index].publicKey,
+ deniedPermissions: allDenials
+ }
+ ],
+ ...userData.slice(index + 1, userData?.length)
+ ]);
+ createNotification({
+ text: `Successfully changed user permissions.`,
+ type: 'success'
+ });
+ }
+ };
+
+ useEffect(() => {
+ setMyRole(userData.filter((user) => user.email === myUser)[0]?.role);
+ (async () => {
+ const result = await getProjectInfo({ projectId: workspaceId });
+ setWorkspaceEnvs(result.environments);
+
+ const orgId = localStorage.getItem('orgData.id') as string;
+ const subscriptions = await getOrganizationSubscriptions({
+ orgId
+ });
+ if (subscriptions) {
+ setCurrentPlan(subscriptions.data[0].plan.product)
+ }
+ })();
+ }, [userData, myUser]);
+
+ const grantAccess = async (id: string, publicKey: string) => {
+ const result = await getLatestFileKey({ workspaceId });
+
+ const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
+
+ // assymmetrically decrypt symmetric key with local private key
+ const key = decryptAssymmetric({
+ ciphertext: result.latestKey.encryptedKey,
+ nonce: result.latestKey.nonce,
+ publicKey: result.latestKey.sender.publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ const { ciphertext, nonce } = encryptAssymmetric({
+ plaintext: key,
+ publicKey,
+ privateKey: PRIVATE_KEY
+ });
+
+ uploadKeys(workspaceId, id, ciphertext, nonce);
+ router.reload();
+ };
+
+ const closeUpgradeModal = () => {
+ setIsUpgradeModalOpen(false);
+ }
+
+ return (
+
+
+
+
+
+
+ NAME
+ EMAIL
+ ROLE
+ {workspaceEnvs.map(env => (
+
+ {env.name.toUpperCase()}
+ {/* PERMISSION */}
+
+ ))}
+
+
+
+
+ {userData?.filter(
+ (user) =>
+ user.firstName?.toLowerCase().includes(filter) ||
+ user.lastName?.toLowerCase().includes(filter) ||
+ user.email?.toLowerCase().includes(filter)
+ ).length > 0 &&
+ userData
+ ?.filter(
+ (user) =>
+ user.firstName?.toLowerCase().includes(filter) ||
+ user.lastName?.toLowerCase().includes(filter) ||
+ user.email?.toLowerCase().includes(filter)
+ )
+ .map((row, index) => (
+
+
+ {row.firstName} {row.lastName}
+
+
+ {row.email}
+
+
+
+
handleRoleUpdate(index, e)}
+ value={row.role}
+ disabled={myRole !== 'admin' || myUser === row.email}
+ // onOpenChange={(open) => setIsOpen(open)}
+ >
+ Admin
+ Member
+
+ {row.status === 'completed' && myUser !== row.email && (
+
+ grantAccess(row.userId, row.publicKey)}
+ color="mineshaft"
+ text="Grant Access"
+ size="md"
+ />
+
+ )}
+
+
+ {workspaceEnvs.map((env) =>
+ handlePermissionUpdate(index, val, row.membershipId, env.slug)}
+ value={
+ // eslint-disable-next-line no-nested-ternary
+ (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read"))
+ ? "No Access"
+ : (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") ? "Read Only" : "Read & Write")
+ }
+ disabled={myRole !== 'admin'}
+ // onOpenChange={(open) => setIsOpen(open)}
+ >
+ No Access
+ Read Only
+ Read & Write
+
+ )}
+
+ {myUser !== row.email &&
+ // row.role !== "admin" &&
+ myRole !== 'member' ? (
+
+ handleDelete(row.membershipId, index)}
+ color="red"
+ size="icon-sm"
+ icon={faX}
+ />
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+ );
+};
+
+export default ProjectUsersTable;
diff --git a/frontend/src/components/basic/table/UserTable.tsx b/frontend/src/components/basic/table/UserTable.tsx
index 94eda06f1..02e65c547 100644
--- a/frontend/src/components/basic/table/UserTable.tsx
+++ b/frontend/src/components/basic/table/UserTable.tsx
@@ -2,8 +2,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { faX } from '@fortawesome/free-solid-svg-icons';
+import changeUserRoleInOrganization from '@app/pages/api/organization/changeUserRoleInOrganization';
import deleteUserFromOrganization from '@app/pages/api/organization/deleteUserFromOrganization';
-import changeUserRoleInWorkspace from '@app/pages/api/workspace/changeUserRoleInWorkspace';
import deleteUserFromWorkspace from '@app/pages/api/workspace/deleteUserFromWorkspace';
import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey';
import uploadKeys from '@app/pages/api/workspace/uploadKeys';
@@ -55,9 +55,9 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
]);
};
- // Update the rold of a certain user
+ // Update the role of a certain user
const handleRoleUpdate = (index: number, e: string) => {
- changeUserRoleInWorkspace(userData[index].membershipId, e);
+ changeUserRoleInOrganization(String(localStorage.getItem("orgData.id")), userData[index].membershipId, e);
changeData([
...userData.slice(0, index),
...[
@@ -110,14 +110,14 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
};
return (
-
+
- FIRST NAME
- LAST NAME
- EMAIL
+ NAME
+ EMAIL
+ ROLE
@@ -136,35 +136,30 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
user.email?.toLowerCase().includes(filter)
)
.map((row, index) => (
-
-
- {row.firstName}
+
+
+ {row.firstName} {row.lastName}
-
- {row.lastName}
-
-
+
{row.email}
-
-
- {row.status === 'granted' &&
+
+
+ {row.status === 'accepted' &&
((myRole === 'admin' && row.role !== 'owner') || myRole === 'owner') &&
- myUser !== row.email ? (
+ (myUser !== row.email) ? (
handleRoleUpdate(index, e)}
data={
myRole === 'owner' ? ['owner', 'admin', 'member'] : ['admin', 'member']
}
- text="Role: "
/>
) : (
row.status !== 'invited' &&
row.status !== 'verified' && (
{
throw new Error('Function not implemented.');
}}
@@ -173,7 +168,7 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
)
)}
{(row.status === 'invited' || row.status === 'verified') && (
-
+
deleteMembershipAndResendInvite(row.email)}
color="mineshaft"
@@ -193,10 +188,12 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
)}
+
+
{myUser !== row.email &&
// row.role !== "admin" &&
myRole !== 'member' ? (
-
+
handleDelete(row.membershipId, index)}
color="red"
diff --git a/frontend/src/components/dashboard/CompareSecretsModal.tsx b/frontend/src/components/dashboard/CompareSecretsModal.tsx
new file mode 100644
index 000000000..b5d81bc04
--- /dev/null
+++ b/frontend/src/components/dashboard/CompareSecretsModal.tsx
@@ -0,0 +1,88 @@
+import { SetStateAction, useEffect, useState } from 'react';
+import Image from 'next/image';
+
+import { WorkspaceEnv } from '@app/hooks/api/types';
+
+import getSecretsForProject from '../utilities/secrets/getSecretsForProject';
+import { Modal, ModalContent } from '../v2';
+
+interface Secrets {
+ label: string;
+ secret: string;
+}
+
+interface CompareSecretsModalProps {
+ compareModal: boolean;
+ setCompareModal: React.Dispatch>;
+ selectedEnv: WorkspaceEnv;
+ workspaceEnvs: WorkspaceEnv[];
+ workspaceId: string;
+ currentSecret: {
+ key: string;
+ value: string;
+ };
+}
+
+const CompareSecretsModal = ({
+ compareModal,
+ setCompareModal,
+ selectedEnv,
+ workspaceEnvs,
+ workspaceId,
+ currentSecret
+}: CompareSecretsModalProps) => {
+ const [secrets, setSecrets] = useState([]);
+
+ const getEnvSecrets = async () => {
+ const workspaceEnvironments = workspaceEnvs?.filter((env) => env !== selectedEnv);
+ const newSecrets = await Promise.all(
+ workspaceEnvironments.map(async (env) => {
+ // #TODO: optimize this query somehow...
+ const allSecrets = await getSecretsForProject({ env: env.slug, workspaceId });
+ const secret =
+ allSecrets.find((item) => item.key === currentSecret.key)?.value ?? 'Not found';
+ return { label: env.name, secret };
+ })
+ );
+ setSecrets([{ label: selectedEnv.name, secret: currentSecret.value }, ...newSecrets]);
+ };
+
+ useEffect(() => {
+ if (compareModal) {
+ (async () => {
+ await getEnvSecrets();
+ })();
+ }
+ }, [compareModal]);
+
+ return (
+
+ e.preventDefault()}>
+
+ {secrets.length === 0 ? (
+
+
+
+ ) : (
+ secrets.map((item) => (
+
+ ))
+ )}
+
+
+
+ );
+};
+export default CompareSecretsModal;
diff --git a/frontend/src/components/dashboard/SideBar.tsx b/frontend/src/components/dashboard/SideBar.tsx
index 1a3f99a29..d602e2c93 100644
--- a/frontend/src/components/dashboard/SideBar.tsx
+++ b/frontend/src/components/dashboard/SideBar.tsx
@@ -6,10 +6,12 @@ import { faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SecretVersionList from '@app/ee/components/SecretVersionList';
+import { WorkspaceEnv } from '@app/hooks/api/types';
import Button from '../basic/buttons/Button';
import Toggle from '../basic/Toggle';
import CommentField from './CommentField';
+import CompareSecretsModal from './CompareSecretsModal';
import DashboardInputField from './DashboardInputField';
import { DeleteActionButton } from './DeleteActionButton';
import GenerateSecretMenu from './GenerateSecretMenu';
@@ -40,6 +42,9 @@ interface SideBarProps {
sharedToHide: string[];
setSharedToHide: (values: string[]) => void;
deleteRow: (props: DeleteRowFunctionProps) => void;
+ workspaceEnvs: WorkspaceEnv[];
+ selectedEnv: WorkspaceEnv;
+ workspaceId: string;
}
/**
@@ -63,15 +68,19 @@ const SideBar = ({
modifyComment,
buttonReady,
savePush,
- deleteRow
+ deleteRow,
+ workspaceEnvs,
+ selectedEnv,
+ workspaceId
}: SideBarProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoading, setIsLoading] = useState(false);
- const [overrideEnabled, setOverrideEnabled] = useState(data[0].valueOverride !== undefined);
+ const [overrideEnabled, setOverrideEnabled] = useState(data[0]?.valueOverride !== undefined);
+ const [compareModal, setCompareModal] = useState(false);
const { t } = useTranslation();
return (
-
+
{isLoading ? (
)}
-
-
-
- deleteRow({ ids: data.map((secret) => secret.id), secretName: data[0]?.key })
- }
- />
+
+
+ setCompareModal(true)}
+ />
+
+
+
+
+
+ deleteRow({ ids: data.map((secret) => secret.id), secretName: data[0]?.key })
+ }
+ />
+
);
diff --git a/frontend/src/components/navigation/NavBarDashboard.tsx b/frontend/src/components/navigation/NavBarDashboard.tsx
index d681930ac..81db93cda 100644
--- a/frontend/src/components/navigation/NavBarDashboard.tsx
+++ b/frontend/src/components/navigation/NavBarDashboard.tsx
@@ -93,7 +93,7 @@ export default function Navbar() {
};
return (
-
+
diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx
index c550d46e9..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,8 +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 () => {
@@ -33,29 +34,24 @@ export default function NavHeader({
orgId: orgId || ''
});
setOrgName(org.name);
-
- const workspace = await getProjectInfo({
- projectId: String(router.query.id)
- });
- 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/components/utilities/config/index.ts b/frontend/src/components/utilities/config/index.ts
index 7570785ba..7ecfb9d4d 100644
--- a/frontend/src/components/utilities/config/index.ts
+++ b/frontend/src/components/utilities/config/index.ts
@@ -4,11 +4,12 @@ const POSTHOG_HOST =
process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com";
const STRIPE_PRODUCT_PRO = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_PRO!;
const STRIPE_PRODUCT_STARTER = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_STARTER!;
+const {SITE_URL} = process.env
export {
ENV,
POSTHOG_API_KEY,
POSTHOG_HOST,
+ SITE_URL,
STRIPE_PRODUCT_PRO,
- STRIPE_PRODUCT_STARTER
-};
\ No newline at end of file
+ STRIPE_PRODUCT_STARTER};
\ No newline at end of file
diff --git a/frontend/src/components/utilities/generateBackupPDF.ts b/frontend/src/components/utilities/generateBackupPDF.ts
index a4ee0035e..e51dfaa0a 100644
--- a/frontend/src/components/utilities/generateBackupPDF.ts
+++ b/frontend/src/components/utilities/generateBackupPDF.ts
@@ -1,5 +1,7 @@
import { jsPDF } from 'jspdf';
+import { SITE_URL } from './config';
+
interface PDFProps {
personalName: string;
personalEmail: string;
@@ -73,7 +75,7 @@ function generateBackupPDF({ personalName, personalEmail, generatedKey }: PDFPro
doc.roundedRect(170, 488, 375, 35, 5, 5, 'F');
doc.setTextColor(23, 23, 23);
doc.setFontSize(14);
- doc.text('https://app.infisical.com/login', 180, 420);
+ doc.text(`${SITE_URL}/login`, 180, 420);
doc.text(personalEmail, 180, 465);
doc.text(generatedKey, 180, 510);
doc.text('Need help? Contact us at support@infisical.com', 32, 575);
diff --git a/frontend/src/components/utilities/secrets/getSecretsForProject.ts b/frontend/src/components/utilities/secrets/getSecretsForProject.ts
index c00f13d04..62c1e2e2d 100644
--- a/frontend/src/components/utilities/secrets/getSecretsForProject.ts
+++ b/frontend/src/components/utilities/secrets/getSecretsForProject.ts
@@ -29,8 +29,8 @@ interface SecretProps {
interface FunctionProps {
env: string;
- setIsKeyAvailable: any;
- setData: any;
+ setIsKeyAvailable?: any;
+ setData?: any;
workspaceId: string;
}
@@ -58,7 +58,9 @@ const getSecretsForProject = async ({
const latestKey = await getLatestFileKey({ workspaceId });
// This is called isKeyAvailable but what it really means is if a person is able to create new key pairs
- setIsKeyAvailable(!latestKey ? encryptedSecrets.length === 0 : true);
+ if (typeof setIsKeyAvailable === 'function') {
+ setIsKeyAvailable(!latestKey ? encryptedSecrets.length === 0 : true);
+ }
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
@@ -131,7 +133,10 @@ const getSecretsForProject = async ({
)[0]?.comment
}));
- setData(result);
+ if (typeof setData === 'function') {
+ setData(result);
+ }
+
return result;
} catch (error) {
console.log('Something went wrong during accessing or decripting secrets.');
diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx
index 21548d0e3..e16e09128 100644
--- a/frontend/src/components/v2/Button/Button.tsx
+++ b/frontend/src/components/v2/Button/Button.tsx
@@ -18,14 +18,15 @@ const buttonVariants = cva(
'font-inter font-medium',
'cursor-pointer',
'inline-flex items-center justify-center',
- 'relative'
+ 'relative',
+ 'whitespace-nowrap'
],
{
variants: {
colorSchema: {
primary: ['bg-primary', 'text-black', 'border-primary hover:bg-opacity-80'],
secondary: ['bg-mineshaft', 'text-gray-300', 'border-mineshaft hover:bg-opacity-80'],
- danger: ['bg-red', 'text-white', 'border-red']
+ danger: ['bg-red', 'text-white', 'border-red hover:bg-opacity-90']
},
variant: {
solid: '',
@@ -33,7 +34,7 @@ const buttonVariants = cva(
plain: ''
},
isDisabled: {
- true: 'bg-opacity-70 cursor-not-allowed',
+ true: 'bg-mineshaft opacity-40 cursor-not-allowed',
false: ''
},
isFullWidth: {
@@ -45,9 +46,9 @@ const buttonVariants = cva(
false: ''
},
size: {
- xs: ['text-xs', 'py-1', 'px-2'],
- sm: ['text-sm', 'py-2', 'px-4'],
- md: ['text-md', 'py-2', 'px-6'],
+ xs: ['text-xs', 'py-1', 'px-1'],
+ sm: ['text-sm', 'py-2', 'px-2'],
+ md: ['text-md', 'py-2', 'px-4'],
lg: ['text-lg', 'py-2', 'px-8']
}
},
@@ -75,7 +76,7 @@ const buttonVariants = cva(
{
colorSchema: 'secondary',
variant: 'plain',
- className: 'text-mineshaft'
+ className: 'text-mineshaft-300'
},
{
colorSchema: 'danger',
@@ -101,7 +102,7 @@ export const Button = forwardRef
(
children,
isDisabled = false,
className = '',
- size = 'md',
+ size = 'sm',
variant = 'solid',
isFullWidth,
isRounded = true,
@@ -144,9 +145,10 @@ export const Button = forwardRef(
)}
{leftIcon}
@@ -154,7 +156,7 @@ export const Button = forwardRef(
{children}
(
-
+
{children}
{subTitle &&
{subTitle}
}
@@ -29,7 +29,7 @@ export type CardBodyProps = {
};
export const CardBody = ({ children, className }: CardBodyProps) => (
-
{children}
+
{children}
);
export type CardProps = {
@@ -47,7 +47,7 @@ export const Card = forwardRef
(
void;
+ onChange?: (isOpen: boolean) => void;
+ deleteKey: string;
+ title: string;
+ onDeleteApproved: () => Promise;
+};
+
+export const DeleteActionModal = ({
+ isOpen,
+ onClose,
+ onChange,
+ deleteKey,
+ onDeleteApproved,
+ title
+}: Props): JSX.Element => {
+ const [inputData, setInputData] = useState('');
+ const [isLoading, setIsLoading] = useToggle();
+
+ useEffect(() => {
+ setInputData('');
+ }, [isOpen]);
+
+ const onDelete = async () => {
+ setIsLoading.on();
+ try {
+ await onDeleteApproved();
+ } catch {
+ setIsLoading.off();
+ } finally {
+ setIsLoading.off();
+ }
+ };
+
+ return (
+ {
+ setInputData('');
+ if (onChange) onChange(isOpenState);
+ }}
+ >
+
+
+ Delete
+
+
+ Cancel
+
+
+ }
+ onClose={onClose}
+ >
+
+ }
+ className="mb-4"
+ >
+ setInputData(e.target.value)} />
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/v2/DeleteActionModal/index.tsx b/frontend/src/components/v2/DeleteActionModal/index.tsx
new file mode 100644
index 000000000..b69763984
--- /dev/null
+++ b/frontend/src/components/v2/DeleteActionModal/index.tsx
@@ -0,0 +1 @@
+export { DeleteActionModal } from './DeleteActionModal';
diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx
index 14e58d5be..5a545acec 100644
--- a/frontend/src/components/v2/FormControl/FormControl.tsx
+++ b/frontend/src/components/v2/FormControl/FormControl.tsx
@@ -11,7 +11,7 @@ export type FormLabelProps = {
};
export const FormLabel = ({ id, label, isRequired }: FormLabelProps) => (
-
+
{label}
{isRequired && * }
@@ -25,7 +25,7 @@ export type FormHelperTextProps = {
export const FormHelperText = ({ isError, text }: FormHelperTextProps) => (
@@ -46,6 +46,7 @@ export type FormControlProps = {
helperText?: ReactNode;
errorText?: ReactNode;
children: JSX.Element;
+ className?: string;
};
export const FormControl = ({
@@ -55,10 +56,11 @@ export const FormControl = ({
helperText,
errorText,
id,
- isError
+ isError,
+ className
}: FormControlProps): JSX.Element => {
return (
-
+
{typeof label === 'string' ? (
) : (
diff --git a/frontend/src/components/v2/IconButton/IconButton.tsx b/frontend/src/components/v2/IconButton/IconButton.tsx
index b1a1f4813..d0500ab80 100644
--- a/frontend/src/components/v2/IconButton/IconButton.tsx
+++ b/frontend/src/components/v2/IconButton/IconButton.tsx
@@ -14,7 +14,7 @@ const iconButtonVariants = cva(
[
'button',
'transition-all',
- 'font-inter font-medium',
+ 'font-inter font-medium user-select-none',
'cursor-pointer',
'inline-flex items-center justify-center',
'relative'
@@ -22,9 +22,9 @@ const iconButtonVariants = cva(
{
variants: {
colorSchema: {
- primary: ['bg-primary', 'text-black', 'border-primary hover:bg-opacity-80'],
- secondary: ['bg-mineshaft', 'text-gray-300', 'border-mineshaft hover:bg-opacity-80'],
- danger: ['bg-red', 'text-white', 'border-red']
+ primary: ['bg-primary', 'text-black', 'border-primary hover:opacity-80'],
+ secondary: ['bg-mineshaft', 'text-gray-300', 'border-mineshaft hover:bg-bunker-400'],
+ danger: ['bg-[#973939] hover:bg-red', 'text-white', 'border-red']
},
variant: {
solid: '',
@@ -40,10 +40,10 @@ const iconButtonVariants = cva(
false: ''
},
size: {
- xs: ['text-xs', 'py-1', 'px-2'],
- sm: ['text-sm', 'py-2', 'px-3'],
- md: ['text-md', 'py-3', 'px-4'],
- lg: ['text-lg', 'py-3', 'px-6']
+ xs: ['text-xs', 'py-2', 'px-2'],
+ sm: ['text-sm', 'py-3', 'px-3'],
+ md: ['text-md', 'py-4', 'px-4'],
+ lg: ['text-lg', 'py-6', 'px-6']
}
},
compoundVariants: [
@@ -97,7 +97,7 @@ export const IconButton = forwardRef
(
ariaLabel,
isDisabled = false,
className,
- size = 'md',
+ size = 'sm',
variant = 'solid',
isRounded = true,
colorSchema = 'primary',
diff --git a/frontend/src/components/v2/Input/Input.tsx b/frontend/src/components/v2/Input/Input.tsx
index 4f502e31d..28524c72d 100644
--- a/frontend/src/components/v2/Input/Input.tsx
+++ b/frontend/src/components/v2/Input/Input.tsx
@@ -8,10 +8,11 @@ type Props = {
isRequired?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
+ isDisabled?: boolean;
};
const inputVariants = cva(
- 'input w-full py-2 text-gray-400 placeholder-gray-500 placeholder-opacity-50',
+ 'input w-full py-2 text-gray-400 placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2',
{
variants: {
size: {
@@ -46,7 +47,7 @@ const inputParentContainerVariants = cva('inline-flex font-inter items-center bo
},
isError: {
true: 'border-red',
- false: 'border-mineshaft-400'
+ false: 'border-mineshaft-500'
},
isFullWidth: {
true: 'w-full',
@@ -70,6 +71,7 @@ export const Input = forwardRef(
className,
isRounded = true,
isFullWidth = true,
+ isDisabled,
isError = false,
isRequired,
leftIcon,
@@ -87,9 +89,10 @@ export const Input = forwardRef(
{...props}
required={isRequired}
ref={ref}
+ disabled={isDisabled}
className={twMerge(
- leftIcon ? 'pl-9' : 'pl-4',
- rightIcon ? 'pr-9' : 'pr-4',
+ leftIcon ? 'pl-9' : 'pl-2.5',
+ rightIcon ? 'pr-9' : 'pr-2.5',
inputVariants({ className, isError, size, isRounded, variant })
)}
/>
diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx
index c7d42541d..41bfb9da7 100644
--- a/frontend/src/components/v2/Modal/Modal.tsx
+++ b/frontend/src/components/v2/Modal/Modal.tsx
@@ -11,33 +11,34 @@ export type ModalContentProps = DialogPrimitive.DialogContentProps & {
title?: ReactNode;
subTitle?: string;
footerContent?: ReactNode;
+ onClose?: () => void;
};
export const ModalContent = forwardRef(
- ({ children, title, subTitle, className, footerContent, ...props }, forwardedRef) => (
+ ({ children, title, subTitle, className, footerContent, onClose, ...props }, forwardedRef) => (
{title && {title} }
{children}
{footerContent && {footerContent} }
-
+
-
+
@@ -55,3 +56,6 @@ export const Modal = ({ isOpen, ...props }: ModalProps) => (
export const ModalTrigger = DialogPrimitive.Trigger;
export type ModalTriggerProps = DialogPrimitive.DialogTriggerProps;
+
+export const ModalClose = DialogPrimitive.Close;
+export type ModalCloseProps = DialogPrimitive.DialogCloseProps;
diff --git a/frontend/src/components/v2/Modal/index.tsx b/frontend/src/components/v2/Modal/index.tsx
index c97a564fb..7517b3ec1 100644
--- a/frontend/src/components/v2/Modal/index.tsx
+++ b/frontend/src/components/v2/Modal/index.tsx
@@ -1,2 +1,2 @@
-export type { ModalContentProps, ModalProps, ModalTriggerProps } from './Modal';
-export { Modal, ModalContent, ModalTrigger } from './Modal';
+export type { ModalCloseProps, ModalContentProps, ModalProps, ModalTriggerProps } from './Modal';
+export { Modal, ModalClose, ModalContent, ModalTrigger } from './Modal';
diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx
index c00223e2b..0a691e9eb 100644
--- a/frontend/src/components/v2/Select/Select.tsx
+++ b/frontend/src/components/v2/Select/Select.tsx
@@ -10,39 +10,45 @@ type Props = {
children: ReactNode;
placeholder?: string;
className?: string;
+ dropdownContainerClassName?: string;
isLoading?: boolean;
};
export type SelectProps = SelectPrimitive.SelectProps & Props;
export const Select = forwardRef(
- ({ children, placeholder, className, isLoading, ...props }, ref): JSX.Element => {
+ (
+ { children, placeholder, className, isLoading, dropdownContainerClassName, ...props },
+ ref
+ ): JSX.Element => {
return (
-
-
-
+ {!props.disabled && (
+
+
+
+ )}
-
+
{isLoading ? (
@@ -75,16 +81,16 @@ export const SelectItem = forwardRef
(
-
+
{children}
diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx
index 0c2b52eb8..d44ba7755 100644
--- a/frontend/src/components/v2/Table/Table.tsx
+++ b/frontend/src/components/v2/Table/Table.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from 'react';
+import { HTMLAttributes, ReactNode, TdHTMLAttributes } from 'react';
import { twMerge } from 'tailwind-merge';
export type TableContainerProps = {
@@ -14,7 +14,7 @@ export const TableContainer = ({
}: TableContainerProps): JSX.Element => (
(
@@ -47,7 +47,7 @@ export type THeadProps = {
};
export const THead = ({ children, className }: THeadProps): JSX.Element => (
-
+
{children}
);
@@ -56,20 +56,22 @@ export const THead = ({ children, className }: THeadProps): JSX.Element => (
export type TrProps = {
children: ReactNode;
className?: string;
-};
+} & HTMLAttributes;
-export const Tr = ({ children, className }: TrProps): JSX.Element => (
- {children}
+export const Tr = ({ children, className, ...props }: TrProps): JSX.Element => (
+
+ {children}
+
);
// table head columns
export type ThProps = {
- children: ReactNode;
+ children?: ReactNode;
className?: string;
};
export const Th = ({ children, className }: ThProps): JSX.Element => (
- {children}
+ {children}
);
// table body
@@ -84,10 +86,12 @@ export const TBody = ({ children, className }: TBodyProps): JSX.Element => (
// table body columns
export type TdProps = {
- children: ReactNode;
+ children?: ReactNode;
className?: string;
-};
+} & TdHTMLAttributes;
-export const Td = ({ children, className }: TdProps): JSX.Element => (
- {children}
+export const Td = ({ children, className, ...props }: TdProps): JSX.Element => (
+
+ {children}
+
);
diff --git a/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx b/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx
new file mode 100644
index 000000000..e991ca3a8
--- /dev/null
+++ b/frontend/src/components/v2/UpgradePlanModal/UpgradePlanModal.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+
+import { Button } from '../Button';
+import { Modal, ModalClose, ModalContent } from '../Modal';
+
+type Props = {
+ isOpen?: boolean;
+ onOpenChange?: (isOpen: boolean) => void;
+ text: string;
+};
+
+export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Element => (
+
+
+ Upgrade Plan
+ ,
+
+
+ Cancel
+
+
+ ]}
+ >
+ {text}
+
+ Upgrade and get access to this, as well as to other powerful enhancements.
+
+
+
+);
diff --git a/frontend/src/components/v2/UpgradePlanModal/index.tsx b/frontend/src/components/v2/UpgradePlanModal/index.tsx
new file mode 100644
index 000000000..e4649c2df
--- /dev/null
+++ b/frontend/src/components/v2/UpgradePlanModal/index.tsx
@@ -0,0 +1 @@
+export { UpgradePlanModal } from './UpgradePlanModal';
diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx
index 437114047..0f89a3a2b 100644
--- a/frontend/src/components/v2/index.tsx
+++ b/frontend/src/components/v2/index.tsx
@@ -1,6 +1,7 @@
export * from './Button';
export * from './Card';
export * from './Checkbox';
+export * from './DeleteActionModal';
export * from './Dropdown';
export * from './FormControl';
export * from './IconButton';
@@ -12,3 +13,4 @@ export * from './Spinner';
export * from './Switch';
export * from './Table';
export * from './TextArea';
+export * from './UpgradePlanModal';
diff --git a/frontend/src/const.ts b/frontend/src/const.ts
index 0e5ed9516..4583d1dbb 100644
--- a/frontend/src/const.ts
+++ b/frontend/src/const.ts
@@ -15,10 +15,32 @@ export const publicPaths = [
`/privacy`,
`/terms`,
`/subprocessors`,
+ `/verify-email`,
+ `/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/context/SubscriptionContext/SubscriptionContext.tsx b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx
new file mode 100644
index 000000000..ae9910224
--- /dev/null
+++ b/frontend/src/context/SubscriptionContext/SubscriptionContext.tsx
@@ -0,0 +1,47 @@
+import { createContext, ReactNode, useContext, useMemo } from 'react';
+
+import { useGetOrgSubscription } from '@app/hooks/api';
+import { GetSubscriptionPlan } from '@app/hooks/api/types';
+
+import { useWorkspace } from '../WorkspaceContext';
+// import { Subscription } from '@app/hooks/api/workspace/types';
+
+type TSubscriptionContext = {
+ subscription?: GetSubscriptionPlan;
+ subscriptionPlan: string;
+ isLoading: boolean;
+};
+
+const SubscriptionContext = createContext(null);
+
+type Props = {
+ children: ReactNode;
+};
+
+export const SubscriptionProvider = ({ children }: Props): JSX.Element => {
+ const { currentWorkspace } = useWorkspace();
+ const { data, isLoading } = useGetOrgSubscription({
+ orgID: currentWorkspace?.organization || ''
+ });
+
+ // memorize the workspace details for the context
+ const value = useMemo(
+ () => ({
+ subscription: data,
+ subscriptionPlan: data?.data?.[0]?.plan?.product || '',
+ isLoading
+ }),
+ [data, isLoading]
+ );
+
+ return {children} ;
+};
+
+export const useSubscription = () => {
+ const ctx = useContext(SubscriptionContext);
+ if (!ctx) {
+ throw new Error('useSubscription has to be used within ');
+ }
+
+ return ctx;
+};
diff --git a/frontend/src/context/SubscriptionContext/index.tsx b/frontend/src/context/SubscriptionContext/index.tsx
new file mode 100644
index 000000000..9a2b07b15
--- /dev/null
+++ b/frontend/src/context/SubscriptionContext/index.tsx
@@ -0,0 +1 @@
+export { SubscriptionProvider, useSubscription } from './SubscriptionContext';
diff --git a/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx
new file mode 100644
index 000000000..4edef8e6a
--- /dev/null
+++ b/frontend/src/context/WorkspaceContext/WorkspaceContext.tsx
@@ -0,0 +1,43 @@
+import { createContext, ReactNode, useContext, useMemo } from 'react';
+import { useRouter } from 'next/router';
+
+import { useGetUserWorkspaces } from '@app/hooks/api';
+import { Workspace } from '@app/hooks/api/workspace/types';
+
+type TWorkspaceContext = {
+ workspaces: Workspace[];
+ currentWorkspace?: Workspace;
+ isLoading: boolean;
+};
+
+const WorkspaceContext = createContext(null);
+
+type Props = {
+ children: ReactNode;
+};
+
+export const WorkspaceProvider = ({ children }: Props): JSX.Element => {
+ const { data: ws, isLoading } = useGetUserWorkspaces();
+ const router = useRouter();
+ const workspaceId = router.query.id;
+
+ // memorize the workspace details for the context
+ const value = useMemo(() => {
+ return {
+ workspaces: ws || [],
+ currentWorkspace: (ws || []).find(({ _id: id }) => id === workspaceId),
+ isLoading
+ };
+ }, [ws, workspaceId, isLoading]);
+
+ return {children} ;
+};
+
+export const useWorkspace = () => {
+ const ctx = useContext(WorkspaceContext);
+ if (!ctx) {
+ throw new Error('useWorkspace has to be used within ');
+ }
+
+ return ctx;
+};
diff --git a/frontend/src/context/WorkspaceContext/index.tsx b/frontend/src/context/WorkspaceContext/index.tsx
new file mode 100644
index 000000000..3496038c3
--- /dev/null
+++ b/frontend/src/context/WorkspaceContext/index.tsx
@@ -0,0 +1 @@
+export { useWorkspace, WorkspaceProvider } from './WorkspaceContext';
diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx
new file mode 100644
index 000000000..1bf59f9ac
--- /dev/null
+++ b/frontend/src/context/index.tsx
@@ -0,0 +1,2 @@
+export { SubscriptionProvider, useSubscription } from './SubscriptionContext';
+export { useWorkspace, WorkspaceProvider } from './WorkspaceContext';
diff --git a/frontend/src/ee/api/memberships/UpdateUserProjectPermission.ts b/frontend/src/ee/api/memberships/UpdateUserProjectPermission.ts
new file mode 100644
index 000000000..1611f13b0
--- /dev/null
+++ b/frontend/src/ee/api/memberships/UpdateUserProjectPermission.ts
@@ -0,0 +1,39 @@
+import SecurityClient from '@app/components/utilities/SecurityClient';
+
+/**
+ * This function updates user permissions for a certain environment in a project
+ * @param {object} obj
+ * @param {string} obj.membershipId - membershipId of a certain user in a project
+ * @param {*[]} obj.denials - permissions that we are prohibitting users to do
+ * @returns
+ */
+const updateUserProjectPermission = async ({
+ membershipId,
+ denials
+}: {
+ membershipId: string;
+ denials: {
+ ability: string;
+ environmentSlug: string;
+ }[]
+}) =>
+ SecurityClient.fetchCall(`/api/v1/membership/${membershipId}/deny-permissions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ permissions: denials
+ })
+ }).then(async (res) => {
+ console.log({
+ permissions: denials
+ }, res)
+ if (res && res.status === 200) {
+ return res.json();
+ }
+ console.log('Failed to update user permissions for a certain environment in a project');
+ return undefined;
+ });
+
+export default updateUserProjectPermission;
diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx
index 9df70dcf0..424fb514a 100644
--- a/frontend/src/hooks/api/index.tsx
+++ b/frontend/src/hooks/api/index.tsx
@@ -1 +1,5 @@
-export { useGetAuthToken } from './auth';
+export * from './auth';
+export * from './keys';
+export * from './serviceTokens';
+export * from './subscriptions';
+export * from './workspace';
diff --git a/frontend/src/hooks/api/keys/index.tsx b/frontend/src/hooks/api/keys/index.tsx
new file mode 100644
index 000000000..23ffdf8d1
--- /dev/null
+++ b/frontend/src/hooks/api/keys/index.tsx
@@ -0,0 +1 @@
+export { useGetUserWsKey } from './queries';
diff --git a/frontend/src/hooks/api/keys/queries.tsx b/frontend/src/hooks/api/keys/queries.tsx
new file mode 100644
index 000000000..d37c73f13
--- /dev/null
+++ b/frontend/src/hooks/api/keys/queries.tsx
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { apiRequest } from '@app/config/request';
+
+import { UserWsKeyPair } from './types';
+
+const encKeyKeys = {
+ getUserWorkspaceKey: (workspaceID: string) => ['worksapce-key-pair', { workspaceID }] as const
+};
+
+const fetchUserWsKey = async (workspaceID: string) => {
+ const { data } = await apiRequest.get<{ latestKey: UserWsKeyPair }>(
+ `/api/v1/key/${workspaceID}/latest`
+ );
+
+ return data.latestKey;
+};
+
+export const useGetUserWsKey = (workspaceID: string) =>
+ useQuery({
+ queryKey: encKeyKeys.getUserWorkspaceKey(workspaceID),
+ queryFn: () => fetchUserWsKey(workspaceID),
+ enabled: Boolean(workspaceID)
+ });
diff --git a/frontend/src/hooks/api/keys/types.ts b/frontend/src/hooks/api/keys/types.ts
new file mode 100644
index 000000000..bad61b5dd
--- /dev/null
+++ b/frontend/src/hooks/api/keys/types.ts
@@ -0,0 +1,22 @@
+export type UserWsKeyPair = {
+ _id: string;
+ encryptedKey: string;
+ nonce: string;
+ sender: Sender;
+ receiver: string;
+ workspace: string;
+ createdAt: string;
+ updatedAt: string;
+ __v: number;
+};
+
+export type Sender = {
+ _id: string;
+ email: string;
+ createdAt: string;
+ updatedAt: string;
+ __v: number;
+ firstName: string;
+ lastName: string;
+ publicKey: string;
+};
diff --git a/frontend/src/hooks/api/serviceTokens/index.ts b/frontend/src/hooks/api/serviceTokens/index.ts
new file mode 100644
index 000000000..548ad313a
--- /dev/null
+++ b/frontend/src/hooks/api/serviceTokens/index.ts
@@ -0,0 +1 @@
+export { useCreateServiceToken, useDeleteServiceToken, useGetUserWsServiceTokens } from './queries';
diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx
new file mode 100644
index 000000000..70e0736d3
--- /dev/null
+++ b/frontend/src/hooks/api/serviceTokens/queries.tsx
@@ -0,0 +1,60 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { apiRequest } from '@app/config/request';
+
+import {
+ CreateServiceTokenDTO,
+ CreateServiceTokenRes,
+ DeleteServiceTokenRes,
+ ServiceToken
+} from './types';
+
+const serviceTokenKeys = {
+ getAllWorkspaceServiceToken: (workspaceID: string) => [{ workspaceID }, 'service-tokens'] as const
+};
+
+const fetchWorkspaceServiceTokens = async (workspaceID: string) => {
+ const { data } = await apiRequest.get<{ serviceTokenData: ServiceToken[] }>(
+ `/api/v2/workspace/${workspaceID}/service-token-data`
+ );
+
+ return data.serviceTokenData;
+};
+
+type UseGetWorkspaceServiceTokensProps = { workspaceID: string };
+
+export const useGetUserWsServiceTokens = ({ workspaceID }: UseGetWorkspaceServiceTokensProps) =>
+ useQuery({
+ queryKey: serviceTokenKeys.getAllWorkspaceServiceToken(workspaceID),
+ queryFn: () => fetchWorkspaceServiceTokens(workspaceID),
+ enabled: Boolean(workspaceID)
+ });
+
+// mutation
+export const useCreateServiceToken = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (body) => {
+ const { data } = await apiRequest.post('/api/v2/service-token/', body);
+ return data;
+ },
+ onSuccess: ({ serviceTokenData: { workspace } }) => {
+ queryClient.invalidateQueries(serviceTokenKeys.getAllWorkspaceServiceToken(workspace));
+ }
+ });
+};
+
+export const useDeleteServiceToken = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (serviceTokenId) => {
+ const { data } = await apiRequest.delete(`/api/v2/service-token/${serviceTokenId}`);
+ return data;
+ },
+ onSuccess: ({ serviceTokenData: { workspace } }) => {
+ queryClient.invalidateQueries(serviceTokenKeys.getAllWorkspaceServiceToken(workspace));
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts
new file mode 100644
index 000000000..a1eb73743
--- /dev/null
+++ b/frontend/src/hooks/api/serviceTokens/types.ts
@@ -0,0 +1,28 @@
+export type ServiceToken = {
+ _id: string;
+ name: string;
+ workspace: string;
+ environment: string;
+ user: string;
+ expiresAt: string;
+ createdAt: string;
+ updatedAt: string;
+ __v: number;
+};
+
+export type CreateServiceTokenDTO = {
+ name: string;
+ workspaceId: string;
+ environment: string;
+ expiresIn: number;
+ encryptedKey: string;
+ iv: string;
+ tag: string;
+};
+
+export type CreateServiceTokenRes = {
+ serviceToken: string;
+ serviceTokenData: ServiceToken;
+};
+
+export type DeleteServiceTokenRes = { serviceTokenData: ServiceToken };
diff --git a/frontend/src/hooks/api/subscriptions/index.tsx b/frontend/src/hooks/api/subscriptions/index.tsx
new file mode 100644
index 000000000..4bb98e85a
--- /dev/null
+++ b/frontend/src/hooks/api/subscriptions/index.tsx
@@ -0,0 +1 @@
+export { useGetOrgSubscription } from './queries';
diff --git a/frontend/src/hooks/api/subscriptions/queries.tsx b/frontend/src/hooks/api/subscriptions/queries.tsx
new file mode 100644
index 000000000..4a65912ef
--- /dev/null
+++ b/frontend/src/hooks/api/subscriptions/queries.tsx
@@ -0,0 +1,30 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { apiRequest } from '@app/config/request';
+
+import { GetSubscriptionPlan } from './types';
+
+// import { Workspace } from './types';
+
+const subscriptionKeys = {
+ getOrgSubsription: (orgID: string) => ['subscription', { orgID }] as const
+};
+
+const fetchOrgSubscription = async (orgID: string) => {
+ const { data } = await apiRequest.get<{ subscriptions: GetSubscriptionPlan }>(
+ `/api/v1/organization/${orgID}/subscriptions`
+ );
+
+ return data.subscriptions;
+};
+
+type UseGetOrgSubscriptionProps = {
+ orgID: string;
+};
+
+export const useGetOrgSubscription = ({ orgID }: UseGetOrgSubscriptionProps) =>
+ useQuery({
+ queryKey: subscriptionKeys.getOrgSubsription(orgID),
+ queryFn: () => fetchOrgSubscription(orgID),
+ enabled: Boolean(orgID)
+ });
diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts
new file mode 100644
index 000000000..f9dfcbf0b
--- /dev/null
+++ b/frontend/src/hooks/api/subscriptions/types.ts
@@ -0,0 +1,25 @@
+export type GetSubscriptionPlan = {
+ data: { plan: SubscriptionPlan }[];
+};
+
+export type SubscriptionPlan = {
+ id: string;
+ object: string;
+ active: boolean;
+ aggregate_usage: unknown;
+ amount: 1400;
+ amount_decimal: 1400;
+ billing_scheme: string;
+ created: 1674833546;
+ currency: string;
+ interval: string;
+ interval_count: 1;
+ livemode: false;
+ metadata: {};
+ nickname: null;
+ product: string;
+ tiers_mode: unknown;
+ transform_usage: unknown;
+ trial_period_days: unknown;
+ usage_type: string;
+};
diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts
new file mode 100644
index 000000000..cc7498b72
--- /dev/null
+++ b/frontend/src/hooks/api/types.ts
@@ -0,0 +1,13 @@
+export type { GetAuthTokenAPI } from './auth/types';
+export type { UserWsKeyPair } from './keys/types';
+export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types';
+export type { GetSubscriptionPlan, SubscriptionPlan } from './subscriptions/types';
+export type {
+ CreateEnvironmentDTO,
+ DeleteEnvironmentDTO,
+ DeleteWorkspaceDTO,
+ RenameWorkspaceDTO,
+ UpdateEnvironmentDTO,
+ Workspace,
+ WorkspaceEnv
+} from './workspace/types';
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx
new file mode 100644
index 000000000..5de95ab05
--- /dev/null
+++ b/frontend/src/hooks/api/workspace/index.tsx
@@ -0,0 +1,8 @@
+export {
+ useCreateWsEnvironment,
+ useDeleteWorkspace,
+ useDeleteWsEnvironment,
+ useGetUserWorkspaces,
+ useRenameWorkspace,
+ useUpdateWsEnvironment
+} from './queries';
diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
new file mode 100644
index 000000000..b0566a43d
--- /dev/null
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -0,0 +1,94 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { apiRequest } from '@app/config/request';
+
+import {
+ CreateEnvironmentDTO,
+ DeleteEnvironmentDTO,
+ DeleteWorkspaceDTO,
+ RenameWorkspaceDTO,
+ UpdateEnvironmentDTO,
+ Workspace
+} from './types';
+
+const workspaceKeys = {
+ getAllUserWorkspace: ['workspaces'] as const
+};
+
+const fetchUserWorkspaces = async () => {
+ const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace');
+
+ return data.workspaces;
+};
+
+export const useGetUserWorkspaces = () =>
+ useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces);
+
+// mutation
+export const useRenameWorkspace = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, RenameWorkspaceDTO>({
+ mutationFn: ({ workspaceID, newWorkspaceName }) =>
+ apiRequest.post(`/api/v1/workspace/${workspaceID}/name`, { name: newWorkspaceName }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
+ }
+ });
+};
+
+export const useDeleteWorkspace = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, DeleteWorkspaceDTO>({
+ mutationFn: ({ workspaceID }) => apiRequest.delete(`/api/v1/workspace/${workspaceID}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
+ }
+ });
+};
+
+export const useCreateWsEnvironment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, CreateEnvironmentDTO>({
+ mutationFn: ({ workspaceID, environmentName, environmentSlug }) =>
+ apiRequest.post(`/api/v2/workspace/${workspaceID}/environments`, {
+ environmentName,
+ environmentSlug
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
+ }
+ });
+};
+
+export const useUpdateWsEnvironment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, UpdateEnvironmentDTO>({
+ mutationFn: ({ workspaceID, environmentName, environmentSlug, oldEnvironmentSlug }) =>
+ apiRequest.put(`/api/v2/workspace/${workspaceID}/environments`, {
+ environmentName,
+ environmentSlug,
+ oldEnvironmentSlug
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
+ }
+ });
+};
+
+export const useDeleteWsEnvironment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation<{}, {}, DeleteEnvironmentDTO>({
+ mutationFn: ({ workspaceID, environmentSlug }) =>
+ apiRequest.delete(`/api/v2/workspace/${workspaceID}/environments`, {
+ data: { environmentSlug }
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
+ }
+ });
+};
diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts
new file mode 100644
index 000000000..cc4709477
--- /dev/null
+++ b/frontend/src/hooks/api/workspace/types.ts
@@ -0,0 +1,29 @@
+export type Workspace = {
+ __v: number;
+ _id: string;
+ name: string;
+ organization: string;
+ environments: WorkspaceEnv[];
+};
+
+export type WorkspaceEnv = { name: string; slug: string };
+
+// mutation dto
+export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string };
+
+export type DeleteWorkspaceDTO = { workspaceID: string };
+
+export type CreateEnvironmentDTO = {
+ workspaceID: string;
+ environmentSlug: string;
+ environmentName: string;
+};
+
+export type UpdateEnvironmentDTO = {
+ workspaceID: string;
+ oldEnvironmentSlug: string;
+ environmentSlug: string;
+ environmentName: string;
+};
+
+export type DeleteEnvironmentDTO = { workspaceID: string; environmentSlug: string };
diff --git a/frontend/src/hooks/usePopUp.tsx b/frontend/src/hooks/usePopUp.tsx
index b0c4163be..52839142d 100644
--- a/frontend/src/hooks/usePopUp.tsx
+++ b/frontend/src/hooks/usePopUp.tsx
@@ -21,7 +21,7 @@ interface UsePopUpReturn | UsePopUpProps[]> {
popUp: UsePopUpState;
handlePopUpOpen: (popUpName: keyof UsePopUpState, data?: unknown) => void;
handlePopUpClose: (popUpName: keyof UsePopUpState) => void;
- handlePopUpToggle: (popUpName: keyof UsePopUpState) => void;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState, state?: boolean) => void;
}
/**
@@ -50,10 +50,10 @@ export const usePopUp = | UsePopUpProps[]>(
setPopUp((oldState) => ({ ...oldState, [popUpName]: { isOpen: false } }));
}, []);
- const handlePopUpToggle = useCallback((popUpName: keyof UsePopUpState) => {
+ const handlePopUpToggle = useCallback((popUpName: keyof UsePopUpState, state?: boolean) => {
setPopUp((oldState) => ({
...oldState,
- [popUpName]: { isOpen: !oldState[popUpName].isOpen }
+ [popUpName]: { isOpen: typeof state === 'undefined' ? !oldState[popUpName].isOpen : state }
}));
}, []);
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/api/organization/GetOrgUsers.ts b/frontend/src/pages/api/organization/GetOrgUsers.ts
index a617e26f4..611ea1fec 100644
--- a/frontend/src/pages/api/organization/GetOrgUsers.ts
+++ b/frontend/src/pages/api/organization/GetOrgUsers.ts
@@ -13,6 +13,7 @@ export interface IMembershipOrg {
organization: string;
role: 'owner' | 'admin' | 'member';
status: 'invited' | 'accepted';
+ deniedPermissions: any[];
}
/**
* This route lets us get all the users in an org.
diff --git a/frontend/src/pages/api/organization/changeUserRoleInOrganization.ts b/frontend/src/pages/api/organization/changeUserRoleInOrganization.ts
new file mode 100644
index 000000000..6bbe966c4
--- /dev/null
+++ b/frontend/src/pages/api/organization/changeUserRoleInOrganization.ts
@@ -0,0 +1,27 @@
+import SecurityClient from '@app/components/utilities/SecurityClient';
+
+/**
+ * This function change the access of a user in a certain organization
+ * @param {string} organizationId
+ * @param {string} membershipId
+ * @param {string} role
+ * @returns
+ */
+const changeUserRoleInOrganization = (organizationId: string, membershipId: string, role: string) =>
+ SecurityClient.fetchCall(`/api/v2/organizations/${organizationId}/memberships/${membershipId}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ role
+ })
+ }).then(async (res) => {
+ if (res && res.status === 200) {
+ return res;
+ }
+ console.log('Failed to change the user role in an org');
+ return undefined;
+ });
+
+export default changeUserRoleInOrganization;
diff --git a/frontend/src/pages/api/workspace/getWorkspaceEnvironments.ts b/frontend/src/pages/api/workspace/getWorkspaceEnvironments.ts
new file mode 100644
index 000000000..845936cb9
--- /dev/null
+++ b/frontend/src/pages/api/workspace/getWorkspaceEnvironments.ts
@@ -0,0 +1,22 @@
+import SecurityClient from '@app/components/utilities/SecurityClient';
+
+/**
+ * This route lets us get the environments that a certain user has acess to in a certain project
+ * @param {string} workspaceId
+ * @returns
+ */
+const getWorkspaceEnvironments = ({ workspaceId }: { workspaceId: string }) =>
+ SecurityClient.fetchCall(`/api/v2/workspace/${workspaceId}/environments`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(async (res) => {
+ if (res?.status === 200) {
+ return (await res.json()).accessibleEnvironments;
+ }
+ console.log('Failed to get accessible environments');
+ return undefined;
+ });
+
+export default getWorkspaceEnvironments;
diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx
index 25fd7fecd..ea6313746 100644
--- a/frontend/src/pages/dashboard/[id].tsx
+++ b/frontend/src/pages/dashboard/[id].tsx
@@ -41,11 +41,13 @@ import updateSecrets from '../api/files/UpdateSecrets';
import getUser from '../api/user/getUser';
import checkUserAction from '../api/userActions/checkUserAction';
import registerUserAction from '../api/userActions/registerUserAction';
+import getWorkspaceEnvironments from '../api/workspace/getWorkspaceEnvironments';
import getWorkspaces from '../api/workspace/getWorkspaces';
type WorkspaceEnv = {
name: string;
slug: string;
+ isWriteDenied: boolean;
};
interface SecretDataProps {
@@ -134,7 +136,8 @@ export default function Dashboard() {
const [selectedSnapshotEnv, setSelectedSnapshotEnv] = useState
();
const [selectedEnv, setSelectedEnv] = useState({
name: '',
- slug: ''
+ slug: '',
+ isWriteDenied: false
});
const [atSecretAreaTop, setAtSecretsAreaTop] = useState(true);
const secretsTop = useRef(null);
@@ -216,9 +219,10 @@ export default function Dashboard() {
}
setAutoCapitalization(workspace?.autoCapitalization ?? true);
- setWorkspaceEnvs(workspace?.environments || []);
+ const accessibleEnvironments = await getWorkspaceEnvironments({ workspaceId });
+ setWorkspaceEnvs(accessibleEnvironments || []);
// set env
- const env = workspace?.environments?.[0] || {
+ const env = accessibleEnvironments?.[0] || {
name: 'unknown',
slug: 'unkown'
};
@@ -353,6 +357,7 @@ export default function Dashboard() {
findDuplicates(data!.map((item: SecretDataProps) => item.key)).length > 0;
if (nameErrors) {
+ setSaveLoading(false);
return createNotification({
text: 'Solve all name errors before saving secrets.',
type: 'error'
@@ -360,12 +365,21 @@ export default function Dashboard() {
}
if (duplicatesExist) {
+ setSaveLoading(false);
return createNotification({
text: 'Remove duplicated secret names before saving.',
type: 'error'
});
}
+ if (selectedEnv.isWriteDenied) {
+ setSaveLoading(false);
+ return createNotification({
+ text: 'You are not allowed to edit this environment',
+ type: 'error'
+ });
+ }
+
// Once "Save changes" is clicked, disable that button
setButtonReady(false);
@@ -506,7 +520,7 @@ export default function Dashboard() {
};
return data ? (
-
+
{t('common:head-title', { title: t('dashboard:title') })}
@@ -514,7 +528,7 @@ export default function Dashboard() {
-
+
{sidebarSecretId !== 'None' && (
envName === name) || {
name: 'unknown',
- slug: 'unknown'
+ slug: 'unknown',
+ isWriteDenied: false
}
)
}
@@ -662,7 +680,8 @@ export default function Dashboard() {
setSelectedEnv(
workspaceEnvs.find(({ name }) => envName === name) || {
name: 'unknown',
- slug: 'unknown'
+ slug: 'unknown',
+ isWriteDenied: false
}
)
}
@@ -675,7 +694,8 @@ export default function Dashboard() {
setSelectedSnapshotEnv(
workspaceEnvs.find(({ name }) => envName === name) || {
name: 'unknown',
- slug: 'unknown'
+ slug: 'unknown',
+ isWriteDenied: false
}
)
}
@@ -857,7 +877,6 @@ export default function Dashboard() {
) : (
);
diff --git a/frontend/src/pages/home/[id].tsx b/frontend/src/pages/home/[id].tsx
index 9ec119973..fef9397c4 100644
--- a/frontend/src/pages/home/[id].tsx
+++ b/frontend/src/pages/home/[id].tsx
@@ -167,7 +167,7 @@ export default function Home() {
})}
{learningItem({
text: 'Inject secrets locally',
- subText: 'Replace .env files with a more secure an efficient alternative.',
+ subText: 'Replace .env files with a more secure and efficient alternative.',
complete: false,
icon: faNetworkWired,
time: '8 min',
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index c32c4bd4f..213b5a263 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -22,6 +22,7 @@ export default function Login() {
const [password, setPassword] = useState('');
const [errorLogin, setErrorLogin] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const lang = router.locale ?? 'en';
@@ -44,6 +45,7 @@ export default function Login() {
}
};
if (isLoggedIn()) {
+ setIsAlreadyLoggedIn(true);
redirectToDashboard();
}
}, []);
@@ -64,6 +66,10 @@ export default function Login() {
});
};
+ if (isAlreadyLoggedIn) {
+ return null
+ }
+
return (
diff --git a/frontend/src/pages/settings/project/[id].tsx b/frontend/src/pages/settings/project/[id].tsx
index 9e9aea4f6..0d08eca45 100644
--- a/frontend/src/pages/settings/project/[id].tsx
+++ b/frontend/src/pages/settings/project/[id].tsx
@@ -1,370 +1,29 @@
-import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import Head from 'next/head';
-import { useRouter } from 'next/router';
-import { useTranslation } from 'next-i18next';
-import { faCheck, faCopy, faPlus } from '@fortawesome/free-solid-svg-icons';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import Button from '@app/components/basic/buttons/Button';
-import AddServiceTokenDialog from '@app/components/basic/dialog/AddServiceTokenDialog';
-import InputField from '@app/components/basic/InputField';
-import EnvironmentTable from '@app/components/basic/table/EnvironmentsTable';
-import ServiceTokenTable from '@app/components/basic/table/ServiceTokenTable';
-import NavHeader from '@app/components/navigation/NavHeader';
-import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps';
-import { Checkbox } from '@app/components/v2';
-import deleteEnvironment from '@app/pages/api/environments/deleteEnvironment';
-import updateEnvironment from '@app/pages/api/environments/updateEnvironment';
-
-import createEnvironment from '../../api/environments/createEnvironment';
-import getServiceTokens from '../../api/serviceToken/getServiceTokens';
-import deleteWorkspace from '../../api/workspace/deleteWorkspace';
-import getWorkspaces from '../../api/workspace/getWorkspaces';
-import renameWorkspace from '../../api/workspace/renameWorkspace';
-import toggleAutoCapitalization from '../../api/workspace/toggleAutoCapitalization';
-
-type EnvData = {
- name: string;
- slug: string;
-};
-
-export default function SettingsBasic() {
- const [buttonReady, setButtonReady] = useState(false);
- const [autoCapitalizationButtonReady, setAutoCapitalizationButtonReady] = useState(false);
- const router = useRouter();
- const [workspaceName, setWorkspaceName] = useState('');
- const [autoCapitalization, setAutoCapitalization] = useState(false);
- const [serviceTokens, setServiceTokens] = useState
([]);
- 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);
- setAutoCapitalization(userWorkspace.autoCapitalization ?? true);
- setEnvironments(userWorkspace.environments);
- }
- });
- const tempServiceTokens = await getServiceTokens({
- workspaceId
- });
- setServiceTokens(tempServiceTokens);
- };
-
- load();
- }, []);
-
- const modifyWorkspaceName = (newName: string) => {
- setButtonReady(true);
- setWorkspaceName(newName);
- };
-
- const toggleAutoCapitalizationCheckBox = (state: boolean) => {
- setAutoCapitalizationButtonReady(true);
- setAutoCapitalization(state);
- };
-
- const submitChanges = (newWorkspaceName: string) => {
- renameWorkspace(workspaceId, newWorkspaceName);
- setButtonReady(false);
- };
-
- const submitAutoCapitalizationChanges = () => {
- toggleAutoCapitalization(workspaceId, autoCapitalization);
- setAutoCapitalizationButtonReady(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('settings-project:auto-capitalization')}
-
toggleAutoCapitalizationCheckBox(state as boolean)}
- > {t('settings-project:auto-capitalization-description')}
-
-
- submitAutoCapitalizationChanges()}
- color="mineshaft"
- size="md"
- active={autoCapitalizationButtonReady}
- 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/pages/users/[id].tsx b/frontend/src/pages/users/[id].tsx
index db71e34fc..27b531444 100644
--- a/frontend/src/pages/users/[id].tsx
+++ b/frontend/src/pages/users/[id].tsx
@@ -8,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Button from '@app/components/basic/buttons/Button';
import AddProjectMemberDialog from '@app/components/basic/dialog/AddProjectMemberDialog';
-import UserTable from '@app/components/basic/table/UserTable';
+import ProjectUsersTable from '@app/components/basic/table/ProjectUsersTable';
import NavHeader from '@app/components/navigation/NavHeader';
import guidGenerator from '@app/components/utilities/randomId';
import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps';
@@ -33,6 +33,7 @@ interface UserProps {
}
interface MembershipProps {
+ deniedPermissions: any[];
user: UserProps;
inviteEmail: string;
role: string;
@@ -76,6 +77,7 @@ export default function Users() {
status: membership?.status,
userId: membership.user?._id,
membershipId: membership._id,
+ deniedPermissions: membership.deniedPermissions,
publicKey: membership.user?.publicKey
}));
setUserList(tempUserList);
@@ -144,7 +146,7 @@ export default function Users() {
};
return userList ? (
-
+
{t('common:head-title', { title: t('settings-members:title') })}
@@ -168,7 +170,7 @@ export default function Users() {
setEmail={setEmail}
/>
{/*
*/}
-
+
-
-
+
) : (
);
diff --git a/frontend/src/pages/verify-email.tsx b/frontend/src/pages/verify-email.tsx
index d134a07af..3b7e1c8d5 100644
--- a/frontend/src/pages/verify-email.tsx
+++ b/frontend/src/pages/verify-email.tsx
@@ -29,7 +29,7 @@ export default function VerifyEmail() {
Login
-
+
{
+ const { t } = useTranslation();
+ const { currentWorkspace, workspaces } = useWorkspace();
+ const router = useRouter();
+ 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 });
+ // redirect user to first workspace user is part of
+ const ws = workspaces.find(({ _id }) => _id !== workspaceID);
+ 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 }: 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 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 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..521394f9f
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx
@@ -0,0 +1,240 @@
+import { Controller, useForm } from 'react-hook-form';
+import { faPencil, 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,
+ 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"
+ leftIcon={ }
+ >
+ 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 environments 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..02b488b55
--- /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..052fdc678
--- /dev/null
+++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx
@@ -0,0 +1,306 @@
+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 service tokens 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';
diff --git a/i18n/README.en.md b/i18n/README.en.md
index fb907e302..1ec8cff50 100644
--- a/i18n/README.en.md
+++ b/i18n/README.en.md
@@ -3,7 +3,7 @@
-
Open-source, E2EE, simple tool to manage and sync environment variables across your team and infrastructure.
+ Open-source, E2EE, simple tool to manage secrets and configs across your team and infrastructure.
@@ -21,9 +21,12 @@
-
+
+
+
+
@@ -34,20 +37,29 @@
-**[Infisical](https://infisical.com)** is an open source, E2EE tool to help teams manage and sync environment variables across their development workflow and infrastructure. It's designed to be simple and take minutes to get going.
-- **[User-Friendly Dashboard](https://infisical.com/docs/getting-started/dashboard/project)** to manage your team's environment variables within projects
-- **[Language-Agnostic CLI](https://infisical.com/docs/cli/overview)** that pulls and injects environment variables into your local workflow
+**Read this in other languages**: [ ](i18n/README.en.md)
+[ ](i18n/README.es.md)
+[ ](i18n/README.ko.md)
+[ ](i18n/README.tr.md)
+
+**[Infisical](https://infisical.com)** is an open source, E2EE tool to help teams manage and sync secrets and configs across their development workflow and infrastructure. It's designed to be simple and take minutes to get going.
+
+- **[User-Friendly Dashboard](https://infisical.com/docs/getting-started/dashboard/project)** to manage your team's secrets and configs within projects
+- **[Language-Agnostic CLI](https://infisical.com/docs/cli/overview)** that pulls and injects esecrets and configs into your local workflow
- **[Complete control over your data](https://infisical.com/docs/self-hosting/overview)** - host it yourself on any infrastructure
- **Navigate Multiple Environments** per project (e.g. development, staging, production, etc.)
-- **Personal/Shared** scoping for environment variables
+- **Personal overrides** for secrets and configs
- **[Integrations](https://infisical.com/docs/integrations/overview)** with CI/CD and production infrastructure
+- **[Infisical API](https://infisical.com/docs/api-reference/overview/introduction)** - manage secrets via HTTPS requests to the platform
+- **[Secret Versioning](https://infisical.com/docs/getting-started/dashboard/versioning)** to view the change history for any secret
+- **[Activity Logs](https://infisical.com/docs/getting-started/dashboard/audit-logs)** to record every action taken in a project
+- **[Point-in-time Secrets Recovery](https://infisical.com/docs/getting-started/dashboard/pit-recovery)** for rolling back to any snapshot of your secrets
+- **Role-based Access Controls** per environment
+- π **2FA** (next week)
- π **1-Click Deploy** to Digital Ocean and Heroku
-- π **Authentication/Authorization** for projects (read/write controls soon)
- π **Automatic Secret Rotation**
-- π **2FA**
-- π **Access Logs**
-- π **Slack Integration & MS Teams** integrations
+- π **Slack & MS Teams** integrations
And more.
@@ -65,7 +77,7 @@ To quickly get started, visit our [get started guide](https://infisical.com/docs
Infisical makes secret management simple and end-to-end encrypted by default. We're on a mission to make it more accessible to all developers, not just security teams .
-According to a [report](https://www.ekransystem.com/en/blog/secrets-management) in 2019, only 10% of organizations use secret management solutions despite all using digital secrets to some extent.
+According to a [report](https://www.ekransystem.com/en/blog/secrets-management), only 10% of organizations use secret management solutions despite all using digital secrets to some extent.
If you care about efficiency and security, then Infisical is right for you.
@@ -87,15 +99,13 @@ Not sure where to get started? You can:
- [GitHub Issues](https://github.com/Infisical/infisical-cli/issues) (For any bugs and errors you encounter using Infisical)
- [Twitter](https://twitter.com/infisical) (Get news fast)
-## π₯ Status
+## π Status
-- [x] Public Alpha: Anyone can sign up over at [infisical.com](https://infisical.com) but go easy on us, there are kinks and we're just getting started.
+- [x] Public Alpha: Anyone can sign up over at [infisical.com](https://infisical.com) but there are some minor kinks.
- [ ] Public Beta: Stable enough for most non-enterprise use-cases.
- [ ] Public: Production-ready.
-We're currently in Public Alpha.
-
-## π Integrations
+## οΏ½π Integrations
We're currently setting the foundation and building [integrations](https://infisical.com/docs/integrations/overview) so secrets can be synced everywhere. Any help is welcome! :)
@@ -138,12 +148,14 @@ We're currently setting the foundation and building [integrations](https://infis
- π Fly.io
+
+ βοΈ Fly.io
+
- π AWS
+ π AWS PS (https://github.com/Infisical/infisical/issues/286)
@@ -151,12 +163,12 @@ We're currently setting the foundation and building [integrations](https://infis
- π Railway
+ π Railway (https://github.com/Infisical/infisical/issues/271)
- π GCP
+ π GCP SM (https://github.com/Infisical/infisical/issues/285)
π GitLab CI/CD (https://github.com/Infisical/infisical/issues/134)
@@ -186,7 +198,7 @@ We're currently setting the foundation and building [integrations](https://infis
- π Railway
+ π Forge
@@ -197,7 +209,20 @@ We're currently setting the foundation and building [integrations](https://infis
π Supabase
- π Render (https://github.com/Infisical/infisical/issues/132)
+
+ βοΈ Render
+
+
+
+
+
+ π BuddyCI
+
+
+ π Serverless
+
+
+ π AWS Lambda
@@ -270,13 +295,13 @@ We're currently setting the foundation and building [integrations](https://infis
-
- βοΈ Ruby on Rails
+
+ βοΈ Vue
-
- βοΈ Vue
+
+ βοΈ Ruby on Rails
@@ -292,6 +317,16 @@ We're currently setting the foundation and building [integrations](https://infis
+
+
+
+ βοΈ .NET
+
+
+
+ And more...
+
+
@@ -309,7 +344,7 @@ Looking to report a security vulnerability? Please don't post about it in GitHub
## π¨ Stay Up-to-Date
-Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of new features are coming very quickly. Watch **releases** of this repository to be notified about future updates:
+Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot of new features coming very frequently. Watch **releases** of this repository to be notified about future updates:

@@ -321,10 +356,10 @@ Infisical officially launched as v.1.0 on November 21st, 2022. However, a lot of
-
+
## π Translations
-Infisical is currently available in English and Korean. Help us translate Infisical to your language!
+Infisical is currently available in English, Korean, French, and Portuguese (Brazil). Help us translate Infisical to your language!
-You can find all the info in [this issue](https://github.com/Infisical/infisical/issues/181).
\ No newline at end of file
+You can find all the info in [this issue](https://github.com/Infisical/infisical/issues/181).
diff --git a/i18n/README.es.md b/i18n/README.es.md
index 0139bdd65..d579bfa1c 100644
--- a/i18n/README.es.md
+++ b/i18n/README.es.md
@@ -144,12 +144,14 @@ Actualmente estamos sentando bases y construyendo [integraciones](https://infisi
- π Fly.io
+
+ βοΈ Fly.io
+
- π AWS
+ π AWS PS (https://github.com/Infisical/infisical/issues/286)
@@ -157,12 +159,12 @@ Actualmente estamos sentando bases y construyendo [integraciones](https://infisi
- π Railway
+ π Railway (https://github.com/Infisical/infisical/issues/271)
- π GCP
+ π GCP SM (https://github.com/Infisical/infisical/issues/285)
π GitLab CI/CD (https://github.com/Infisical/infisical/issues/134)
@@ -192,7 +194,7 @@ Actualmente estamos sentando bases y construyendo [integraciones](https://infisi
- π Railway
+ π Forge
@@ -203,7 +205,20 @@ Actualmente estamos sentando bases y construyendo [integraciones](https://infisi
π Supabase
- π Render (https://github.com/Infisical/infisical/issues/132)
+
+ βοΈ Render
+
+
+
+
+
+ π BuddyCI
+
+
+ π Serverless
+
+
+ π AWS Lambda
@@ -337,7 +352,7 @@ Infisical se lanzΓ³ oficialmente como v.1.0 el 21 de noviembre de 2022. Hay much
-
+
## π Traducciones
diff --git a/i18n/README.ko.md b/i18n/README.ko.md
index c56862b71..bab9e4d49 100644
--- a/i18n/README.ko.md
+++ b/i18n/README.ko.md
@@ -145,12 +145,14 @@ Infisicalμ νμ¬ κ³΅κ° μν μνμμ.
- π Fly.io
+
+ βοΈ Fly.io
+
- π AWS
+ π AWS PS (https://github.com/Infisical/infisical/issues/286)
@@ -158,12 +160,12 @@ Infisicalμ νμ¬ κ³΅κ° μν μνμμ.
- π Railway
+ π Railway (https://github.com/Infisical/infisical/issues/271)
- π GCP
+ π GCP SM (https://github.com/Infisical/infisical/issues/285)
π GitLab CI/CD (https://github.com/Infisical/infisical/issues/134)
@@ -193,7 +195,7 @@ Infisicalμ νμ¬ κ³΅κ° μν μνμμ.
- π Railway
+ π Forge
@@ -204,7 +206,20 @@ Infisicalμ νμ¬ κ³΅κ° μν μνμμ.
π Supabase
- π Render (https://github.com/Infisical/infisical/issues/132)
+
+ βοΈ Render
+
+
+
+
+
+ π BuddyCI
+
+
+ π Serverless
+
+
+ π AWS Lambda
@@ -306,7 +321,7 @@ Infisicalμ νμ¬ κ³΅κ° μν μνμμ.
- λ μ°Ύμμ΅λλ€!
+ And more...
@@ -338,7 +353,7 @@ Infisicalμ 2022λ
11μ 21μΌμ v1.0 λ²μ μ μΆμνμ΄μ. μλ‘μ΄
-
+
## π λ²μ
diff --git a/i18n/README.tr.md b/i18n/README.tr.md
index 24daa8fc8..bef6aa6b8 100644
--- a/i18n/README.tr.md
+++ b/i18n/README.tr.md
@@ -143,12 +143,14 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
- π Fly.io
+
+ βοΈ Fly.io
+
- π AWS
+ π AWS PS (https://github.com/Infisical/infisical/issues/286)
@@ -156,12 +158,12 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
- π Railway
+ π Railway (https://github.com/Infisical/infisical/issues/271)
- π GCP
+ π GCP SM (https://github.com/Infisical/infisical/issues/285)
π GitLab CI/CD (https://github.com/Infisical/infisical/issues/134)
@@ -191,7 +193,7 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
- π Railway
+ π Forge
@@ -202,7 +204,20 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
π Supabase
- π Render (https://github.com/Infisical/infisical/issues/132)
+
+ βοΈ Render
+
+
+
+
+
+ π BuddyCI
+
+
+ π Serverless
+
+
+ π AWS Lambda
@@ -275,13 +290,13 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
-
- βοΈ Ruby on Rails
+
+ βοΈ Vue
-
- βοΈ Vue
+
+ βοΈ Ruby on Rails
@@ -297,6 +312,16 @@ Nereden baΕlayacaΔΔ±nΔ±zdan emin deΔil misiniz? O zaman;
+
+
+
+ βοΈ .NET
+
+
+
+ And more...
+
+
@@ -326,7 +351,7 @@ Infisical 21 KasΔ±m 2022 de v.1.0 resmi olarak olarak yayΔ±nlandΔ±. Buna raΔmen
-
+
## π Γeviriler