Finish preliminary making user settings, org settings styling similar to usage and billing page

This commit is contained in:
Tuan Dang
2023-06-29 17:47:23 +07:00
parent 81c69d92b3
commit 056f5a4555
73 changed files with 2098 additions and 1698 deletions
@@ -143,7 +143,7 @@ export const DashboardEnvOverview = () => {
return (
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<div className="relative right-5">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
<div className="mt-6">
@@ -545,7 +545,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<FormProvider {...method}>
<form autoComplete="off" className="h-full">
{/* breadcrumb row */}
<div className="relative right-6 -top-2 mb-2">
<div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader
pageName={t("dashboard.title")}
currentEnv={
@@ -9,16 +9,13 @@ import {
export const BillingSettingsPage = () => {
const { t } = useTranslation();
return (
<div className="h-full py-8 px-4">
<NavHeader pageName={t("billing.title")} />
<div className="ml-4 flex text-3xl mt-8 items-start max-w-screen-lg">
<div className="flex-1">
<p className="font-semibold text-gray-200">{t("billing.title")}</p>
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<NavHeader pageName={t("billing.title")} />
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
<div />
</div>
<div />
</div>
<div className="ml-4">
<BillingTabGroup />
</div>
</div>
@@ -43,7 +43,7 @@ export const CurrentPlanSection = () => {
}
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
<TableContainer className="mt-4">
<Table>
@@ -39,7 +39,7 @@ export const PreviewSection = () => {
return (
<div>
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && (
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mt-8 flex items-center bg-mineshaft-600 max-w-screen-lg">
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mb-6 flex items-center bg-mineshaft-600 max-w-screen-lg">
<div className="flex-1">
<h2 className="text-xl font-semibold text-mineshaft-50">Become Infisical</h2>
<p className="text-gray-400 mt-4">Unlimited members, projects, RBAC, smart alerts, and so much more</p>
@@ -53,7 +53,7 @@ export const PreviewSection = () => {
</div>
)}
{!isLoading && data && (
<div className="flex mt-8 max-w-screen-lg">
<div className="flex mb-6 max-w-screen-lg">
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 mr-4 border border-mineshaft-600">
<p className="mb-2 text-gray-400">Current plan</p>
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">Starter</p>
@@ -63,7 +63,7 @@ export const CompanyNameSection = () => {
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Business name
@@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => {
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
Invoice email recipient
@@ -26,7 +26,7 @@ export const PmtMethodsSection = () => {
}
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Payment Methods
@@ -3,7 +3,6 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
// import Button from "@app/components/basic/buttons/Button";
import { usePopUp } from "@app/hooks/usePopUp";
import { TaxIDModal } from "./TaxIDModal";
@@ -15,7 +14,7 @@ export const TaxIDSection = () => {
] as const);
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex items-center mb-8">
<h2 className="text-xl font-semibold flex-1 text-white">
Tax ID
@@ -2,7 +2,7 @@ import { InvoicesTable } from "./InvoicesTable";
export const BillingReceiptsTab = () => {
return (
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
<InvoicesTable />
</div>
@@ -14,7 +14,7 @@ const tabs = [
export const BillingTabGroup = () => {
return (
<Tab.Group>
<Tab.List className="mt-8 border-b-2 border-mineshaft-800 max-w-screen-lg">
<Tab.List className="mt-8 mb-6 border-b-2 border-mineshaft-800 max-w-screen-lg">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
@@ -1,282 +1,66 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
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,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
import {
OrgIncidentContactsTable,
OrgMembersTable,
OrgIncidentContactsSection,
OrgMembersSection,
OrgNameChangeSection,
OrgServiceAccountsTable
} from "./components";
export const OrgSettingsPage = () => {
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 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 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 (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<NavHeader pageName={t("settings.org.title")} />
<div className="my-8 ml-8 max-w-5xl">
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
<p className="text-base font-normal text-gray-400">{t("settings.org.description")}</p>
</div>
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
<OrgNameChangeSection />
<div className="mb-6 w-full rounded-md bg-white/5 p-6">
<p className="mr-4 mb-4 text-xl 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}
/>
</div>
<div className="mb-6 mt-2 w-full rounded-md bg-white/5 p-6">
<p className="mr-4 mb-4 text-xl font-semibold text-white">Service Accounts</p>
<OrgMembersSection />
<OrgIncidentContactsSection />
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<OrgServiceAccountsTable />
</div>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
<div className="flex w-full max-w-3xl flex-col justify-between">
<p className="mb-3 min-w-max text-xl font-semibold">
{t("section.incident.incident-contacts")}
</p>
<p className="mb-2 min-w-max text-xs text-gray-500">
{t("section.incident.incident-contacts-description")}
</p>
</div>
</div>
<div className="w-full">
<OrgIncidentContactsTable
isLoading={IsIncidentContactLoading}
contacts={incidentContact}
onRemoveContact={onRemoveIncidentContact}
onAddContact={onAddIncidentContact}
/>
</div>
</div>
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
As soon as you delete an organization, you will
not be able to undo it. This will immediately
remove all organization members and cancel your
subscription. If you still want to do that,
please enter the name of the organization below.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
As soon as you delete an organization, you will
not be able to undo it. This will immediately
remove all organization members and cancel your
subscription. If you still want to do that,
please enter the name of the organization below.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
</div>
);
};
@@ -0,0 +1,120 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useAddIncidentContact
} from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addContactFormSchema = yup.object({
email: yup.string().email().required().label("Email").trim()
});
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
type Props = {
popUp: UsePopUpState<["addContact"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void;
};
export const AddOrgIncidentContactModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { data: serverDetails } = useFetchServerStatus()
const {
control,
handleSubmit,
reset
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
const { mutateAsync, isLoading } = useAddIncidentContact();
const onFormSubmit = async ({ email }: TAddContactForm) => {
try {
if (!currentOrg?._id) return;
await mutateAsync({
orgId: currentOrg._id,
email
});
createNotification({
text: "Successfully added incident contact",
type: "success"
});
if (serverDetails?.emailConfigured){
handlePopUpClose("addContact");
}
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to add incident contact",
type: "error"
});
}
}
return (
<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(onFormSubmit)}>
<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
size="sm"
type="submit"
isLoading={isLoading}
isDisabled={isLoading}
>
Add Incident Contact
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addContact")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button
} from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export const OrgIncidentContactsSection = () => {
const { t } = useTranslation();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addContact"
] as const);
return (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<p className="min-w-max text-xl font-semibold">
{t("section.incident.incident-contacts")}
</p>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addContact")}
>
Add contact
</Button>
</div>
<OrgIncidentContactsTable />
<AddOrgIncidentContactModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
}
@@ -0,0 +1,117 @@
import { useState } from "react";
import {
faContactBook,
faMagnifyingGlass,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
DeleteActionModal,
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useDeleteIncidentContact,
useGetOrgIncidentContact} from "@app/hooks/api";
export const OrgIncidentContactsTable = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? "");
const [searchContact, setSearchContact] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeContact",
"setUpEmail"
] as const);
const { mutateAsync } = useDeleteIncidentContact();
const onRemoveIncidentContact = async () => {
try {
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
if (!currentOrg?._id) return;
await mutateAsync({
orgId: currentOrg._id,
email: incidentContactEmail
});
createNotification({
text: "Successfully removed incident contact",
type: "success"
});
handlePopUpClose("removeContact");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove incident contact",
type: "error"
});
}
};
const filteredContacts = contacts ? contacts.filter(({ email }) =>
email.toLocaleLowerCase().includes(searchContact)
) : [];
return (
<div>
<Input
value={searchContact}
onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Email</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} key="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>
<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}
/>
</div>
);
};
@@ -0,0 +1,2 @@
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
@@ -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} key="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";
@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useAddUserToOrg
} from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addMemberFormSchema = yup.object({
email: yup.string().email().required().label("Email").trim()
});
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>; // TODO: change to FormData
type Props = {
popUp: UsePopUpState<["addMember"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addMember"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
};
// TODO: test no-SMTP setup case
export const AddOrgMemberModal = ({
popUp,
handlePopUpToggle,
handlePopUpClose
}: Props) => {
const { currentOrg } = useOrganization();
const { data: serverDetails } = useFetchServerStatus();
const { createNotification } = useNotificationContext();
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const [completeInviteLink, setCompleteInviteLink] = useState<string | undefined>("");
const {
control,
handleSubmit,
reset
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
const { mutateAsync, isLoading } = useAddUserToOrg();
useEffect(() => {
let timer: NodeJS.Timeout;
if (isInviteLinkCopied) {
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isInviteLinkCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(completeInviteLink as string);
setInviteLinkCopied.on();
};
const onFormSubmit = async ({ email }: TAddMemberForm) => {
try {
if (!currentOrg?._id) return;
const { data } = await mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
setCompleteInviteLink(data?.completeInviteLink);
if (!data.completeInviteLink) {
createNotification({
text: "Successfully sent an invite to the user.",
type: "success"
});
}
if (serverDetails?.emailConfigured){
handlePopUpClose("addMember");
}
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to send an invite to the user",
type: "error"
});
}
}
return (
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen);
setCompleteInviteLink(undefined);
}}
>
<ModalContent
title={`Invite others to ${currentOrg?.name ?? ""}`}
subTitle={
<div>
{!completeInviteLink && <div>
An invite is specific to an email address and expires after 1 day.
<br />
For security reasons, you will need to separately add members to projects.
</div>}
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div>
}
>
{!completeInviteLink && (
<form onSubmit={handleSubmit(onFormSubmit)} >
<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={isLoading}
isDisabled={isLoading}
>
Add Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addMember")}
>
Cancel
</Button>
</div>
</form>
)}
{completeInviteLink && (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{completeInviteLink}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isInviteLinkCopied ? 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">click to copy</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,57 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
UpgradePlanModal
} from "@app/components/v2";
import { useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { AddOrgMemberModal } from "./AddOrgMemberModal";
import { OrgMembersTable } from "./OrgMembersTable";
export const OrgMembersSection = () => {
const { subscription } = useSubscription();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"upgradePlan",
] as const);
const isMoreMembersAllowed = subscription?.memberLimit ? (subscription.membersUsed < subscription.memberLimit) : true;
return (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
Organization members
</p>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreMembersAllowed) {
handlePopUpOpen("addMember");
return;
}
handlePopUpOpen("upgradePlan");
}}
>
Add Member
</Button>
</div>
<OrgMembersTable />
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="Add more members by upgrading to a higher Infisical plan."
/>
<AddOrgMemberModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
handlePopUpClose={handlePopUpClose}
/>
</div>
);
}
@@ -1,20 +1,19 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMemo, useState } from "react";
import { useRouter } from "next/router";
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmailServiceSetupModal, EmptyState,
FormControl,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
@@ -25,73 +24,63 @@ import {
Td,
Th,
THead,
Tr,
UpgradePlanModal} from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
Tr
} from "@app/components/v2";
import { useOrganization, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAddUserToOrg,
useDeleteOrgMembership,
useGetOrgUsers,
useGetUserWorkspaceMemberships,
useGetUserWsKey,
useUpdateOrgUserRole,
useUploadWsKey
} from "@app/hooks/api";
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>>
};
const addMemberFormSchema = yup.object({
email: yup.string().email().required().label("Email").trim()
});
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 = () => {
const router = useRouter();
const { user: currentUser } = useUser();
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const {data: serverDetails } = useFetchServerStatus()
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { data: serverDetails } = useFetchServerStatus()
const { data: members, isLoading: isOrgUserLoading } = useGetOrgUsers(currentOrg?._id ?? ""); // members
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(currentOrg?._id ?? "");
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
const uploadWsKey = useUploadWsKey();
const addUserToOrg = useAddUserToOrg();
const removeUserOrgMembership = useDeleteOrgMembership();
const updateOrgUserRole = useUpdateOrgUserRole();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan",
"setUpEmail"
] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
const isLoading = isOrgUserLoading || IsWsMembershipLoading;
const userId = currentUser?._id || "";
const onRemoveMember = async (membershipId: string) => {
if (!currentOrg?._id) return;
const onAddMember = async ({ email }: TAddMemberForm) => {
await onInviteMember(email);
if (serverDetails?.emailConfigured){
handlePopUpClose("addMember");
}
reset();
try {
if (!currentOrg?._id) return;
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 onRemoveOrgMemberApproved = async () => {
@@ -100,62 +89,111 @@ export const OrgMembersTable = ({
handlePopUpClose("removeMember");
};
const onInviteMember = async (email: string) => {
if (!currentOrg?._id) return;
try {
const { data } = await addUserToOrg.mutateAsync({
organizationId: currentOrg?._id,
inviteeEmail: email
});
// 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 onGrantAccess = async (targetUserId: 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: targetUserId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?._id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
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 isIamOwner = useMemo(
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
() => members ? members.find(({ user }) => userId === user?._id)?.role === "owner" : [],
[userId, members]
);
const filterdUser = useMemo(
() =>
members.filter(
members ? members.filter(
({ user, inviteEmail }) =>
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
),
) : [],
[members, searchMemberFilter]
);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isInviteLinkCopied) {
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isInviteLinkCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(completeInviteLink as string);
setInviteLinkCopied.on();
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreUserNotAllowed) {
handlePopUpOpen("upgradePlan");
} else {
handlePopUpOpen("addMember");
}
}}
>
Add Member
</Button>
</div>
<div>
<TableContainer>
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
@@ -167,7 +205,7 @@ export const OrgMembersTable = ({
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={5} key="org-members" />}
{isLoading && <TableSkeleton columns={5} />}
{!isLoading &&
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
const name = user ? `${user.firstName} ${user.lastName}` : "-";
@@ -213,7 +251,7 @@ export const OrgMembersTable = ({
<Td>
{userWs ? (
userWs?.map(({ name: wsName, _id }) => (
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
<Tag key={`user-${currentUser._id}-workspace-${_id}`} className="my-1">
{wsName}
</Tag>
))
@@ -254,74 +292,6 @@ export const OrgMembersTable = ({
<EmptyState title="No project members found" icon={faUsers} />
)}
</TableContainer>
</div>
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen);
setCompleteInviteLink(undefined)
}}
>
<ModalContent
title={`Invite others to ${orgName}`}
subTitle={
<div>
{!completeInviteLink && <div>
An invite is specific to an email address and expires after 1 day.
<br />
For security reasons, you will need to separately add members to projects.
</div>}
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div>
}
>
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} >
<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 Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addMember")}
>
Cancel
</Button>
</div>
</form>}
{
completeInviteLink &&
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{completeInviteLink}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isInviteLinkCopied ? 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">click to copy</span>
</IconButton>
</div>
}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
@@ -329,15 +299,6 @@ export const OrgMembersTable = ({
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={onRemoveOrgMemberApproved}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/>
</div>
);
};
@@ -0,0 +1 @@
export { OrgMembersSection } from "./OrgMembersSection";
@@ -1 +0,0 @@
export { OrgMembersTable } from "./OrgMembersTable";
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
@@ -16,7 +15,6 @@ const formSchema = yup.object({
type FormData = yup.InferType<typeof formSchema>;
export const OrgNameChangeSection = (): JSX.Element => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
const {
@@ -54,9 +52,11 @@ export const OrgNameChangeSection = (): JSX.Element => {
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600 "
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<p className="text-xl font-semibold text-mineshaft-100 mb-8">{t("common.display-name")}</p>
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
Organization name
</p>
<div className="mb-2 max-w-md">
<Controller
defaultValue=""
@@ -1,5 +1,8 @@
import { 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 {
faCheck,
@@ -10,21 +13,21 @@ import {
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 { yupResolver } from "@hookform/resolvers/yup";
// import * as yup from "yup";
// import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
// FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
// Select,
// SelectItem,
Table,
TableContainer,
TableSkeleton,
@@ -37,25 +40,26 @@ import {
import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import {
useCreateServiceAccount,
// useCreateServiceAccount,
useDeleteServiceAccount,
useGetServiceAccounts} from "@app/hooks/api";
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 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")
});
// 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>;
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => {
const router = useRouter();
@@ -67,9 +71,9 @@ export const OrgServiceAccountsTable = () => {
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 [accessKey] = useState("");
const [publicKey] = useState("");
const [privateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
@@ -78,7 +82,7 @@ export const OrgServiceAccountsTable = () => {
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
const createServiceAccount = useCreateServiceAccount();
// const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => {
@@ -98,32 +102,32 @@ export const OrgServiceAccountsTable = () => {
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
// const {
// control,
// handleSubmit,
// reset,
// formState: { isSubmitting }
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
// if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPublicKey(keyPair.publicKey);
setPrivateKey(keyPair.privateKey);
// const keyPair = generateKeyPair();
// setPublicKey(keyPair.publicKey);
// setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn: Number(expiresIn)
});
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
// name,
// organizationId: currentOrg?._id,
// publicKey: keyPair.publicKey,
// expiresIn: Number(expiresIn)
// });
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
}
// setStep(1);
// reset();
// }
const onRemoveServiceAccount = async () => {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
@@ -144,63 +148,67 @@ export const OrgServiceAccountsTable = () => {
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>
<div>
We are currently revising the service account mechanism. In the meantime,
please use service tokens or API key to fetch secrets via API request.
</div>
// <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 (
@@ -269,27 +277,27 @@ export const OrgServiceAccountsTable = () => {
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>
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
// reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Th>Name</Th>
@@ -345,7 +353,7 @@ export const OrgServiceAccountsTable = () => {
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
// reset();
}}
>
<ModalContent
@@ -1,5 +1,4 @@
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export { OrgMembersTable } from "./OrgMembersTable";
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
export { OrgMembersSection } from "./OrgMembersSection";
export { OrgNameChangeSection } from "./OrgNameChangeSection";
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddAPIKeyModal } from "./AddAPIKeyModal";
import { APIKeyTable } from "./APIKeyTable";
export const APIKeySection = () => {
const { t } = useTranslation();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addAPIKey"
] as const);
return (
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">
{t("settings.personal.api-keys.title")}
</p>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addAPIKey")}
>
Add API Key
</Button>
</div>
<APIKeyTable />
<AddAPIKeyModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
}
@@ -0,0 +1,112 @@
import { faKey,faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import {
useDeleteAPIKey,
useGetMyAPIKeys} from "@app/hooks/api";
export const APIKeyTable = () => {
const { createNotification } = useNotificationContext();
const { data, isLoading } = useGetMyAPIKeys();
const { mutateAsync } = useDeleteAPIKey();
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
try {
await mutateAsync(apiKeyDataId);
createNotification({
text: "Successfully deleted API key",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete API key",
type: "error"
});
}
}
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`;
return formattedDate;
}
return (
<div>
<TableContainer className="">
<Table>
<THead>
<Tr>
<Th className="flex-1">Name</Th>
<Th className="flex-1">Last active</Th>
<Th className="flex-1">Created</Th>
<Th className="flex-1">Expiration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} key="api-keys" />}
{!isLoading && data && data.length > 0 && data.map(({
_id,
name,
createdAt,
expiresAt,
lastUsed
}) => {
return (
<Tr className="h-10" key={`api-key-${_id}`}>
<Td>{name}</Td>
<Td>{formatDate(lastUsed)}</Td>
<Td>{formatDate(createdAt)}</Td>
<Td>{formatDate(expiresAt)}</Td>
<Td>
<IconButton
onClick={async () => {
await handleDeleteAPIKeyDataClick(_id);
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No API Keys on file"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
}
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useCreateAPIKey } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const expirations = [
{ label: "1 day", value: "1d" },
{ label: "7 days", value: "7d" },
{ label: "1 month", value: "1mo" },
{ label: "6 months", value: "6mo" },
{ label: "12 months", value: "12mo" }
];
const expirationMapping: { [key: string]: number } = {
"1d": 86400,
"7d": 604800,
"1mo": 2592000,
"6mo": 15552000,
"12mo": 31104000
}
const schema = yup.object({
name: yup.string().required("API Key name is required"),
expiresIn: yup.string().required("API Key expiration window is required")
}).required();
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["addAPIKey"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addAPIKey"]>, state?: boolean) => void;
};
export const AddAPIKeyModal = ({
popUp,
handlePopUpToggle
}: Props) => {
const [newAPIKey, setNewAPIKey] = useState("");
const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false);
const { createNotification } = useNotificationContext();
const { mutateAsync, isLoading } = useCreateAPIKey();
const {
control,
handleSubmit,
reset
} = useForm<FormData>({
resolver: yupResolver(schema)
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isAPIKeyCopied) {
timer = setTimeout(() => setIsAPIKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [setIsAPIKeyCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newAPIKey);
setIsAPIKeyCopied.on();
};
const onFormSubmit = async ({ name, expiresIn }: FormData) => {
try {
const { apiKey } = await mutateAsync({
name,
expiresIn: expirationMapping[expiresIn]
});
setNewAPIKey(apiKey);
createNotification({
text: "Successfully created API key",
type: "success"
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create API key",
type: "error"
});
}
}
const hasAPIKey = Boolean(newAPIKey);
return (
<Modal
isOpen={popUp?.addAPIKey?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addAPIKey", isOpen);
reset();
setNewAPIKey("");
}}
>
<ModalContent title="Create API Key">
{!hasAPIKey ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="My API Key"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue="6mo"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirations.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isLoading}
isDisabled={isLoading}
>
Add
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
) : (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newAPIKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isAPIKeyCopied ? 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">
Click to copy
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,2 @@
export { APIKeySection } from "./APIKeySection";
@@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import ListBox from "@app/components/basic/Listbox";
export const ChangeLanguageSection = () => {
const { t, i18n } = useTranslation();
const router = useRouter();
const lang = router.locale ?? "en";
const setLanguage = async (to: string) => {
router.push(router.asPath, router.asPath, { locale: to });
localStorage.setItem("lang", to);
i18n.changeLanguage(to);
};
return (
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
{t("settings.personal.change-language")}
</p>
<div className="w-28">
<ListBox
isSelected={lang}
onChange={setLanguage}
data={["en", "ko", "fr", "es"]}
text={`${t("common.language")}: `}
/>
</div>
</div>
);
}
@@ -0,0 +1 @@
export { ChangeLanguageSection } from "./ChangeLanguageSection";
@@ -0,0 +1,166 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import attemptChangePassword from "@app/components/utilities/attemptChangePassword";
import checkPassword from "@app/components/utilities/checks/checkPassword";
import {
Button,
FormControl,
Input
} from "@app/components/v2";
import { useUser } from "@app/context";
import { useGetCommonPasswords } from "@app/hooks/api";
type Errors = {
length?: string,
upperCase?: string,
lowerCase?: string,
number?: string,
specialChar?: string,
repeatedChar?: string,
};
const schema = yup.object({
oldPassword: yup.string().required("Old password is required"),
newPassword: yup.string().required("New password is required")
}).required();
export type FormData = yup.InferType<typeof schema>;
export const ChangePasswordSection = () => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const { user } = useUser();
const { data: commonPasswords } = useGetCommonPasswords();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
oldPassword: "",
newPassword: ""
},
resolver: yupResolver(schema)
});
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
try {
if (!user?.email) return;
if (!commonPasswords) return;
const errorCheck = checkPassword({
password: newPassword,
commonPasswords,
setErrors
});
if (errorCheck) return;
setIsLoading(true);
await attemptChangePassword({
email: user.email,
currentPassword: oldPassword,
newPassword
});
setIsLoading(false);
createNotification({
text: "Successfully changed password",
type: "success"
});
reset();
window.location.href = "/login";
} catch (err) {
console.error(err);
setIsLoading(false);
createNotification({
text: "Failed to change password",
type: "error"
});
}
}
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Change password
</h2>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Old password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="oldPassword"
/>
</div>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="New password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="newPassword"
/>
</div>
{Object.keys(errors).length > 0 && (
<div className="my-4 max-w-md flex flex-col items-start rounded-md bg-white/5 px-2 py-2">
<div className="mb-2 text-sm text-gray-400">{t("section.password.validate-base")}</div>
{Object.keys(errors).map((key) => {
if (errors[key as keyof Errors]) {
return (
<div className="ml-1 flex flex-row items-top justify-start" key={key}>
<div>
<FontAwesomeIcon
icon={faXmark}
className="text-md text-red ml-0.5 mr-2.5"
/>
</div>
<p className="text-gray-400 text-sm">
{errors[key as keyof Errors]}
</p>
</div>
);
}
return null;
})}
</div>
)}
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading}
>
Save
</Button>
</form>
);
}
@@ -0,0 +1 @@
export { ChangePasswordSection } from "./ChangePasswordSection";
@@ -0,0 +1,91 @@
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import {
Button,
FormControl,
Input
} from "@app/components/v2";
import { useUser } from "@app/context";
const schema = yup.object({
password: yup.string().required("Password is required")
}).required();
export type FormData = yup.InferType<typeof schema>;
export const EmergencyKitSection = () => {
const { createNotification } = useNotificationContext();
const { user } = useUser();
const { reset, control, handleSubmit } = useForm({
defaultValues: {
password: "",
},
resolver: yupResolver(schema)
});
const onFormSubmit = ({
password
}: FormData) => {
try {
if (!user?.email) return;
issueBackupKey({
email: user.email,
password,
personalName: `${user.firstName} ${user.lastName}`,
setBackupKeyError: () => {},
setBackupKeyIssued: () => {}
});
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to download emergency kit",
type: "error"
});
}
}
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
Emergency Kit
</h2>
<p className="text-gray-400 mb-8">
The kit contains information you can use to recover your account.
</p>
<div className="max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Password"
type="password"
{...field}
className="bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="password"
/>
</div>
<Button
type="submit"
colorSchema="secondary"
isLoading={false}
>
Save
</Button>
</form>
);
}
@@ -0,0 +1 @@
export { EmergencyKitSection } from "./EmergencyKitSection";
@@ -0,0 +1,7 @@
import { APIKeySection } from "../APIKeySection";
export const PersonalAPIKeyTab = () => {
return (
<APIKeySection />
);
}
@@ -0,0 +1 @@
export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab";
@@ -0,0 +1,15 @@
import { ChangePasswordSection } from "../ChangePasswordSection";
import { EmergencyKitSection } from "../EmergencyKitSection";
import { SecuritySection } from "../SecuritySection";
import { SessionsSection } from "../SessionsSection";
export const PersonalSecurityTab = () => {
return (
<div>
<SecuritySection />
<SessionsSection />
<ChangePasswordSection />
<EmergencyKitSection />
</div>
);
}
@@ -0,0 +1 @@
export { PersonalSecurityTab } from "./PersonalSecurityTab";
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import NavHeader from "@app/components/navigation/NavHeader";
import { PersonalTabGroup } from "./PersonalTabGroup";
export const PersonalSettingsPage = () => {
const { t } = useTranslation();
return (
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
<div className="max-w-screen-lg w-full">
<NavHeader pageName={t("settings.personal.title")} isProjectRelated={false} />
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">
{t("settings.personal.title")}
</p>
</div>
<PersonalTabGroup />
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { Fragment } from "react"
import { Tab } from "@headlessui/react"
import { PersonalAPIKeyTab } from "../PersonalAPIKeyTab";
import { PersonalSecurityTab } from "../PersonalSecurityTab";
const tabs = [
{ name: "Security", key: "tab-account-security" },
{ name: "API Keys", key: "tab-account-api-keys" }
];
export const PersonalTabGroup = () => {
return (
<Tab.Group>
<Tab.List className="mb-6 border-b-2 border-mineshaft-800 w-full">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 p-4 font-semibold outline-none ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<PersonalSecurityTab />
</Tab.Panel>
<Tab.Panel>
<PersonalAPIKeyTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
}
@@ -0,0 +1 @@
export { PersonalTabGroup } from "./PersonalTabGroup";
@@ -51,8 +51,8 @@ export const SecuritySection = () => {
return (
<>
<form>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
<p className="mb-4 mt-2 text-xl font-semibold">
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
Two-factor Authentication
</p>
<Checkbox
@@ -0,0 +1,44 @@
import { faBan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button
} from "@app/components/v2";
import { useRevokeMySessions } from "@app/hooks/api";
import { SessionsTable } from "./SessionsTable";
export const SessionsSection = () => {
const { mutateAsync } = useRevokeMySessions();
const onRevokeAllSessionsClick = async () => {
try {
await mutateAsync();
window.location.href = "/login";
} catch (err) {
console.error(err);
}
}
return (
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
Sessions
</h2>
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faBan} />}
onClick={onRevokeAllSessionsClick}
>
Revoke all
</Button>
</div>
<p className="text-gray-400 mb-8">
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs.
</p>
<SessionsTable />
</div>
);
}
@@ -0,0 +1,74 @@
import { faServer } from "@fortawesome/free-solid-svg-icons";
import {
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useGetMySessions } from "@app/hooks/api";
export const SessionsTable = () => {
const { data, isLoading } = useGetMySessions();
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`;
return formattedDate;
}
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Created</Th>
<Th>Last active</Th>
<Th>IP address</Th>
<Th>Device</Th>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} key="sesssions" />}
{!isLoading && data && data.length > 0 && data.map(({
_id,
createdAt,
lastUsed,
ip,
userAgent
}) => {
return (
<Tr className="h-10" key={`session-${_id}`}>
<Td>{formatDate(createdAt)}</Td>
<Td>{formatDate(lastUsed)}</Td>
<Td>{ip}</Td>
<Td>{userAgent}</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No sessions on file"
icon={faServer}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -0,0 +1 @@
export { SessionsSection } from "./SessionsSection";
@@ -0,0 +1 @@
export { PersonalSettingsPage } from "./PersonalSettingsPage";
@@ -356,7 +356,7 @@ export const ProjectSettingsPage = () => {
return (
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]">
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
<div className="relative right-5">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
<div className="my-8 flex max-w-5xl flex-row items-center justify-between text-xl">