feat(rbac): base ui for org rbac management

This commit is contained in:
Akhil Mohan
2023-08-14 20:46:46 +05:30
parent 1167b1bc60
commit c5f76b1e6f
17 changed files with 1182 additions and 1000 deletions

View File

@@ -1,3 +1,5 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import crypto from "crypto";
import { useEffect, useState } from "react";
@@ -11,8 +13,11 @@ import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faFolderOpen } from "@fortawesome/free-regular-svg-icons";
import {
faArrowRight,
faArrowUpRightFromSquare,
faCheck,
faCheckCircle,
faExclamationCircle,
faClipboard,
faHandPeace,
faMagnifyingGlass,
faNetworkWired,
@@ -22,6 +27,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as Tabs from "@radix-ui/react-tabs";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -36,7 +42,6 @@ import {
Skeleton,
UpgradePlanModal
} from "@app/components/v2";
import { TabsObject } from "@app/components/v2/Tabs";
import { useSubscription, useUser, useWorkspace } from "@app/context";
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useRegisterUserAction,useUploadWsKey } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
@@ -63,6 +68,210 @@ type ItemProps = {
link?: string;
};
function copyToClipboard(id: string, setState: (value: boolean) => void) {
// Get the text field
const copyText = document.getElementById(id) as HTMLInputElement;
// Select the text field
copyText.select();
copyText.setSelectionRange(0, 99999); // For mobile devices
// Copy the text inside the text field
navigator.clipboard.writeText(copyText.value);
setState(true);
setTimeout(() => setState(false), 2000);
// Alert the copied text
// alert("Copied the text: " + copyText.value);
}
const CodeItem = ({
isCopied,
setIsCopied,
textExplanation,
code,
id
}: {
isCopied: boolean;
setIsCopied: (value: boolean) => void;
textExplanation: string;
code: string;
id: string;
}) => {
return (
<>
<p className="mb-2 mt-4 text-bunker-300 text-sm leading-normal">{textExplanation}</p>
<div className="font-mono text-sm px-3 py-2 bg-bunker rounded-md border border-mineshaft-600 flex flex-row items-center justify-between">
<input disabled value={code} id={id} className="w-full bg-transparent text-bunker-200" />
<button
type="button"
onClick={() => copyToClipboard(id, setIsCopied)}
className="h-full pl-3.5 pr-2 text-bunker-300 hover:text-primary-200 duration-200"
>
{isCopied ? (
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
) : (
<FontAwesomeIcon icon={faClipboard} />
)}
</button>
</div>
</>
);
};
const TabsObject = () => {
const [downloadCodeCopied, setDownloadCodeCopied] = useState(false);
const [downloadCode2Copied, setDownloadCode2Copied] = useState(false);
const [loginCodeCopied, setLoginCodeCopied] = useState(false);
const [initCodeCopied, setInitCodeCopied] = useState(false);
const [runCodeCopied, setRunCodeCopied] = useState(false);
return (
<Tabs.Root
className="flex flex-col w-full cursor-default border border-mineshaft-600 rounded-md"
defaultValue="tab1"
>
<Tabs.List
className="shrink-0 flex border-b border-mineshaft-600"
aria-label="Manage your account"
>
<Tabs.Trigger
className="bg-bunker-700 px-5 h-10 flex-1 flex items-center justify-center text-sm leading-none text-bunker-300 select-none first:rounded-tl-md last:rounded-tr-md data-[state=active]:text-primary data-[state=active]:font-medium data-[state=active]:focus:relative data-[state=active]:border-b data-[state=active]:border-primary outline-none cursor-default"
value="tab1"
>
MacOS
</Tabs.Trigger>
<Tabs.Trigger
className="bg-bunker-700 px-5 h-10 flex-1 flex items-center justify-center text-sm leading-none text-bunker-300 select-none first:rounded-tl-md last:rounded-tr-md data-[state=active]:text-primary data-[state=active]:font-medium data-[state=active]:focus:relative data-[state=active]:border-b data-[state=active]:border-primary outline-none cursor-default"
value="tab2"
>
Windows
</Tabs.Trigger>
{/* <Tabs.Trigger
className="bg-bunker-700 px-5 h-10 flex-1 flex items-center justify-center text-sm leading-none text-bunker-300 select-none first:rounded-tl-md last:rounded-tr-md data-[state=active]:text-primary data-[state=active]:font-medium data-[state=active]:focus:relative data-[state=active]:border-b data-[state=active]:border-primary outline-none cursor-default"
value="tab3"
>
Arch Linux
</Tabs.Trigger> */}
<a
target="_blank"
rel="noopener noreferrer"
className="bg-bunker-700 hover:text-bunker-100 duration-200 px-5 h-10 flex-1 flex items-center justify-center text-sm leading-none text-bunker-300 select-none first:rounded-tl-md last:rounded-tr-md data-[state=active]:text-primary data-[state=active]:font-medium data-[state=active]:focus:relative data-[state=active]:border-b data-[state=active]:border-primary outline-none cursor-default"
href="https://infisical.com/docs/cli/overview"
>
Other Platforms <FontAwesomeIcon icon={faArrowUpRightFromSquare} className="ml-2" />
</a>
</Tabs.List>
<Tabs.Content
className="grow p-5 pt-0 bg-bunker-700 rounded-b-md outline-none cursor-default"
value="tab1"
>
<CodeItem
isCopied={downloadCodeCopied}
setIsCopied={setDownloadCodeCopied}
textExplanation="1. Download CLI"
code="brew install infisical/get-cli/infisical"
id="downloadCode"
/>
<CodeItem
isCopied={loginCodeCopied}
setIsCopied={setLoginCodeCopied}
textExplanation="2. Login"
code="infisical login"
id="loginCode"
/>
<CodeItem
isCopied={initCodeCopied}
setIsCopied={setInitCodeCopied}
textExplanation="3. Choose Project"
code="infisical init"
id="initCode"
/>
<CodeItem
isCopied={runCodeCopied}
setIsCopied={setRunCodeCopied}
textExplanation="4. Done! Now, you can prepend your usual start script with:"
code="infisical run -- [YOUR USUAL CODE START SCRIPT GOES HERE]"
id="runCode"
/>
<p className="text-bunker-300 text-sm mt-2">
You can find example of start commands for different frameworks{" "}
<a
className="text-primary underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/integrations/overview"
>
here
</a>
.{" "}
</p>
</Tabs.Content>
<Tabs.Content className="grow p-5 pt-0 bg-bunker-700 rounded-b-md outline-none" value="tab2">
<CodeItem
isCopied={downloadCodeCopied}
setIsCopied={setDownloadCodeCopied}
textExplanation="1. Download CLI"
code="scoop bucket add org https://github.com/Infisical/scoop-infisical.git"
id="downloadCodeW"
/>
<div className="font-mono text-sm px-3 py-2 mt-2 bg-bunker rounded-md border border-mineshaft-600 flex flex-row items-center justify-between">
<input
disabled
value="scoop install infisical"
id="downloadCodeW2"
className="w-full bg-transparent text-bunker-200"
/>
<button
type="button"
onClick={() => copyToClipboard("downloadCodeW2", setDownloadCode2Copied)}
className="h-full pl-3.5 pr-2 text-bunker-300 hover:text-primary-200 duration-200"
>
{downloadCode2Copied ? (
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
) : (
<FontAwesomeIcon icon={faClipboard} />
)}
</button>
</div>
<CodeItem
isCopied={loginCodeCopied}
setIsCopied={setLoginCodeCopied}
textExplanation="2. Login"
code="infisical login"
id="loginCodeW"
/>
<CodeItem
isCopied={initCodeCopied}
setIsCopied={setInitCodeCopied}
textExplanation="3. Choose Project"
code="infisical init"
id="initCodeW"
/>
<CodeItem
isCopied={runCodeCopied}
setIsCopied={setRunCodeCopied}
textExplanation="4. Done! Now, you can prepend your usual start script with:"
code="infisical run -- [YOUR USUAL CODE START SCRIPT GOES HERE]"
id="runCodeW"
/>
<p className="text-bunker-300 text-sm mt-2">
You can find example of start commands for different frameworks{" "}
<a
className="text-primary underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/integrations/overview"
>
here
</a>
.{" "}
</p>
</Tabs.Content>
</Tabs.Root>
);
};
const LearningItem = ({
text,
subText,

View File

@@ -1,216 +1,28 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { motion } from "framer-motion";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import {
useAddIncidentContact,
useAddUserToOrg,
useDeleteIncidentContact,
useDeleteOrgMembership,
useGetOrgIncidentContact,
useGetOrgUsers,
useGetUserWorkspaceMemberships,
useGetUserWsKey,
useRenameOrg,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useGetRoles } from "@app/hooks/api";
import {
OrgIncidentContactsTable,
OrgMembersTable,
OrgNameChangeSection,
OrgServiceAccountsTable
} from "./components";
import { OrgMembersTable } from "./components/OrgMembersTable";
import { OrgRoleTabSection } from "./components/OrgRoleTabSection";
enum TabSections {
Member = "members",
Roles = "roles"
}
export const MembersPage = () => {
const host = window.location.origin;
const router = useRouter();
const { action } = router.query;
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext();
const orgId = currentOrg?._id || "";
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
useGetUserWorkspaceMemberships(orgId);
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
useGetOrgIncidentContact(orgId);
const renameOrg = useRenameOrg();
const removeUserOrgMembership = useDeleteOrgMembership();
const addUserToOrg = useAddUserToOrg();
const updateOrgUserRole = useUpdateOrgUserRole();
const uploadWsKey = useUploadWsKey();
const addIncidentContact = useAddIncidentContact();
const removeIncidentContact = useDeleteIncidentContact();
const [completeInviteLink, setcompleteInviteLink] = useState<string | undefined>("");
const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
const onRenameOrg = async (name: string) => {
if (!currentOrg?._id) return;
try {
await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
createNotification({
text: "Successfully renamed organization",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename organization",
type: "error"
});
}
};
const onRemoveUserOrgMembership = async (membershipId: string) => {
if (!currentOrg?._id) return;
try {
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
createNotification({
text: "Successfully removed user from org",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
};
const onAddUserToOrg = async (email: string) => {
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
setcompleteInviteLink(data?.completeInviteLink);
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLink) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
};
const onUpdateOrgUserRole = async (membershipId: string, role: string) => {
if (!currentOrg?._id) return;
try {
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const onGrantUserAccess = async (userId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
userId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const onAddIncidentContact = async (email: string) => {
if (!currentOrg?._id) return;
try {
await addIncidentContact.mutateAsync({ orgId, email });
createNotification({
text: "Successfully added incident contact",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to add incident contact",
type: "error"
});
}
};
const onRemoveIncidentContact = async (email: string) => {
if (!currentOrg?._id) return;
try {
await removeIncidentContact.mutateAsync({ orgId, email });
createNotification({
text: "Successfully removed incident contact",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove incident contact",
type: "error"
});
}
};
const { data: roles } = useGetRoles({
orgId
});
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
@@ -218,20 +30,28 @@ export const MembersPage = () => {
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
{t("section.members.org-members")}
</p>
<OrgMembersTable
isLoading={isOrgUserLoading || IsWsMembershipLoading}
isMoreUserNotAllowed={isMoreUsersNotAllowed}
orgName={currentOrg?.name || ""}
members={orgUsers}
workspaceMemberships={workspaceMemberships}
onInviteMember={onAddUserToOrg}
userId={user?._id || ""}
onRemoveMember={onRemoveUserOrgMembership}
onRoleChange={onUpdateOrgUserRole}
onGrantAccess={onGrantUserAccess}
completeInviteLink={completeInviteLink}
setCompleteInviteLink={setcompleteInviteLink}
/>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>Members</Tab>
{process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && (
<Tab value={TabSections.Roles}>Roles</Tab>
)}
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgMembersTable roles={roles} />
</motion.div>
</TabPanel>
<TabPanel value={TabSections.Roles}>
<OrgRoleTabSection roles={roles} />
</TabPanel>
</Tabs>
</div>
</div>
);

View File

@@ -1,200 +0,0 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faContactBook,
faMagnifyingGlass,
faPlus,
faTrash
} 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,
EmailServiceSetupModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { IncidentContact } from "@app/hooks/api/types";
type Props = {
isLoading?: boolean;
contacts?: IncidentContact[];
onRemoveContact: (email: string) => Promise<void>;
onAddContact: (email: string) => Promise<void>;
};
const addContactFormSchema = yup.object({
email: yup.string().email().required().label("Email").trim()
});
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
export const OrgIncidentContactsTable = ({
contacts = [],
onAddContact,
onRemoveContact,
isLoading
}: Props) => {
const [searchContact, setSearchContact] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addContact",
"removeContact",
"setUpEmail"
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
const onAddIncidentContact = ({ email }: TAddContactForm) => {
onAddContact(email);
handlePopUpClose("addContact");
reset();
};
const onRemoveIncidentContact = async () => {
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
await onRemoveContact(incidentContactEmail);
handlePopUpClose("removeContact");
};
const filteredContacts = contacts.filter(({ email }) =>
email.toLocaleLowerCase().includes(searchContact)
);
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchContact}
onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..."
/>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (serverDetails?.emailConfigured) {
handlePopUpOpen("addContact");
} else {
handlePopUpOpen("setUpEmail");
}
}}
>
Add Contact
</Button>
</div>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Email</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email }) => (
<Tr key={email}>
<Td className="w-full">{email}</Td>
<Td className="mr-4">
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
))}
</TBody>
</Table>
{filteredContacts?.length === 0 && !isLoading && (
<EmptyState title="No incident contacts found" icon={faContactBook} />
)}
</TableContainer>
</div>
<Modal
isOpen={popUp?.addContact?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addContact", isOpen);
reset();
}}
>
<ModalContent
title="Add an Incident Contact"
subTitle="This contact will be notified in the unlikely event of a severe incident."
>
<form onSubmit={handleSubmit(onAddIncidentContact)}>
<Controller
control={control}
defaultValue=""
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Incident Contact
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addContact")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeContact.isOpen}
deleteKey="remove"
title="Do you want to remove this email from incident contact?"
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
onDeleteApproved={onRemoveIncidentContact}
/>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
);
};

View File

@@ -1 +0,0 @@
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";

View File

@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import {
@@ -14,6 +14,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
@@ -37,26 +41,23 @@ import {
Tr,
UpgradePlanModal
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetSSOConfig } from "@app/hooks/api";
import {
useAddUserToOrg,
useDeleteOrgMembership,
useGetOrgUsers,
useGetSSOConfig,
useGetUserWorkspaceMemberships,
useGetUserWsKey,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { OrgUser, Workspace } from "@app/hooks/api/types";
type Props = {
members?: OrgUser[];
workspaceMemberships?: Record<string, Workspace[]>;
orgName: string;
isLoading?: boolean;
isMoreUserNotAllowed: boolean;
onRemoveMember: (userId: string) => Promise<void>;
onInviteMember: (email: string) => Promise<void>;
onRoleChange: (membershipId: string, role: string) => Promise<void>;
onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
// the current user id to block remove org button
userId: string;
completeInviteLink: string | undefined;
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>;
roles?: TRole[];
};
const addMemberFormSchema = yup.object({
@@ -65,27 +66,21 @@ const addMemberFormSchema = yup.object({
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
export const OrgMembersTable = ({
members = [],
workspaceMemberships = {},
orgName,
isMoreUserNotAllowed,
onRemoveMember,
onInviteMember,
onGrantAccess,
onRoleChange,
userId,
isLoading,
completeInviteLink,
setCompleteInviteLink
}: Props) => {
export const OrgMembersTable = ({ roles = [] }: Props) => {
const router = useRouter();
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? "");
const { workspaces, currentWorkspace } = useWorkspace();
const { user } = useUser();
const userId = user?._id || "";
const orgId = currentOrg?._id || "";
const workspaceId = currentWorkspace?._id || "";
const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const { workspaces } = useWorkspace();
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
@@ -93,6 +88,23 @@ export const OrgMembersTable = ({
"upgradePlan",
"setUpEmail"
] as const);
const { subscription } = useSubscription();
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
useGetUserWorkspaceMemberships(orgId);
const { data: wsKey } = useGetUserWsKey(workspaceId);
const removeUserOrgMembership = useDeleteOrgMembership();
const addUserToOrg = useAddUserToOrg();
const updateOrgUserRole = useUpdateOrgUserRole();
const uploadWsKey = useUploadWsKey();
const [completeInviteLink, setCompleteInviteLink] = useState<string | undefined>("");
const isMoreUsersNotAllowed = subscription?.memberLimit
? subscription.membersUsed >= subscription.memberLimit
: false;
useEffect(() => {
if (router.query.action === "invite") {
@@ -108,32 +120,100 @@ export const OrgMembersTable = ({
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
const onAddMember = async ({ email }: TAddMemberForm) => {
await onInviteMember(email);
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
setCompleteInviteLink(data?.completeInviteLink);
// only show this notification when email is configured.
// A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLink) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
if (serverDetails?.emailConfigured) {
handlePopUpClose("addMember");
}
reset();
};
const onAddUserToOrg = async (email: string) => {
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
setCompleteInviteLink(data?.completeInviteLink);
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
if (!data.completeInviteLink) {
createNotification({
text: "Successfully invited user to the organization.",
type: "success"
});
}
} catch (error) {
console.error(error);
createNotification({
text: "Failed to invite user to org",
type: "error"
});
}
};
const onRemoveOrgMemberApproved = async () => {
const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id;
await onRemoveMember(orgMembershipId);
const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
if (!currentOrg?._id) return;
try {
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
createNotification({
text: "Successfully removed user from org",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the organization",
type: "error"
});
}
handlePopUpClose("removeMember");
};
const isIamOwner = useMemo(
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
() => members?.find(({ user: u }) => userId === u?._id)?.role === "owner",
[userId, members]
);
const findRoleFromId = useCallback(
(roleId: string) => {
return roles.find(({ _id: id }) => id === roleId);
},
[roles]
);
const filterdUser = useMemo(
() =>
members.filter(
({ user, inviteEmail }) =>
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
u?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
),
[members, searchMemberFilter]
@@ -147,11 +227,65 @@ export const OrgMembersTable = ({
return () => clearTimeout(timer);
}, [isInviteLinkCopied]);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?._id) return;
try {
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const onGrantAccess = async (grantedUserId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
userId: grantedUserId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(completeInviteLink as string);
setInviteLinkCopied.on();
};
const isLoading = isMembersLoading || IsWsMembershipLoading;
return (
<div className="w-full">
<div className="mb-4 flex">
@@ -175,7 +309,7 @@ export const OrgMembersTable = ({
return;
}
if (isMoreUserNotAllowed) {
if (isMoreUsersNotAllowed) {
handlePopUpOpen("upgradePlan");
} else {
handlePopUpOpen("addMember");
@@ -200,106 +334,116 @@ export const OrgMembersTable = ({
<TBody>
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading &&
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
const name = user ? `${user.firstName} ${user.lastName}` : "-";
const email = user?.email || inviteEmail;
const userWs = workspaceMemberships?.[user?._id];
filterdUser?.map(
({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => {
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const userWs = workspaceMemberships?.[u?._id];
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
{status === "accepted" && (
<Select
defaultValue={role}
isDisabled={userId === user?._id}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(isIamOwner || role === "owner") && (
<SelectItem value="owner">owner</SelectItem>
)}
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
</Select>
)}
{(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured && (
<Button
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => onInviteMember(email)}
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
{status === "accepted" && (
<Select
defaultValue={
role === "custom" ? findRoleFromId(customRole)?.slug : role
}
isDisabled={userId === u?._id}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
Resend Invite
{roles
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured && (
<Button
className="w-40"
colorSchema="primary"
variant="outline_bg"
onClick={() => onAddUserToOrg(email)}
>
Resend Invite
</Button>
)}
{status === "completed" && (
<Button
colorSchema="secondary"
onClick={() => onGrantAccess(u?._id, u?.publicKey)}
>
Grant Access
</Button>
)}
{status === "completed" && (
<Button
colorSchema="secondary"
onClick={() => onGrantAccess(user?._id, user?.publicKey)}
>
Grant Access
</Button>
)}
</Td>
<Td>
{userWs ? (
userWs?.map(({ name: wsName, _id }) => (
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
{wsName}
</Tag>
))
) : (
<div className="flex flex-row">
{(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured ? (
<Tag colorSchema="red">
This user hasn&apos;t accepted the invite yet
</Td>
<Td>
{userWs ? (
userWs?.map(({ name: wsName, _id }) => (
<Tag key={`user-${u._id}-workspace-${_id}`} className="my-1">
{wsName}
</Tag>
) : (
<Tag colorSchema="red">
This user isn&apos;t part of any projects yet
</Tag>
)}
{router.query.id !== "undefined" &&
!(
(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured
) && (
<button
type="button"
onClick={() =>
router.push(`/project/${workspaces[0]?._id}/members`)
}
className="w-max cursor-pointer rounded-sm bg-mineshaft px-1.5 py-0.5 text-sm duration-200 hover:bg-primary hover:text-black"
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
Add to projects
</button>
))
) : (
<div className="flex flex-row">
{(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured ? (
<Tag colorSchema="red">
This user hasn&apos;t accepted the invite yet
</Tag>
) : (
<Tag colorSchema="red">
This user isn&apos;t part of any projects yet
</Tag>
)}
</div>
)}
</Td>
<Td>
{userId !== user?._id && (
<IconButton
ariaLabel="delete"
colorSchema="danger"
isDisabled={userId === user?._id}
onClick={() => handlePopUpOpen("removeMember", { id: orgMembershipId })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</Td>
</Tr>
);
})}
{router.query.id !== "undefined" &&
!(
(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured
) && (
<button
type="button"
onClick={() =>
router.push(`/project/${workspaces[0]?._id}/members`)
}
className="w-max cursor-pointer rounded-sm bg-mineshaft px-1.5 py-0.5 text-sm duration-200 hover:bg-primary hover:text-black"
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
Add to projects
</button>
)}
</div>
)}
</Td>
<Td>
{userId !== u?._id && (
<IconButton
ariaLabel="delete"
colorSchema="danger"
isDisabled={userId === u?._id}
onClick={() =>
handlePopUpOpen("removeMember", { id: orgMembershipId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</Td>
</Tr>
);
}
)}
</TBody>
</Table>
{!isLoading && filterdUser?.length === 0 && (
@@ -315,7 +459,7 @@ export const OrgMembersTable = ({
}}
>
<ModalContent
title={`Invite others to ${orgName}`}
title={`Invite others to ${currentOrg?.name}`}
subTitle={
<div>
{!completeInviteLink && (

View File

@@ -1,69 +0,0 @@
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 = {
orgName?: string;
onOrgNameChange: (name: string) => Promise<void>;
};
const formSchema = yup.object({
name: yup.string().required().label("Project Name")
});
type FormData = yup.InferType<typeof formSchema>;
export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => {
const {
handleSubmit,
control,
reset,
formState: { isDirty, isSubmitting }
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
const { t } = useTranslation();
useEffect(() => {
reset({ name: orgName });
}, [orgName]);
const onFormSubmit = async ({ name }: FormData) => {
await onOrgNameChange(name);
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-3">
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p>
<div className="mb-2 w-full max-w-lg">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Type your org name" {...field} />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<Button
isLoading={isSubmitting}
color="primary"
variant="outline_bg"
size="sm"
type="submit"
isDisabled={!isDirty || isSubmitting}
leftIcon={<FontAwesomeIcon icon={faCheck} />}
>
{t("common.save-changes")}
</Button>
</div>
</form>
);
};

View File

@@ -1 +0,0 @@
export { OrgNameChangeSection } from "./OrgNameChangeSection";

View File

@@ -0,0 +1,167 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { faArrowLeft, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, FormControl, Input } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useCreateRole, useUpdateRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "./OrgRoleModifySection.utils";
import { OrgRoleWorkspacePermission } from "./OrgRoleWorkspacePermission";
type Props = {
role?: TRole;
onGoBack: VoidFunction;
};
export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
const [searchPermission, setSearchPermission] = useState("");
const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || "");
const isNewRole = !role?.slug;
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const orgId = currentOrg?._id || "";
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, errors },
setValue,
control
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
resolver: yupResolver(formSchema)
});
const { mutateAsync: createRole } = useCreateRole();
const { mutateAsync: updateRole } = useUpdateRole();
const handleRoleUpdate = async (el: TFormSchema) => {
if (!role?._id) return;
try {
await updateRole({
orgId,
id: role?._id,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const handleFormSubmit = async (el: TFormSchema) => {
if (!isNewRole) {
await handleRoleUpdate(el);
return;
}
try {
await createRole({
orgId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Created new role" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="flex justify-between mb-2 items-center">
<h1 className="text-xl font-semibold text-mineshaft-100">
{isNewRole ? "New" : "Edit"} Role
</h1>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
</div>
<p className="mb-8 text-gray-400">
Roles are used to grant access to particular resources in your organization
</p>
<div className="flex flex-col space-y-6">
<FormControl
label="Name"
isRequired
className="mb-0"
isError={Boolean(errors?.name)}
errorText={errors?.name?.message}
>
<Input {...register("name")} placeholder="Billing Team" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Slug"
isRequired
isError={Boolean(errors?.slug)}
errorText={errors?.slug?.message}
>
<Input {...register("slug")} placeholder="biller" isReadOnly={isNonEditable} />
</FormControl>
<FormControl
label="Description"
helperText="A short description about this role"
isError={Boolean(errors?.description)}
errorText={errors?.description?.message}
>
<Input {...register("description")} isReadOnly={isNonEditable} />
</FormControl>
<div className="flex justify-between items-center pt-4 border-t border-t-mineshaft-800">
<div>
<h2 className="text-xl font-medium">Add Permission</h2>
</div>
<div className="flex-1 max-w-md">
<Input
value={searchPermission}
onChange={(e) => setSearchPermission(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search permissions..."
/>
</div>
</div>
<div className="flex flex-col space-y-4">
<OrgRoleWorkspacePermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
/>
</div>
</div>
<div className="flex items-center space-x-4 mt-12">
<Button
type="submit"
isDisabled={isSubmitting || isNonEditable || !isDirty}
isLoading={isSubmitting}
>
{isNewRole ? "Create Role" : "Save Role"}
</Button>
<Button onClick={onGoBack} variant="outline_bg">
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -0,0 +1,93 @@
/* eslint-disable no-param-reassign */
import * as yup from "yup";
import { TPermission } from "@app/hooks/api/roles/types";
const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
export const formSchema = yup.object({
name: yup.string().required().label("Name"),
description: yup.string(),
slug: yup.string().required().label("Slug"),
permissions: yup.object({
workspace: yup.lazy((val) =>
yup.object(
Object.fromEntries(
Object.entries(val || {}).map(([k]) => [
k,
yup.object({
read: yup.bool(),
edit: yup.bool(),
delete: yup.bool(),
create: yup.bool()
})
])
)
)
)
})
});
export type TFormSchema = yup.InferType<typeof formSchema>;
const api2FormWorkspace = (
formVal: TFormSchema["permissions"]["workspace"],
permission: TPermission
) => {
if (permission.subject !== "workspace") return;
const isCustomRule = Boolean(permission?.condition?.id);
if (isCustomRule && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true };
}
const workspaceId = permission?.condition?.id || "all";
if (!formVal?.[workspaceId])
formVal[workspaceId] = { read: false, edit: false, create: false, delete: false };
formVal[workspaceId][permission.action] = true;
};
// convert role permission to form compatiable data structure
export const rolePermission2Form = (permissions: TPermission[] = []) => {
const formVal: TFormSchema["permissions"] = {
workspace: {}
};
permissions.forEach((permission) => {
api2FormWorkspace(formVal?.workspace, permission);
});
return formVal;
};
const form2ApiWorkspace = (
permissions: TPermission[],
workspace: TFormSchema["permissions"]["workspace"]
) => {
const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]);
PERMISSION_ACTIONS.forEach((action) => {
if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" });
});
if (!isFullAccess) {
Object.keys(workspace)
.filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter
.forEach((workspaceId) => {
const actions = Object.keys(workspace[workspaceId]) as ["read", "edit", "create", "delete"];
actions.forEach((action) => {
// if not full access for an action
if (!workspace?.all?.[action] && workspace[workspaceId][action]) {
permissions.push({ action, subject: "workspace", condition: { id: workspaceId } });
}
});
});
}
};
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TPermission[] = [];
// easy deep copy
if (formVal?.workspace)
form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace)));
return permissions;
};

View File

@@ -0,0 +1,205 @@
import { useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faClipboardList } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import {
Checkbox,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TFormSchema } from "./OrgRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum WorkspacePermission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: Props) => {
const { workspaces } = useWorkspace();
const customWorkspaceRule = useWatch({
control,
name: "permissions.workspace.custom"
});
const isCustom = Boolean(customWorkspaceRule);
const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" });
const selectedWsTopVal = useMemo(() => {
const { read, delete: del, edit, create } = allWorkspaceRule || {};
if (read && del && edit && create) return WorkspacePermission.FullAccess;
if (read) return WorkspacePermission.ReadOnly;
return WorkspacePermission.NoAccess;
}, [allWorkspaceRule]);
const handleTopLevelPermissionChange = (val: WorkspacePermission) => {
switch (val) {
case WorkspacePermission.NoAccess:
setValue("permissions.workspace", {}, { shouldDirty: true });
break;
case WorkspacePermission.FullAccess:
setValue(
"permissions.workspace",
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
break;
case WorkspacePermission.ReadOnly:
setValue(
"permissions.workspace",
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
default:
setValue(
"permissions.workspace",
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
break;
}
};
return (
<div className="px-10 py-6 bg-mineshaft-800 rounded-md">
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faClipboardList} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Projects</div>
<div className="text-xs font-light">User project access control</div>
</div>
<div>
<Select
defaultValue={WorkspacePermission.NoAccess}
isDisabled={isNonEditable}
value={isCustom ? WorkspacePermission.Custom : selectedWsTopVal}
onValueChange={handleTopLevelPermissionChange}
>
<SelectItem value={WorkspacePermission.NoAccess}>No Access</SelectItem>
<SelectItem value={WorkspacePermission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={WorkspacePermission.FullAccess}>Full Access</SelectItem>
<SelectItem value={WorkspacePermission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "auto" : 0 }}
className="overflow-hidden"
>
<TableContainer className="border-mineshaft-500 mt-6">
<Table>
<THead>
<Tr>
<Th />
<Th className="text-center">Read</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Edit</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
workspaces?.map(({ name, _id: id }) => (
<Tr key={`custom-role-ws-${name}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.read`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.workspace.${id}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.delete`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgRoleModifySection } from "./OrgRoleModifySection";

View File

@@ -0,0 +1,46 @@
import { motion } from "framer-motion";
import { usePopUp } from "@app/hooks";
import { TRole } from "~/hooks/api/roles/types";
import { OrgRoleModifySection } from "./OrgRoleModifySection";
import { OrgRoleTable } from "./OrgRoleTable";
type Props = {
roles?: TRole[];
isRolesLoading?: boolean;
};
export const OrgRoleTabSection = ({ roles = [], isRolesLoading }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
return popUp.editRole.isOpen ? (
<motion.div
key="role-modify"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<OrgRoleModifySection
role={popUp.editRole.data as TRole}
onGoBack={() => handlePopUpClose("editRole")}
/>
</motion.div>
) : (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<OrgRoleTable
roles={roles}
isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/>
</motion.div>
);
};

View File

@@ -0,0 +1,140 @@
import { useState } from "react";
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import {
Button,
DeleteActionModal,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types";
import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider";
import { usePopUp } from "~/hooks/usePopUp";
type Props = {
isRolesLoading?: boolean;
roles?: TRole[];
onSelectRole: (role?: TRole) => void;
};
export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
const [searchRoles, setSearchRoles] = useState("");
const { currentOrg } = useOrganization();
const orgId = currentOrg?._id || "";
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
const { mutateAsync: deleteRole } = useDeleteRole();
const handleRoleDelete = async () => {
const { _id: id } = popUp?.deleteRole?.data as TRole;
try {
await deleteRole({
orgId,
id
});
createNotification({ type: "success", text: "Successfully removed the role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create role" });
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchRoles}
onChange={(e) => setSearchRoles(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search roles..."
/>
</div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}>
Add Role
</Button>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>Created At</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
const { _id: id, name, createdAt, slug } = role;
const isNonMutatable = ["owner", "admin", "member"].includes(slug);
return (
<Tr key={id}>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
{createdAt ? format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa") : "-"}
</Td>
<Td>
<div className="flex space-x-2 items-center">
<Tooltip content="Edit">
<IconButton
ariaLabel="edit"
onClick={() => onSelectRole(role)}
variant="plain"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
<Tooltip
content={isNonMutatable ? "Reserved roles are non-removable" : "Delete"}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteRole?.data as TRole)?.name || " "
} role?`}
deleteKey={(popUp?.deleteRole?.data as TRole)?.slug || ""}
onClose={() => handlePopUpClose("deleteRole")}
onDeleteApproved={handleRoleDelete}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export { OrgRoleTabSection } from "./OrgRoleTabSection";

View File

@@ -1,367 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import {
faCheck,
faCopy,
faMagnifyingGlass,
faPencil,
faPlus,
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useCreateServiceAccount,
useDeleteServiceAccount,
useGetServiceAccounts
} from "@app/hooks/api";
const serviceAccountExpiration = [
{ label: "1 Day", value: 86400 },
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: -1 }
];
const addServiceAccountFormSchema = yup.object({
name: yup.string().required().label("Name").trim(),
expiresIn: yup.string().required().label("Service Account Expiration")
});
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => {
const router = useRouter();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey, setAccessKey] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
"removeServiceAccount"
] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
useGetServiceAccounts(orgId);
const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => {
let timer: NodeJS.Timeout;
if (isAccessKeyCopied) {
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
}
if (isPublicKeyCopied) {
timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
}
if (isPrivateKeyCopied) {
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPublicKey(keyPair.publicKey);
setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn: Number(expiresIn)
});
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
};
const onRemoveServiceAccount = async () => {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose("removeServiceAccount");
};
const filteredServiceAccounts = useMemo(
() =>
serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<form onSubmit={handleSubmit(onAddServiceAccount)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />;
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && (
<TableSkeleton columns={5} innerKey="org-service-accounts" />
)}
{!isServiceAccountsLoading &&
filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(
`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() =>
handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
};

View File

@@ -1 +0,0 @@
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";

View File

@@ -1,5 +0,0 @@
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export { OrgMembersTable } from "./OrgMembersTable";
export { OrgNameChangeSection } from "./OrgNameChangeSection";
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";