mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 19:26:07 +00:00
feat(rbac): base ui for org rbac management
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
||||||
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { faFolderOpen } from "@fortawesome/free-regular-svg-icons";
|
||||||
import {
|
import {
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowUpRightFromSquare,
|
||||||
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
faExclamationCircle,
|
faExclamationCircle,
|
||||||
|
faClipboard,
|
||||||
faHandPeace,
|
faHandPeace,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faNetworkWired,
|
faNetworkWired,
|
||||||
@@ -22,6 +27,7 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as Tabs from "@radix-ui/react-tabs";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
@@ -36,7 +42,6 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
UpgradePlanModal
|
UpgradePlanModal
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { TabsObject } from "@app/components/v2/Tabs";
|
|
||||||
import { useSubscription, useUser, useWorkspace } from "@app/context";
|
import { useSubscription, useUser, useWorkspace } from "@app/context";
|
||||||
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useRegisterUserAction,useUploadWsKey } from "@app/hooks/api";
|
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useRegisterUserAction,useUploadWsKey } from "@app/hooks/api";
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
@@ -63,6 +68,210 @@ type ItemProps = {
|
|||||||
link?: string;
|
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 = ({
|
const LearningItem = ({
|
||||||
text,
|
text,
|
||||||
subText,
|
subText,
|
||||||
|
|||||||
@@ -1,216 +1,28 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
import {
|
import { useOrganization } from "@app/context";
|
||||||
decryptAssymmetric,
|
import { useGetRoles } from "@app/hooks/api";
|
||||||
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 {
|
import { OrgMembersTable } from "./components/OrgMembersTable";
|
||||||
OrgIncidentContactsTable,
|
import { OrgRoleTabSection } from "./components/OrgRoleTabSection";
|
||||||
OrgMembersTable,
|
|
||||||
OrgNameChangeSection,
|
enum TabSections {
|
||||||
OrgServiceAccountsTable
|
Member = "members",
|
||||||
} from "./components";
|
Roles = "roles"
|
||||||
|
}
|
||||||
|
|
||||||
export const MembersPage = () => {
|
export const MembersPage = () => {
|
||||||
const host = window.location.origin;
|
|
||||||
const router = useRouter();
|
|
||||||
const { action } = router.query;
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { subscription } = useSubscription();
|
|
||||||
const { createNotification } = useNotificationContext();
|
|
||||||
|
|
||||||
const orgId = currentOrg?._id || "";
|
const orgId = currentOrg?._id || "";
|
||||||
|
|
||||||
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
|
const { data: roles } = useGetRoles({
|
||||||
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
|
orgId
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<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">
|
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
|
||||||
{t("section.members.org-members")}
|
{t("section.members.org-members")}
|
||||||
</p>
|
</p>
|
||||||
<OrgMembersTable
|
<Tabs defaultValue={TabSections.Member}>
|
||||||
isLoading={isOrgUserLoading || IsWsMembershipLoading}
|
<TabList>
|
||||||
isMoreUserNotAllowed={isMoreUsersNotAllowed}
|
<Tab value={TabSections.Member}>Members</Tab>
|
||||||
orgName={currentOrg?.name || ""}
|
{process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && (
|
||||||
members={orgUsers}
|
<Tab value={TabSections.Roles}>Roles</Tab>
|
||||||
workspaceMemberships={workspaceMemberships}
|
)}
|
||||||
onInviteMember={onAddUserToOrg}
|
</TabList>
|
||||||
userId={user?._id || ""}
|
<TabPanel value={TabSections.Member}>
|
||||||
onRemoveMember={onRemoveUserOrgMembership}
|
<motion.div
|
||||||
onRoleChange={onUpdateOrgUserRole}
|
key="panel-1"
|
||||||
onGrantAccess={onGrantUserAccess}
|
transition={{ duration: 0.15 }}
|
||||||
completeInviteLink={completeInviteLink}
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
setCompleteInviteLink={setcompleteInviteLink}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
-200
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
|
||||||
+283
-139
@@ -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 { Controller, useForm } from "react-hook-form";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
|||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
decryptAssymmetric,
|
||||||
|
encryptAssymmetric
|
||||||
|
} from "@app/components/utilities/cryptography/crypto";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
@@ -37,26 +41,23 @@ import {
|
|||||||
Tr,
|
Tr,
|
||||||
UpgradePlanModal
|
UpgradePlanModal
|
||||||
} from "@app/components/v2";
|
} 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 { 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 { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
import { OrgUser, Workspace } from "@app/hooks/api/types";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
members?: OrgUser[];
|
roles?: TRole[];
|
||||||
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>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMemberFormSchema = yup.object({
|
const addMemberFormSchema = yup.object({
|
||||||
@@ -65,27 +66,21 @@ const addMemberFormSchema = yup.object({
|
|||||||
|
|
||||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
||||||
|
|
||||||
export const OrgMembersTable = ({
|
export const OrgMembersTable = ({ roles = [] }: Props) => {
|
||||||
members = [],
|
|
||||||
workspaceMemberships = {},
|
|
||||||
orgName,
|
|
||||||
isMoreUserNotAllowed,
|
|
||||||
onRemoveMember,
|
|
||||||
onInviteMember,
|
|
||||||
onGrantAccess,
|
|
||||||
onRoleChange,
|
|
||||||
userId,
|
|
||||||
isLoading,
|
|
||||||
completeInviteLink,
|
|
||||||
setCompleteInviteLink
|
|
||||||
}: Props) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { createNotification } = useNotificationContext();
|
const { createNotification } = useNotificationContext();
|
||||||
|
|
||||||
const { currentOrg } = useOrganization();
|
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 [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const { workspaces } = useWorkspace();
|
|
||||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
"addMember",
|
"addMember",
|
||||||
@@ -93,6 +88,23 @@ export const OrgMembersTable = ({
|
|||||||
"upgradePlan",
|
"upgradePlan",
|
||||||
"setUpEmail"
|
"setUpEmail"
|
||||||
] as const);
|
] 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(() => {
|
useEffect(() => {
|
||||||
if (router.query.action === "invite") {
|
if (router.query.action === "invite") {
|
||||||
@@ -108,32 +120,100 @@ export const OrgMembersTable = ({
|
|||||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||||
|
|
||||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
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) {
|
if (serverDetails?.emailConfigured) {
|
||||||
handlePopUpClose("addMember");
|
handlePopUpClose("addMember");
|
||||||
}
|
}
|
||||||
|
|
||||||
reset();
|
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 onRemoveOrgMemberApproved = async () => {
|
||||||
const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id;
|
const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
|
||||||
await onRemoveMember(orgMembershipId);
|
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");
|
handlePopUpClose("removeMember");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIamOwner = useMemo(
|
const isIamOwner = useMemo(
|
||||||
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
|
() => members?.find(({ user: u }) => userId === u?._id)?.role === "owner",
|
||||||
[userId, members]
|
[userId, members]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const findRoleFromId = useCallback(
|
||||||
|
(roleId: string) => {
|
||||||
|
return roles.find(({ _id: id }) => id === roleId);
|
||||||
|
},
|
||||||
|
[roles]
|
||||||
|
);
|
||||||
|
|
||||||
const filterdUser = useMemo(
|
const filterdUser = useMemo(
|
||||||
() =>
|
() =>
|
||||||
members.filter(
|
members?.filter(
|
||||||
({ user, inviteEmail }) =>
|
({ user: u, inviteEmail }) =>
|
||||||
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
|
u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
|
u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
|
u?.email?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
inviteEmail?.includes(searchMemberFilter)
|
inviteEmail?.includes(searchMemberFilter)
|
||||||
),
|
),
|
||||||
[members, searchMemberFilter]
|
[members, searchMemberFilter]
|
||||||
@@ -147,11 +227,65 @@ export const OrgMembersTable = ({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isInviteLinkCopied]);
|
}, [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 = () => {
|
const copyTokenToClipboard = () => {
|
||||||
navigator.clipboard.writeText(completeInviteLink as string);
|
navigator.clipboard.writeText(completeInviteLink as string);
|
||||||
setInviteLinkCopied.on();
|
setInviteLinkCopied.on();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLoading = isMembersLoading || IsWsMembershipLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="mb-4 flex">
|
<div className="mb-4 flex">
|
||||||
@@ -175,7 +309,7 @@ export const OrgMembersTable = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMoreUserNotAllowed) {
|
if (isMoreUsersNotAllowed) {
|
||||||
handlePopUpOpen("upgradePlan");
|
handlePopUpOpen("upgradePlan");
|
||||||
} else {
|
} else {
|
||||||
handlePopUpOpen("addMember");
|
handlePopUpOpen("addMember");
|
||||||
@@ -200,106 +334,116 @@ export const OrgMembersTable = ({
|
|||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
|
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
filterdUser?.map(
|
||||||
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => {
|
||||||
const email = user?.email || inviteEmail;
|
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||||
const userWs = workspaceMemberships?.[user?._id];
|
const email = u?.email || inviteEmail;
|
||||||
|
const userWs = workspaceMemberships?.[u?._id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
|
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
|
||||||
<Td>{name}</Td>
|
<Td>{name}</Td>
|
||||||
<Td>{email}</Td>
|
<Td>{email}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
{status === "accepted" && (
|
{status === "accepted" && (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={role}
|
defaultValue={
|
||||||
isDisabled={userId === user?._id}
|
role === "custom" ? findRoleFromId(customRole)?.slug : role
|
||||||
className="w-40 bg-mineshaft-600"
|
}
|
||||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
isDisabled={userId === u?._id}
|
||||||
onValueChange={(selectedRole) =>
|
className="w-40 bg-mineshaft-600"
|
||||||
onRoleChange(orgMembershipId, selectedRole)
|
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)}
|
|
||||||
>
|
>
|
||||||
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === "completed" && (
|
</Td>
|
||||||
<Button
|
<Td>
|
||||||
colorSchema="secondary"
|
{userWs ? (
|
||||||
onClick={() => onGrantAccess(user?._id, user?.publicKey)}
|
userWs?.map(({ name: wsName, _id }) => (
|
||||||
>
|
<Tag key={`user-${u._id}-workspace-${_id}`} className="my-1">
|
||||||
Grant Access
|
{wsName}
|
||||||
</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't accepted the invite yet
|
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
))
|
||||||
<Tag colorSchema="red">
|
) : (
|
||||||
This user isn't part of any projects yet
|
<div className="flex flex-row">
|
||||||
</Tag>
|
{(status === "invited" || status === "verified") &&
|
||||||
)}
|
serverDetails?.emailConfigured ? (
|
||||||
{router.query.id !== "undefined" &&
|
<Tag colorSchema="red">
|
||||||
!(
|
This user hasn't accepted the invite yet
|
||||||
(status === "invited" || status === "verified") &&
|
</Tag>
|
||||||
serverDetails?.emailConfigured
|
) : (
|
||||||
) && (
|
<Tag colorSchema="red">
|
||||||
<button
|
This user isn't part of any projects yet
|
||||||
type="button"
|
</Tag>
|
||||||
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>
|
{router.query.id !== "undefined" &&
|
||||||
)}
|
!(
|
||||||
</Td>
|
(status === "invited" || status === "verified") &&
|
||||||
<Td>
|
serverDetails?.emailConfigured
|
||||||
{userId !== user?._id && (
|
) && (
|
||||||
<IconButton
|
<button
|
||||||
ariaLabel="delete"
|
type="button"
|
||||||
colorSchema="danger"
|
onClick={() =>
|
||||||
isDisabled={userId === user?._id}
|
router.push(`/project/${workspaces[0]?._id}/members`)
|
||||||
onClick={() => handlePopUpOpen("removeMember", { id: orgMembershipId })}
|
}
|
||||||
>
|
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={faTrash} />
|
>
|
||||||
</IconButton>
|
<FontAwesomeIcon icon={faPlus} className="mr-1" />
|
||||||
)}
|
Add to projects
|
||||||
</Td>
|
</button>
|
||||||
</Tr>
|
)}
|
||||||
);
|
</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>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isLoading && filterdUser?.length === 0 && (
|
{!isLoading && filterdUser?.length === 0 && (
|
||||||
@@ -315,7 +459,7 @@ export const OrgMembersTable = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title={`Invite others to ${orgName}`}
|
title={`Invite others to ${currentOrg?.name}`}
|
||||||
subTitle={
|
subTitle={
|
||||||
<div>
|
<div>
|
||||||
{!completeInviteLink && (
|
{!completeInviteLink && (
|
||||||
|
|||||||
-69
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
|
||||||
+167
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+93
@@ -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;
|
||||||
|
};
|
||||||
+205
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { OrgRoleModifySection } from "./OrgRoleModifySection";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrgRoleTabSection } from "./OrgRoleTabSection";
|
||||||
-367
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
|
||||||
export { OrgMembersTable } from "./OrgMembersTable";
|
|
||||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
|
||||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user