mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 23:47:06 +00:00
Fix merge conflicts
This commit is contained in:
@@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { Button, Input, TableContainer, Tooltip } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useGetProjectFoldersBatch,
|
||||
useGetProjectSecretsByKey,
|
||||
@@ -22,6 +22,7 @@ export const DashboardEnvOverview = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
const { currentOrg } = useOrganization();
|
||||
const workspaceId = currentWorkspace?._id as string;
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
@@ -29,7 +30,7 @@ export const DashboardEnvOverview = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !workspaceId && router.isReady) {
|
||||
router.push("/noprojects");
|
||||
router.push(`/org/${currentOrg?._id}/overview`);
|
||||
}
|
||||
}, [isLoading, workspaceId, router.isReady]);
|
||||
|
||||
|
||||
@@ -3,6 +3,19 @@ import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowLeft,
|
||||
faCheck,
|
||||
faClockRotateLeft,
|
||||
@@ -10,12 +23,14 @@ import {
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
@@ -36,15 +51,19 @@ import {
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { leaveConfirmDefaultMessage } from "@app/const";
|
||||
import { useSubscription,useWorkspace } from "@app/context";
|
||||
import { useOrganization, useSubscription, useWorkspace } from "@app/context";
|
||||
import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks";
|
||||
import {
|
||||
useBatchSecretsOp,
|
||||
useCreateFolder,
|
||||
useCreateSecretImport,
|
||||
useCreateWsTag,
|
||||
useDeleteFolder,
|
||||
useDeleteSecretImport,
|
||||
useGetImportedSecrets,
|
||||
useGetProjectFolders,
|
||||
useGetProjectSecrets,
|
||||
useGetSecretImports,
|
||||
useGetSecretVersion,
|
||||
useGetSnapshotSecrets,
|
||||
useGetUserAction,
|
||||
@@ -55,7 +74,8 @@ import {
|
||||
useGetWsTags,
|
||||
usePerformSecretRollback,
|
||||
useRegisterUserAction,
|
||||
useUpdateFolder
|
||||
useUpdateFolder,
|
||||
useUpdateSecretImport
|
||||
} from "@app/hooks/api";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
@@ -71,6 +91,8 @@ import {
|
||||
import { PitDrawer } from "./components/PitDrawer";
|
||||
import { SecretDetailDrawer } from "./components/SecretDetailDrawer";
|
||||
import { SecretDropzone } from "./components/SecretDropzone";
|
||||
import { SecretImportForm } from "./components/SecretImportForm";
|
||||
import { SecretImportSection } from "./components/SecretImportSection";
|
||||
import { SecretInputRow } from "./components/SecretInputRow";
|
||||
import { SecretTableHeader } from "./components/SecretTableHeader";
|
||||
import {
|
||||
@@ -84,7 +106,7 @@ import {
|
||||
} from "./DashboardPage.utils";
|
||||
|
||||
const USER_ACTION_PUSH = "first_time_secrets_pushed";
|
||||
|
||||
type TDeleteSecretImport = { environment: string; secretPath: string };
|
||||
/*
|
||||
* Some imp aspects to consider. Here there are multiple stats changing
|
||||
* Thus ideally we need to use a context. But instead we rely on react hook form
|
||||
@@ -113,7 +135,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
"compareSecrets",
|
||||
"folderForm",
|
||||
"deleteFolder",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"addSecretImport",
|
||||
"deleteSecretImport"
|
||||
] as const);
|
||||
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
@@ -127,12 +151,15 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
const { currentOrg } = useOrganization();
|
||||
const workspaceId = currentWorkspace?._id as string;
|
||||
const selectedEnvSlug = selectedEnv?.slug || "";
|
||||
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !workspaceId && router.isReady) {
|
||||
router.push("/noprojects");
|
||||
router.push(`/org/${currentOrg?._id}/overview`);
|
||||
}
|
||||
}, [isLoading, workspaceId, router.isReady]);
|
||||
|
||||
@@ -160,7 +187,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env: selectedEnv?.slug || "",
|
||||
env: selectedEnvSlug,
|
||||
decryptFileKey: latestFileKey!,
|
||||
isPaused: Boolean(snapshotId),
|
||||
folderId
|
||||
@@ -168,7 +195,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
|
||||
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
|
||||
workspaceId: workspaceId || "",
|
||||
environment: selectedEnv?.slug || "",
|
||||
environment: selectedEnvSlug,
|
||||
parentFolderId: folderId,
|
||||
isPaused: isRollbackMode,
|
||||
sortDir
|
||||
@@ -181,7 +208,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
isFetchingNextPage
|
||||
} = useGetWorkspaceSecretSnapshots({
|
||||
workspaceId,
|
||||
environment: selectedEnv?.slug || "",
|
||||
environment: selectedEnvSlug,
|
||||
folder: folderId,
|
||||
limit: 10
|
||||
});
|
||||
@@ -192,17 +219,18 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
isFetching: isSnapshotChanging
|
||||
} = useGetSnapshotSecrets({
|
||||
snapshotId: snapshotId || "",
|
||||
env: selectedEnv?.slug || "",
|
||||
env: selectedEnvSlug,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
|
||||
workspaceId,
|
||||
selectedEnv?.slug || "",
|
||||
selectedEnvSlug,
|
||||
folderId
|
||||
);
|
||||
|
||||
const { data: wsTags } = useGetWsTags(workspaceId);
|
||||
|
||||
// mutation calls
|
||||
const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
|
||||
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
|
||||
@@ -212,6 +240,50 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
|
||||
|
||||
const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports(
|
||||
workspaceId,
|
||||
selectedEnvSlug,
|
||||
folderId
|
||||
);
|
||||
|
||||
const { data: importedSecrets } = useGetImportedSecrets({
|
||||
workspaceId,
|
||||
decryptFileKey: latestFileKey!,
|
||||
environment: selectedEnvSlug,
|
||||
folderId
|
||||
});
|
||||
|
||||
// This is for dnd-kit. As react-query state mutation async
|
||||
// This will act as a placeholder to avoid a glitching animation on dropping items
|
||||
const [items, setItems] = useState<
|
||||
Array<{ environment: string; secretPath: string; id: string }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isSecretImportCfgFetching ||
|
||||
// case in which u go to a folder and come back to fill in with cache data
|
||||
(items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching)
|
||||
) {
|
||||
setItems(
|
||||
secretImportCfg?.imports?.map((el) => ({
|
||||
...el,
|
||||
id: `${el.environment}-${el.secretPath}`
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
}, [isSecretImportCfgFetching]);
|
||||
|
||||
const { mutateAsync: createSecretImport } = useCreateSecretImport();
|
||||
const { mutate: updateSecretImportSync } = useUpdateSecretImport();
|
||||
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
const method = useForm<FormData>({
|
||||
// why any: well yup inferred ts expects other keys to defined as undefined
|
||||
defaultValues: secrets as any,
|
||||
@@ -318,14 +390,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
await performSecretRollback({
|
||||
workspaceId,
|
||||
version: snapshotSecret.version,
|
||||
environment: selectedEnv?.slug || "",
|
||||
environment: selectedEnvSlug,
|
||||
folderId
|
||||
});
|
||||
setValue("isSnapshotMode", false);
|
||||
setSnaphotId(null);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || "")
|
||||
);
|
||||
queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug));
|
||||
createNotification({
|
||||
text: "Successfully rollback secrets",
|
||||
type: "success"
|
||||
@@ -339,7 +409,10 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onAppendSecret = () => append(DEFAULT_SECRET_VALUE);
|
||||
const onAppendSecret = () => {
|
||||
setSearchFilter("");
|
||||
append(DEFAULT_SECRET_VALUE);
|
||||
};
|
||||
|
||||
const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => {
|
||||
if (isSnapshotMode) {
|
||||
@@ -360,6 +433,10 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
);
|
||||
// type check
|
||||
if (!selectedEnv?.slug) return;
|
||||
if (batchedSecret.length === 0) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await batchSecretOp({
|
||||
requests: batchedSecret,
|
||||
@@ -514,6 +591,79 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretImportCreate = async (env: string, secretPath: string) => {
|
||||
try {
|
||||
await createSecretImport({
|
||||
workspaceId,
|
||||
environment: selectedEnv?.slug || "",
|
||||
folderId,
|
||||
secretImport: {
|
||||
environment: env,
|
||||
secretPath
|
||||
}
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully create secret link"
|
||||
});
|
||||
handlePopUpClose("addSecretImport");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to create secret link",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretImportDelete = async () => {
|
||||
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
|
||||
?.data as TDeleteSecretImport;
|
||||
try {
|
||||
if (secretImportCfg?._id) {
|
||||
await deleteSecretImport({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
folderId,
|
||||
id: secretImportCfg?._id,
|
||||
secretImportEnv: importEnv,
|
||||
secretImportPath: impSecPath
|
||||
});
|
||||
handlePopUpClose("deleteSecretImport");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed secret link"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to remove secret link",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (evt: DragEndEvent) => {
|
||||
const { active, over } = evt;
|
||||
if (over?.id && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex(({ id }) => id === active.id);
|
||||
const newIndex = items.findIndex(({ id }) => id === over.id);
|
||||
const newImportOrder = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newImportOrder);
|
||||
updateSecretImportSync({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
folderId,
|
||||
id: secretImportCfg?._id || "",
|
||||
secretImports: newImportOrder.map((el) => ({
|
||||
environment: el.environment,
|
||||
secretPath: el.secretPath
|
||||
}))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// when secrets is not loading and secrets list is empty
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
|
||||
|
||||
@@ -526,7 +676,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
const isSnapshotSecretEmtpy =
|
||||
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
|
||||
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
|
||||
const isEmptyPage = isFoldersEmpty && isSecretEmpty;
|
||||
const isSecretImportEmpty = !secretImportCfg?.imports?.length;
|
||||
const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty;
|
||||
|
||||
if (isSecretsLoading || isEnvListLoading) {
|
||||
return (
|
||||
@@ -632,7 +783,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
handlePopUpOpen("secretSnapshots");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
@@ -644,54 +795,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
</Button>
|
||||
</div>
|
||||
{!isReadOnly && !isRollbackMode && (
|
||||
<>
|
||||
<div className="block lg:hidden">
|
||||
<Tooltip content="Add Folder">
|
||||
<IconButton
|
||||
ariaLabel="recovery"
|
||||
variant="outline_bg"
|
||||
onClick={() => handlePopUpOpen("folderForm")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFolderPlus} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="block lg:hidden">
|
||||
<Tooltip content="Point-in-time Recovery">
|
||||
<IconButton
|
||||
ariaLabel="recovery"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (secretContainer.current) {
|
||||
secretContainer.current.scroll({
|
||||
top: 0,
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="hidden lg:block">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => handlePopUpOpen("folderForm")}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!(isReadOnly || isRollbackMode)) {
|
||||
if (secretContainer.current) {
|
||||
secretContainer.current.scroll({
|
||||
top: 0,
|
||||
@@ -699,15 +807,49 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
});
|
||||
}
|
||||
prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
|
||||
}}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
setSearchFilter("");
|
||||
}
|
||||
}}
|
||||
className="font-semibold bg-mineshaft-600 border border-mineshaft-500 p-2 rounded-l-md text-sm text-mineshaft-300 cursor-pointer hover:bg-primary/[0.1] hover:border-primary/40 pr-4 duration-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="px-2"/>Add Secret
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
|
||||
<div className="bg-mineshaft-600 border border-mineshaft-500 p-2 rounded-r-md text-sm text-mineshaft-300 cursor-pointer hover:bg-primary/[0.1] hover:border-primary/40 duration-200">
|
||||
<FontAwesomeIcon icon={faAngleDown} className="pr-2 pl-1.5"/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="mt-1 z-[60] left-20 w-[10.8rem]">
|
||||
<div className="bg-mineshaft-800 p-1 border border-mineshaft-600 rounded-md">
|
||||
<div className="w-full pb-1">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => handlePopUpOpen("folderForm")}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
||||
onClick={() => handlePopUpOpen("addSecretImport")}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{isRollbackMode && (
|
||||
<Button
|
||||
@@ -742,49 +884,67 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
ref={secretContainer}
|
||||
>
|
||||
{!isEmptyPage && (
|
||||
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
|
||||
<table className="secret-table relative">
|
||||
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
||||
<tbody className="max-h-96 overflow-y-auto">
|
||||
<FolderSection
|
||||
onFolderOpen={handleFolderOpen}
|
||||
onFolderUpdate={(id, name) => handlePopUpOpen("folderForm", { id, name })}
|
||||
onFolderDelete={(id, name) => handlePopUpOpen("deleteFolder", { id, name })}
|
||||
folders={folderList}
|
||||
search={searchFilter}
|
||||
/>
|
||||
{fields.map(({ id, _id }, index) => (
|
||||
<SecretInputRow
|
||||
key={id}
|
||||
isReadOnly={isReadOnly}
|
||||
isRollbackMode={isRollbackMode}
|
||||
isAddOnly={isAddOnly}
|
||||
index={index}
|
||||
searchTerm={searchFilter}
|
||||
onSecretDelete={onSecretDelete}
|
||||
onRowExpand={() => onDrawerOpen({ id: _id as string, index })}
|
||||
isSecretValueHidden={isSecretValueHidden}
|
||||
wsTags={wsTags}
|
||||
onCreateTagOpen={() => handlePopUpOpen("addTag")}
|
||||
<DndContext
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
|
||||
<table className="secret-table relative">
|
||||
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
||||
<tbody className="max-h-96 overflow-y-auto">
|
||||
<SecretImportSection
|
||||
onSecretImportDelete={(impSecEnv, impSecPath) =>
|
||||
handlePopUpOpen("deleteSecretImport", {
|
||||
environment: impSecEnv,
|
||||
secretPath: impSecPath
|
||||
})
|
||||
}
|
||||
secrets={secrets?.secrets}
|
||||
importedSecrets={importedSecrets}
|
||||
items={items}
|
||||
/>
|
||||
))}
|
||||
{!isReadOnly && !isRollbackMode && (
|
||||
<tr>
|
||||
<td colSpan={3} className="hover:bg-mineshaft-700">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
|
||||
onClick={onAppendSecret}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
<span className="ml-2 w-20">Add Secret</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
<FolderSection
|
||||
onFolderOpen={handleFolderOpen}
|
||||
onFolderUpdate={(id, name) => handlePopUpOpen("folderForm", { id, name })}
|
||||
onFolderDelete={(id, name) => handlePopUpOpen("deleteFolder", { id, name })}
|
||||
folders={folderList}
|
||||
search={searchFilter}
|
||||
/>
|
||||
{fields.map(({ id, _id }, index) => (
|
||||
<SecretInputRow
|
||||
key={id}
|
||||
isReadOnly={isReadOnly}
|
||||
isRollbackMode={isRollbackMode}
|
||||
isAddOnly={isAddOnly}
|
||||
index={index}
|
||||
searchTerm={searchFilter}
|
||||
onSecretDelete={onSecretDelete}
|
||||
onRowExpand={() => onDrawerOpen({ id: _id as string, index })}
|
||||
isSecretValueHidden={isSecretValueHidden}
|
||||
wsTags={wsTags}
|
||||
onCreateTagOpen={() => handlePopUpOpen("addTag")}
|
||||
/>
|
||||
))}
|
||||
{!isReadOnly && !isRollbackMode && (
|
||||
<tr>
|
||||
<td colSpan={3} className="hover:bg-mineshaft-700">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
|
||||
onClick={onAppendSecret}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
<span className="ml-2 w-20">Add Secret</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</DndContext>
|
||||
)}
|
||||
<PitDrawer
|
||||
isDrawerOpen={popUp?.secretSnapshots?.isOpen}
|
||||
@@ -872,6 +1032,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={popUp?.addSecretImport?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Add Secret Link"
|
||||
subTitle="To inherit secrets from another environment or folder"
|
||||
>
|
||||
<SecretImportForm
|
||||
environments={currentWorkspace?.environments}
|
||||
onCreate={handleSecretImportCreate}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
|
||||
@@ -879,6 +1053,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||
onDeleteApproved={handleFolderDelete}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecretImport.isOpen}
|
||||
deleteKey="unlink"
|
||||
title="Do you want to remove this secret import?"
|
||||
subTitle={`This will unlink secrets from environment ${
|
||||
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
|
||||
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
|
||||
onDeleteApproved={handleSecretImportDelete}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp?.compareSecrets?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
|
||||
@@ -900,7 +1084,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={subscription.slug === null ? "You can perform point-in-time recovery under an Enterprise license" : "You can perform point-in-time recovery if you switch to Infisical's Team plan"}
|
||||
text={
|
||||
subscription.slug === null
|
||||
? "You can perform point-in-time recovery under an Enterprise license"
|
||||
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-i
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useSyntaxHighlight } from "@app/hooks";
|
||||
|
||||
import { useToggle } from "~/hooks/useToggle";
|
||||
import { useToggle } from "@app/hooks/useToggle";
|
||||
|
||||
type Props = {
|
||||
secrets: any[] | undefined;
|
||||
@@ -71,6 +70,8 @@ const DashboardInput = ({
|
||||
ref={ref}
|
||||
className={`absolute top-1.5 left-3.5 z-10 overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||
isOverridden && "text-primary-300"
|
||||
} ${
|
||||
(value || "") === "" && "text-mineshaft-400"
|
||||
}`}
|
||||
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const FolderSection = ({
|
||||
ariaLabel="delete"
|
||||
onClick={() => handleFolderDelete(id, name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
onCreate: (environment: string, secretPath: string) => Promise<void>;
|
||||
environments?: Array<{ slug: string; name: string }>;
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
environment: yup.string().required().label("Environment").trim(),
|
||||
secretPath: yup
|
||||
.string()
|
||||
.required()
|
||||
.label("Secret Path")
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
});
|
||||
|
||||
type TFormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TFormData>({
|
||||
resolver: yupResolver(formSchema)
|
||||
});
|
||||
|
||||
const onSubmit = async ({ environment, secretPath }: TFormData) => {
|
||||
await onCreate(environment, secretPath);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretImportForm } from "./SecretImportForm";
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faKey,
|
||||
faUpDown,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { EmptyState, IconButton, TableContainer, Tooltip } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks/useToggle";
|
||||
|
||||
type Props = {
|
||||
onDelete: (environment: string, secretPath: string) => void;
|
||||
importedEnv: string;
|
||||
importedSecPath: string;
|
||||
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||
};
|
||||
|
||||
// to show the environment and folder icon
|
||||
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||
{secretPath && (
|
||||
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||
<FontAwesomeIcon icon={faFolder} size="lg" className="text-primary-700" />
|
||||
<span>{secretPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SecretImportItem = ({
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
onDelete,
|
||||
importedSecrets = []
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: `${importedEnv}-${importedSecPath}`
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<FontAwesomeIcon icon={faFileImport} className="text-primary-700" />
|
||||
</td>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||
>
|
||||
<div className="flex-grow p-2">
|
||||
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Change Order" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="expand"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpDown} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onDelete(importedEnv, importedSecPath);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{isExpanded && !isDragging && (
|
||||
<td colSpan={3}>
|
||||
<div className="rounded-md bg-bunker-700 p-4 pb-6">
|
||||
<div className="mb-2 text-lg font-medium">Secrets Imported</div>
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importedSecrets?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{importedSecrets.map(({ key, value, overriden }, index) => (
|
||||
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{value}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
import { SecretImportItem } from "./SecretImportItem";
|
||||
|
||||
type TImportedSecrets = Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: DecryptedSecret[];
|
||||
}>;
|
||||
|
||||
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||
|
||||
export const computeImportedSecretRows = (
|
||||
importedSecEnv: string,
|
||||
importedSecPath: string,
|
||||
importSecrets: TImportedSecrets = [],
|
||||
secrets: DecryptedSecret[] = [],
|
||||
environments: { name: string; slug: string }[] = []
|
||||
) => {
|
||||
const importedSecIndex = importSecrets.findIndex(
|
||||
({ secretPath, environment }) =>
|
||||
secretPath === importedSecPath && importedSecEnv === environment
|
||||
);
|
||||
if (importedSecIndex === -1) return [];
|
||||
|
||||
const importedSec = importSecrets[importedSecIndex];
|
||||
|
||||
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||
const envSlug2Name: Record<string, string> = {};
|
||||
environments.forEach((el) => {
|
||||
envSlug2Name[el.slug] = el.name;
|
||||
});
|
||||
|
||||
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||
importSecrets[i].secrets.forEach((el) => {
|
||||
overridenSec[el.key] = {
|
||||
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||
secretPath: importSecrets[i].secretPath
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
secrets.forEach((el) => {
|
||||
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||
});
|
||||
|
||||
return importedSec.secrets.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
overriden: overridenSec?.[key]
|
||||
}));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
importedSecrets?: TImportedSecrets;
|
||||
onSecretImportDelete: (env: string, secPath: string) => void;
|
||||
items: { id: string; environment: string; secretPath: string }[];
|
||||
};
|
||||
|
||||
export const SecretImportSection = ({
|
||||
secrets = [],
|
||||
importedSecrets = [],
|
||||
onSecretImportDelete,
|
||||
items = []
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
return (
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => (
|
||||
<SecretImportItem
|
||||
key={id}
|
||||
importedEnv={importSecEnv}
|
||||
importedSecrets={computeImportedSecretRows(
|
||||
importSecEnv,
|
||||
impSecPath,
|
||||
importedSecrets,
|
||||
secrets,
|
||||
environments
|
||||
)}
|
||||
onDelete={onSecretImportDelete}
|
||||
importedSecPath={impSecPath}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretImportSection } from "./SecretImportSection";
|
||||
@@ -94,6 +94,8 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
|
||||
ref={ref}
|
||||
className={`absolute top-1.5 left-3.5 z-10 w-full overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||
isOverridden && "text-primary-300"
|
||||
} ${
|
||||
(value || "") === "" && "text-mineshaft-400"
|
||||
}`}
|
||||
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { memo, useRef } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import {
|
||||
faCheck,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faPlus,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
||||
@@ -95,6 +98,16 @@ export const SecretInputRow = memo(
|
||||
name: `secrets.${index}.key`,
|
||||
disabled: isKeySubDisabled.current
|
||||
});
|
||||
const secValue = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.value`,
|
||||
disabled: isKeySubDisabled.current
|
||||
});
|
||||
const secValueOverride = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.valueOverride`,
|
||||
disabled: isKeySubDisabled.current
|
||||
})
|
||||
const secId = useWatch({ control, name: `secrets.${index}._id` });
|
||||
|
||||
const tags = useWatch({ control, name: `secrets.${index}.tags`, defaultValue: [] }) || [];
|
||||
@@ -103,6 +116,21 @@ export const SecretInputRow = memo(
|
||||
{}
|
||||
);
|
||||
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isInviteLinkCopied) {
|
||||
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInviteLinkCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText((secValueOverride || secValue) as string);
|
||||
setInviteLinkCopied.on();
|
||||
};
|
||||
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
@@ -223,6 +251,19 @@ export const SecretInputRow = memo(
|
||||
{slug}
|
||||
</Tag>
|
||||
))}
|
||||
<div className="w-0 group-hover:w-6 overflow-hidden">
|
||||
<Tooltip content="Copy value">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
className="py-[0.42rem]"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="duration-0 ml-1 overflow-hidden">
|
||||
<Popover>
|
||||
@@ -366,7 +407,7 @@ export const SecretInputRow = memo(
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete">
|
||||
<IconButton
|
||||
size="md"
|
||||
size="lg"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
|
||||
@@ -35,7 +35,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
// generate CSRF token for OAuth2 code-token exchange integrations
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
|
||||
let link = "";
|
||||
switch (integrationOption.slug) {
|
||||
case "azure-key-vault":
|
||||
@@ -95,6 +95,18 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
case "cloudflare-pages":
|
||||
link = `${window.location.origin}/integrations/cloudflare-pages/authorize`;
|
||||
break;
|
||||
case "bitbucket":
|
||||
link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
|
||||
break;
|
||||
case "codefresh":
|
||||
link = `${window.location.origin}/integrations/codefresh/authorize`;
|
||||
break;
|
||||
case "digital-ocean-app-platform":
|
||||
link = `${window.location.origin}/integrations/digital-ocean-app-platform/authorize`;
|
||||
break;
|
||||
case "cloud-66":
|
||||
link = `${window.location.origin}/integrations/cloud-66/authorize`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
@@ -49,6 +48,7 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
|
||||
|
||||
const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
|
||||
useGetCloudIntegrations();
|
||||
|
||||
const { data: integrationAuths, isLoading: isIntegrationAuthLoading } =
|
||||
useGetWorkspaceAuthorizations(
|
||||
workspaceId,
|
||||
@@ -177,10 +177,7 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
|
||||
<div className="ml-4">
|
||||
<NavHeader pageName={t("integrations.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="container mx-auto max-w-7xl pb-12 text-white">
|
||||
<IntegrationsSection
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
|
||||
+7
-17
@@ -31,22 +31,21 @@ export const CloudIntegrationSection = ({
|
||||
|
||||
const isEmpty = !isLoading && !cloudIntegrations?.length;
|
||||
|
||||
const sortedCloudIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="mx-6 grid grid-flow-dense gap-4"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 1fr))" }}
|
||||
>
|
||||
<div className="mx-6 grid grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
|
||||
))}
|
||||
{!isLoading &&
|
||||
cloudIntegrations?.map((cloudIntegration) => (
|
||||
sortedCloudIntegrations?.map((cloudIntegration) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
@@ -68,18 +67,9 @@ export const CloudIntegrationSection = ({
|
||||
width={70}
|
||||
alt="integration logo"
|
||||
/>
|
||||
{cloudIntegration.name.split(" ").length > 2 ? (
|
||||
<div className="ml-4 max-w-xs text-3xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
<div>{cloudIntegration.name.split(" ")[0]}</div>
|
||||
<div className="text-base">
|
||||
{cloudIntegration.name.split(" ")[1]} {cloudIntegration.name.split(" ")[2]}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
|
||||
<div className="absolute top-0 right-0 z-40 h-full">
|
||||
|
||||
+3
-1
@@ -12,6 +12,8 @@ type Props = {
|
||||
export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sortedFrameworks = frameworks.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-4 mt-12 mb-4 flex flex-col items-start justify-between px-2 text-xl">
|
||||
@@ -22,7 +24,7 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
className="mx-6 mt-4 grid grid-flow-dense gap-3"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
{frameworks.map((framework) => (
|
||||
{sortedFrameworks.map((framework) => (
|
||||
<a
|
||||
key={`framework-integration-${framework.slug}`}
|
||||
href={framework.docsLink}
|
||||
|
||||
+8
-6
@@ -32,7 +32,6 @@ export const IntegrationsSection = ({
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteConfirmation"
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
@@ -45,10 +44,12 @@ export const IntegrationsSection = ({
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !integrations.length && (
|
||||
<EmptyState
|
||||
className="mx-6 rounded-md border border-mineshaft-700 pt-8 pb-4"
|
||||
title="No integrations found. Click on one of the below providers to sync secrets."
|
||||
/>
|
||||
<div className="mx-6">
|
||||
<EmptyState
|
||||
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
|
||||
title="No integrations found. Click on one of the below providers to sync secrets."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="flex flex-col space-y-4 p-6 pt-0">
|
||||
@@ -104,7 +105,8 @@ export const IntegrationsSection = ({
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab") && (
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "bitbucket") && (
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios"
|
||||
|
||||
import { fetchUserDetails } from "@app/hooks/api/users/queries";
|
||||
import getOrganizations from "@app/pages/api/organization/getOrgs";
|
||||
import { getAuthToken, isLoggedIn } from "@app/reactQuery";
|
||||
|
||||
import {
|
||||
InitialStep,
|
||||
MFAStep,
|
||||
SAMLSSOStep
|
||||
} from "./components";
|
||||
|
||||
export const Login = () => {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(akhilmhdh): workspace will be controlled by a workspace context
|
||||
const redirectToDashboard = async () => {
|
||||
try {
|
||||
const userOrgs = await getOrganizations();
|
||||
// userWorkspace = userWorkspaces[0] && userWorkspaces[0]._id;
|
||||
const userOrg = userOrgs[0] && userOrgs[0]._id;
|
||||
|
||||
// user details
|
||||
const userDetails = await fetchUserDetails()
|
||||
// send details back to client
|
||||
|
||||
if (queryParams && queryParams.get("callback_port")) {
|
||||
const callbackPort = queryParams.get("callback_port")
|
||||
|
||||
// send post request to cli with details
|
||||
const cliUrl = `http://localhost:${callbackPort}`
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl, { email: userDetails.email, privateKey: localStorage.getItem("PRIVATE_KEY"), JTWToken: getAuthToken() })
|
||||
}
|
||||
router.push(`/org/${userOrg}/overview`);
|
||||
} catch (error) {
|
||||
console.log("Error - Not logged in yet");
|
||||
}
|
||||
};
|
||||
if (isLoggedIn()) {
|
||||
redirectToDashboard();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<InitialStep
|
||||
setStep={setStep}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<MFAStep
|
||||
email={email}
|
||||
password={password}
|
||||
providerAuthToken={undefined}
|
||||
callbackPort={queryParams.get("callback_port")}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<SAMLSSOStep setStep={setStep} />
|
||||
);
|
||||
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import jwt_decode from "jwt-decode";
|
||||
|
||||
import {
|
||||
MFAStep,
|
||||
PasswordStep
|
||||
} from "./components";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
}
|
||||
|
||||
export const LoginSSO = ({ providerAuthToken }: Props) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const {
|
||||
email,
|
||||
isUserCompleted,
|
||||
callbackPort
|
||||
} = jwt_decode(providerAuthToken) as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserCompleted) {
|
||||
setStep(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<div />
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<PasswordStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
callbackPort={callbackPort}
|
||||
email={email}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setStep={setStep}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<MFAStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
callbackPort={callbackPort}
|
||||
email={email}
|
||||
password={password}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import axios from "axios"
|
||||
|
||||
import Error from "@app/components/basic/Error";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import getOrganizations from "@app/pages/api/organization/getOrgs";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
password: string;
|
||||
setPassword: (email: string) => void;
|
||||
}
|
||||
|
||||
export const InitialStep = ({
|
||||
setStep,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState(false);
|
||||
const [loginEmailChosen, setLoginEmailChosen] = useState(false);
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (!email || !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (queryParams && queryParams.get("callback_port")) {
|
||||
const callbackPort = queryParams.get("callback_port")
|
||||
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
|
||||
if (isCliLoginSuccessful.mfaEnabled) {
|
||||
// case: login requires MFA step
|
||||
setStep(1);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// case: login was successful
|
||||
const cliUrl = `http://localhost:${callbackPort}`
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create()
|
||||
const cliResp = await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse })
|
||||
console.log(cliResp)
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
|
||||
// on success, router.push to cli Login Successful page
|
||||
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLogin({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (isLoginSuccessful && isLoginSuccessful.success) {
|
||||
// case: login was successful
|
||||
|
||||
if (isLoginSuccessful.mfaEnabled) {
|
||||
// case: login requires MFA step
|
||||
setStep(1);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const userOrgs = await getOrganizations();
|
||||
const userOrg = userOrgs[0] && userOrgs[0]._id;
|
||||
|
||||
// case: login does not require MFA step
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
router.push(`/org/${userOrg}/overview`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (err) {
|
||||
setLoginError(true);
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin} className='flex flex-col mx-auto w-full justify-center items-center'>
|
||||
<h1 className='text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8' >Login to Infisical</h1>
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant={loginEmailChosen ? "outline_bg" : "solid"}
|
||||
onClick={() => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
window.open(`/api/v1/sso/redirect/google${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
window.close();
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faGoogle} className="mr-1" />}
|
||||
className="h-12 w-full mx-0"
|
||||
>
|
||||
{t("login.continue-with-google")}
|
||||
</Button>
|
||||
</div>
|
||||
{loginEmailChosen && <>
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[20rem] flex flex-row items-center my-4 py-2'>
|
||||
<div className='w-full border-t border-mineshaft-500' />
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md'>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
placeholder="Enter your email..."
|
||||
isRequired
|
||||
autoComplete="username"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="h-12 select:-webkit-autofill:focus"
|
||||
/>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className='h-12'
|
||||
colorSchema="primary"
|
||||
variant="solid"
|
||||
isLoading={isLoading}
|
||||
> Continue with Email </Button>
|
||||
</div>
|
||||
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[20rem] flex flex-row items-center mt-4 py-2'>
|
||||
<div className='w-full border-t border-mineshaft-500' />
|
||||
</div></>}
|
||||
{!loginEmailChosen && <div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoginEmailChosen(true);
|
||||
}}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className='h-12'
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
> Continue with Email </Button>
|
||||
</div>}
|
||||
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
setStep(2);
|
||||
}}
|
||||
isFullWidth
|
||||
className="h-12 w-full mx-0"
|
||||
>
|
||||
Continue with SAML SSO
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6 text-bunker-400 text-sm flex flex-row">
|
||||
<span className="mr-1">Don't have an acount yet?</span>
|
||||
<Link href="/signup">
|
||||
<span className='hover:underline hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>{t("login.create-account")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-bunker-400 text-sm flex flex-row">
|
||||
<span className="mr-1">Forgot password?</span>
|
||||
<Link href="/verify-email">
|
||||
<span className='hover:underline hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>Recover your account</span>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from "react";
|
||||
import ReactCodeInput from "react-code-input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios"
|
||||
|
||||
import Error from "@app/components/basic/Error"; // which to notification
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import attemptCliLoginMfa from "@app/components/utilities/attemptCliLoginMfa"
|
||||
import attemptLoginMfa from "@app/components/utilities/attemptLoginMfa";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useSendMfaToken } from "@app/hooks/api/auth";
|
||||
import getOrganizations from "@app/pages/api/organization/getOrgs";
|
||||
|
||||
// The style for the verification code input
|
||||
const props = {
|
||||
inputStyle: {
|
||||
fontFamily: "monospace",
|
||||
margin: "4px",
|
||||
MozAppearance: "textfield",
|
||||
width: "48px",
|
||||
borderRadius: "5px",
|
||||
fontSize: "24px",
|
||||
height: "48px",
|
||||
paddingLeft: "7",
|
||||
backgroundColor: "#0d1117",
|
||||
color: "white",
|
||||
border: "1px solid #2d2f33",
|
||||
textAlign: "center",
|
||||
outlineColor: "#8ca542",
|
||||
borderColor: "#2d2f33"
|
||||
}
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
password: string;
|
||||
providerAuthToken?: string;
|
||||
callbackPort?: string | null;
|
||||
}
|
||||
|
||||
interface VerifyMfaTokenError {
|
||||
response: {
|
||||
data: {
|
||||
context: {
|
||||
code: string;
|
||||
triesLeft: number;
|
||||
};
|
||||
};
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const MFAStep = ({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
callbackPort
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingResend, setIsLoadingResend] = useState(false);
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sendMfaToken = useSendMfaToken();
|
||||
|
||||
const handleLoginMfa = async () => {
|
||||
try {
|
||||
if (mfaCode.length !== 6) {
|
||||
createNotification({
|
||||
text: "Please enter a 6-digit MFA code and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (callbackPort){
|
||||
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLoginMfa({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
mfaToken: mfaCode
|
||||
})
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success){
|
||||
// case: login was successful
|
||||
const cliUrl = `http://localhost:${callbackPort}`
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl,{...isCliLoginSuccessful.loginResponse,email})
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLoginMfa({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
mfaToken: mfaCode
|
||||
});
|
||||
|
||||
if (isLoginSuccessful) {
|
||||
setIsLoading(false);
|
||||
const userOrgs = await getOrganizations();
|
||||
const userOrg = userOrgs[0] && userOrgs[0]._id;
|
||||
|
||||
// case: login does not require MFA step
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
router.push(`/org/${userOrg}/overview`);
|
||||
} else {
|
||||
createNotification({
|
||||
text: "Failed to log in",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const error = err as VerifyMfaTokenError;
|
||||
createNotification({
|
||||
text: "Failed to log in",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
if (error?.response?.status === 500) {
|
||||
window.location.reload();
|
||||
} else if (error?.response?.data?.context?.triesLeft) {
|
||||
setTriesLeft(error?.response?.data?.context?.triesLeft);
|
||||
if (error.response.data.context.triesLeft === 0) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendMfaCode = async () => {
|
||||
try {
|
||||
setIsLoadingResend(true);
|
||||
await sendMfaToken.mutateAsync({ email });
|
||||
setIsLoadingResend(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsLoadingResend(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mx-auto w-max md:px-8 pb-4 pt-4 md:mb-16">
|
||||
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
|
||||
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email} </p>
|
||||
<div className="hidden md:block w-max min-w-[20rem] mx-auto">
|
||||
<ReactCodeInput
|
||||
name=""
|
||||
inputMode="tel"
|
||||
type="text"
|
||||
fields={6}
|
||||
onChange={setMfaCode}
|
||||
{...props}
|
||||
className="mt-6 mb-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="block md:hidden w-max mt-4 mx-auto">
|
||||
<ReactCodeInput
|
||||
name=""
|
||||
inputMode="tel"
|
||||
type="text"
|
||||
fields={6}
|
||||
onChange={setMfaCode}
|
||||
{...props}
|
||||
className="mt-2 mb-2"
|
||||
/>
|
||||
</div>
|
||||
{typeof triesLeft === "number" && (
|
||||
<Error text={`${t("mfa.step2-code-error")} ${triesLeft}`} />
|
||||
)}
|
||||
<div className="flex flex-col mt-6 items-center justify-center lg:w-[19%] w-1/4 min-w-[20rem] mt-2 max-w-xs md:max-w-md mx-auto text-sm text-center md:text-left">
|
||||
<div className="text-l py-1 text-lg w-full">
|
||||
<Button
|
||||
onClick={() => handleLoginMfa()}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className='h-14'
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={isLoading}
|
||||
> {String(t("mfa.verify"))} </Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center w-full max-h-24 max-w-md mx-auto pt-2">
|
||||
<div className="flex flex-row items-baseline gap-1 text-sm">
|
||||
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
|
||||
<div className="mt-2 text-bunker-400 text-md flex flex-row">
|
||||
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
|
||||
<span className='hover:underline hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>
|
||||
{isLoadingResend
|
||||
? t("signup.step2-resend-progress")
|
||||
: t("signup.step2-resend-submit")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-bunker-400 pb-2">{t("signup.step2-spam-alert")}</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MFAStep } from "./MFAStep";
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router"
|
||||
import axios from "axios"
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import getOrganizations from "@app/pages/api/organization/getOrgs";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
callbackPort?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
setStep: (step: number) => void;
|
||||
}
|
||||
|
||||
export const PasswordStep = ({
|
||||
providerAuthToken,
|
||||
callbackPort,
|
||||
email,
|
||||
password,
|
||||
setPassword,
|
||||
setStep
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (callbackPort) {
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken
|
||||
})
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
|
||||
if (isCliLoginSuccessful.mfaEnabled) {
|
||||
// case: login requires MFA step
|
||||
setStep(2);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// case: login was successful
|
||||
const cliUrl = `http://localhost:${callbackPort}`
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create()
|
||||
await instance.post(cliUrl, { ...isCliLoginSuccessful.loginResponse })
|
||||
|
||||
// cli page
|
||||
router.push("/cli-redirect");
|
||||
|
||||
// on success, router.push to cli Login Successful page
|
||||
}
|
||||
} else {
|
||||
const loginAttempt = await attemptLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
});
|
||||
|
||||
if (loginAttempt && loginAttempt.success) {
|
||||
// case: login was successful
|
||||
|
||||
if (loginAttempt.mfaEnabled) {
|
||||
// TODO: deal with MFA
|
||||
// case: login requires MFA step
|
||||
setIsLoading(false);
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
// case: login does not require MFA step
|
||||
const userOrgs = await getOrganizations();
|
||||
const userOrg = userOrgs[0]._id;
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
router.push(`/org/${userOrg?._id}/overview`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your master password and try again.",
|
||||
type: "error"
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="h-full mx-auto w-full max-w-md px-6 pt-8"
|
||||
>
|
||||
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8">
|
||||
What’s your Infisical Password?
|
||||
</p>
|
||||
<div className="relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 w-full mx-auto flex items-center justify-center min-w-[22rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={async () => handleLogin()}
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-bunker-400 text-xs flex flex-col items-center w-max mx-auto mt-4">
|
||||
<span className='duration-200 max-w-sm text-center px-4'>
|
||||
Infisical Master Password serves as a decryption mechanism so that even Google is not able to access your secrets.
|
||||
</span>
|
||||
<Link href="/verify-email">
|
||||
<span className='hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer'>{t("login.forgot-password")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/login");
|
||||
}}
|
||||
type="button"
|
||||
className="text-bunker-400 text-xs hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
}
|
||||
|
||||
export const SAMLSSOStep = ({
|
||||
setStep
|
||||
}: Props) => {
|
||||
const [ssoIdentifier, setSSOIdentifier] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 flex w-max justify-center text-xl font-medium text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200 text-center mb-8">
|
||||
What's your SSO Identifier?
|
||||
</p>
|
||||
<div className="relative flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<div className="flex items-center justify-center w-full rounded-lg max-h-24 md:max-h-28">
|
||||
<Input
|
||||
value={ssoIdentifier}
|
||||
onChange={(e) => setSSOIdentifier(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your SSO identifier..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='lg:w-1/6 w-1/4 w-full mx-auto flex items-center justify-center min-w-[20rem] md:min-w-[22rem] text-center rounded-md mt-4'>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
window.open(`/api/v1/sso/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
window.close();
|
||||
}}
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="text-bunker-300 text-sm hover:underline mt-2 hover:underline-offset-4 hover:decoration-primary-700 hover:text-bunker-200 duration-200 cursor-pointer"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SAMLSSOStep } from "./SAMLSSOStep";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
export { MFAStep } from "./MFAStep";
|
||||
export { SAMLSSOStep } from "./SAMLSSOStep";
|
||||
|
||||
// SSO-specific step
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Login } from "./Login";
|
||||
export { LoginSSO } from "./LoginSSO";
|
||||
@@ -0,0 +1,238 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddIncidentContact,
|
||||
useAddUserToOrg,
|
||||
useDeleteIncidentContact,
|
||||
useDeleteOrgMembership,
|
||||
useGetOrgIncidentContact,
|
||||
useGetOrgUsers,
|
||||
useGetUserWorkspaceMemberships,
|
||||
useGetUserWsKey,
|
||||
useRenameOrg,
|
||||
useUpdateOrgUserRole,
|
||||
useUploadWsKey
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import {
|
||||
OrgIncidentContactsTable,
|
||||
OrgMembersTable,
|
||||
OrgNameChangeSection,
|
||||
OrgServiceAccountsTable
|
||||
} from "./components";
|
||||
|
||||
export const MembersPage = () => {
|
||||
const host = window.location.origin;
|
||||
const router = useRouter();
|
||||
const { action } = router.query;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { user } = useUser();
|
||||
const { subscription } = useSubscription();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const orgId = currentOrg?._id || "";
|
||||
|
||||
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
|
||||
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
|
||||
useGetUserWorkspaceMemberships(orgId);
|
||||
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
|
||||
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
|
||||
useGetOrgIncidentContact(orgId);
|
||||
|
||||
const renameOrg = useRenameOrg();
|
||||
const removeUserOrgMembership = useDeleteOrgMembership();
|
||||
const addUserToOrg = useAddUserToOrg();
|
||||
const updateOrgUserRole = useUpdateOrgUserRole();
|
||||
const uploadWsKey = useUploadWsKey();
|
||||
const addIncidentContact = useAddIncidentContact();
|
||||
const removeIncidentContact = useDeleteIncidentContact();
|
||||
|
||||
const [completeInviteLink, setcompleteInviteLink] = useState<string | undefined>("");
|
||||
|
||||
const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
|
||||
|
||||
const onRenameOrg = async (name: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
|
||||
createNotification({
|
||||
text: "Successfully renamed organization",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to rename organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveUserOrgMembership = async (membershipId: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
|
||||
createNotification({
|
||||
text: "Successfully removed user from org",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the organization",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
const onAddUserToOrg = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
const { data } = await addUserToOrg.mutateAsync({
|
||||
organizationId: currentOrg?._id,
|
||||
inviteeEmail: email
|
||||
});
|
||||
setcompleteInviteLink(data?.completeInviteLink);
|
||||
|
||||
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
|
||||
if (!data.completeInviteLink) {
|
||||
createNotification({
|
||||
text: "Successfully invited user to the organization.",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to invite user to org",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateOrgUserRole = async (membershipId: string, role: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
|
||||
createNotification({
|
||||
text: "Successfully updated user role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update user role",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onGrantUserAccess = async (userId: string, publicKey: string) => {
|
||||
try {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
if (!PRIVATE_KEY || !wsKey) return;
|
||||
|
||||
// assymmetrically decrypt symmetric key with local private key
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: wsKey.encryptedKey,
|
||||
nonce: wsKey.nonce,
|
||||
publicKey: wsKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const { ciphertext, nonce } = encryptAssymmetric({
|
||||
plaintext: key,
|
||||
publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
await uploadWsKey.mutateAsync({
|
||||
userId,
|
||||
nonce,
|
||||
encryptedKey: ciphertext,
|
||||
workspaceId: currentWorkspace?._id || ""
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to grant access to user",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddIncidentContact = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await addIncidentContact.mutateAsync({ orgId, email });
|
||||
createNotification({
|
||||
text: "Successfully added incident contact",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to add incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveIncidentContact = async (email: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
try {
|
||||
await removeIncidentContact.mutateAsync({ orgId, email });
|
||||
createNotification({
|
||||
text: "Successfully removed incident contact",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove incident contact",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
|
||||
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
|
||||
{t("section.members.org-members")}
|
||||
</p>
|
||||
<OrgMembersTable
|
||||
isLoading={isOrgUserLoading || IsWsMembershipLoading}
|
||||
isMoreUserNotAllowed={isMoreUsersNotAllowed}
|
||||
orgName={currentOrg?.name || ""}
|
||||
members={orgUsers}
|
||||
workspaceMemberships={workspaceMemberships}
|
||||
onInviteMember={onAddUserToOrg}
|
||||
userId={user?._id || ""}
|
||||
onRemoveMember={onRemoveUserOrgMembership}
|
||||
onRoleChange={onUpdateOrgUserRole}
|
||||
onGrantAccess={onGrantUserAccess}
|
||||
completeInviteLink={completeInviteLink}
|
||||
setCompleteInviteLink={setcompleteInviteLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||
@@ -0,0 +1,366 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/router";
|
||||
import { faCheck, faCopy, 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 {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmailServiceSetupModal, EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization , useWorkspace } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetSSOConfig } 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) => {
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? "");
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const {data: serverDetails } = useFetchServerStatus()
|
||||
const { workspaces } = useWorkspace();
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"removeMember",
|
||||
"upgradePlan",
|
||||
"setUpEmail"
|
||||
] as const);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.action === "invite") {
|
||||
handlePopUpOpen("addMember");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||
|
||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
||||
await onInviteMember(email);
|
||||
if (serverDetails?.emailConfigured){
|
||||
handlePopUpClose("addMember");
|
||||
}
|
||||
|
||||
reset();
|
||||
};
|
||||
|
||||
const onRemoveOrgMemberApproved = async () => {
|
||||
const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id;
|
||||
await onRemoveMember(orgMembershipId);
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
const isIamOwner = useMemo(
|
||||
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
|
||||
[userId, members]
|
||||
);
|
||||
|
||||
const filterdUser = useMemo(
|
||||
() =>
|
||||
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
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (!isLoadingSSOConfig && ssoConfig && ssoConfig.isActive) {
|
||||
createNotification({
|
||||
text: "You cannot invite users when SAML SSO is configured for your organization",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMoreUserNotAllowed) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
} else {
|
||||
handlePopUpOpen("addMember");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Projects</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={5} key="org-members" />}
|
||||
{!isLoading &&
|
||||
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
||||
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
||||
const email = user?.email || inviteEmail;
|
||||
const userWs = workspaceMemberships?.[user?._id];
|
||||
|
||||
return (
|
||||
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
{status === "accepted" && (
|
||||
<Select
|
||||
defaultValue={role}
|
||||
isDisabled={userId === user?._id}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
onRoleChange(orgMembershipId, selectedRole)
|
||||
}
|
||||
>
|
||||
{(isIamOwner || role === "owner") && (
|
||||
<SelectItem value="owner">owner</SelectItem>
|
||||
)}
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="member">member</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && (
|
||||
<Button className='w-40' colorSchema="primary" variant="outline_bg" onClick={() => onInviteMember(email)}>
|
||||
Resend Invite
|
||||
</Button>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => onGrantAccess(user?._id, user?.publicKey)}
|
||||
>
|
||||
Grant Access
|
||||
</Button>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{userWs ? (
|
||||
userWs?.map(({ name: wsName, _id }) => (
|
||||
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
|
||||
{wsName}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<div className='flex flex-row'>
|
||||
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured)
|
||||
? <Tag colorSchema="red">This user hasn't accepted the invite yet</Tag>
|
||||
: <Tag colorSchema="red">This user isn't part of any projects yet</Tag>}
|
||||
{router.query.id !== "undefined" && !((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && <button
|
||||
type="button"
|
||||
onClick={() => router.push(`/project/${workspaces[0]?._id}/members`)}
|
||||
className='text-sm bg-mineshaft w-max px-1.5 py-0.5 hover:bg-primary duration-200 hover:text-black cursor-pointer rounded-sm'
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="mr-1" />
|
||||
Add to projects
|
||||
</button>}
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== user?._id && (
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="danger"
|
||||
isDisabled={userId === user?._id}
|
||||
onClick={() => handlePopUpOpen("removeMember", { id: orgMembershipId })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && filterdUser?.length === 0 && (
|
||||
<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"
|
||||
title="Do you want to remove this user from the org?"
|
||||
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 { OrgMembersTable } from "./OrgMembersTable";
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
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 p-2 text-base text-gray-400 bg-white/[0.07]">
|
||||
<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 p-2 text-base text-gray-400 bg-white/[0.07]">
|
||||
<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 p-2 text-base text-gray-400 bg-white/[0.07]">
|
||||
<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} key="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||
export { OrgMembersTable } from "./OrgMembersTable";
|
||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MembersPage } from "./MembersPage";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IPAllowlistSection } from "./components";
|
||||
|
||||
export const IPAllowlistPage = () => {
|
||||
return (
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full">
|
||||
<div className="max-w-7xl px-6 w-full">
|
||||
<div className="my-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
|
||||
<div />
|
||||
</div>
|
||||
<IPAllowlistSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect } from "react";
|
||||
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 { useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddTrustedIp,
|
||||
useGetMyIp,
|
||||
useUpdateTrustedIp
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = yup.object({
|
||||
ipAddress: yup.string().required("IP address is required"),
|
||||
comment: yup.string()
|
||||
}).required();
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["trustedIp"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const IPAllowlistModal = ({
|
||||
popUp,
|
||||
handlePopUpClose,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { data, isLoading } = useGetMyIp();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const addTrustedIp = useAddTrustedIp();
|
||||
const updateTrustedIp = useUpdateTrustedIp();
|
||||
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const trustedIpData = popUp?.trustedIp?.data as {
|
||||
ipAddress: string;
|
||||
comment: string;
|
||||
prefix: number;
|
||||
};
|
||||
|
||||
if (popUp?.trustedIp?.data) {
|
||||
reset({
|
||||
ipAddress: `${trustedIpData.ipAddress}${trustedIpData.prefix !== undefined ? `/${trustedIpData.prefix}` : ""}`,
|
||||
comment: trustedIpData.comment
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
ipAddress: "",
|
||||
comment: ""
|
||||
});
|
||||
}
|
||||
|
||||
}, [popUp?.trustedIp?.data]);
|
||||
|
||||
const onIPAllowlistModalSubmit = async ({
|
||||
ipAddress,
|
||||
comment
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
if (popUp?.trustedIp?.data) {
|
||||
await updateTrustedIp.mutateAsync({
|
||||
workspaceId: currentWorkspace._id,
|
||||
trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId,
|
||||
ipAddress,
|
||||
comment,
|
||||
isActive: true
|
||||
});
|
||||
} else {
|
||||
await addTrustedIp.mutateAsync({
|
||||
workspaceId: currentWorkspace._id,
|
||||
ipAddress,
|
||||
comment,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpClose("trustedIp");
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.trustedIp?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("trustedIp", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={popUp?.trustedIp?.data ? "Update IP" : "Add IP"}>
|
||||
<form onSubmit={handleSubmit(onIPAllowlistModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="ipAddress"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="IPv4/IPv6 Address / CIDR Notation"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="123.456.789.0"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isLoading && data && (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="button"
|
||||
onClick={() => setValue("ipAddress", data)}
|
||||
className="mb-8"
|
||||
>
|
||||
Add current IP address
|
||||
</Button>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="comment"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Comment"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="My IP address"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.trustedIp?.data ? "Update" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("trustedIp")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription,useWorkspace } from "@app/context";
|
||||
import {
|
||||
useDeleteTrustedIp
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IPAllowlistModal } from "./IPAllowlistModal";
|
||||
import { IPAllowlistTable } from "./IPAllowlistTable";
|
||||
|
||||
export const IPAllowlistSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { mutateAsync } = useDeleteTrustedIp();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"trustedIp",
|
||||
"deleteTrustedIp",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
|
||||
try {
|
||||
|
||||
if (!currentWorkspace?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceId: currentWorkspace._id,
|
||||
trustedIpId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted IP access range",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteTrustedIp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
text: "Failed to delete IP access range",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
|
||||
<div className="flex items-center mb-8">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||
IP Allowlist
|
||||
</h2>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("trustedIp")
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
isLoading={false}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add IP
|
||||
</Button>
|
||||
</div>
|
||||
<IPAllowlistTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<IPAllowlistModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteTrustedIp.isOpen}
|
||||
title={`Are you sure want to delete ${
|
||||
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId)
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useGetTrustedIps
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["upgradePlan"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
|
||||
data?: {
|
||||
trustedIpId: string;
|
||||
ipAddress?: string;
|
||||
comment?: string;
|
||||
isActive?: boolean;
|
||||
prefix?: number;
|
||||
},
|
||||
) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const IPAllowlistTable = ({
|
||||
popUp,
|
||||
handlePopUpOpen,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? "");
|
||||
|
||||
const formatType = (type: string, prefix?: number) => {
|
||||
return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="flex-1">IP Address / Range</Th>
|
||||
<Th className="flex-1">Format</Th>
|
||||
<Th className="flex-1">Comment</Th>
|
||||
{/* <Th className="flex-1">Status</Th> */}
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{!isLoading && data && data?.length > 0 && data
|
||||
.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
|
||||
.map(({
|
||||
_id,
|
||||
ipAddress,
|
||||
comment,
|
||||
type,
|
||||
prefix,
|
||||
isActive
|
||||
}) => {
|
||||
return (
|
||||
<Tr
|
||||
key={`ip-access-range-${_id}`}
|
||||
className="h-10"
|
||||
>
|
||||
<Td>
|
||||
{`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`}
|
||||
</Td>
|
||||
<Td>
|
||||
{formatType(type, prefix)}
|
||||
</Td>
|
||||
<Td>
|
||||
{comment}
|
||||
</Td>
|
||||
{/* <Td>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
color="#2ecc71"
|
||||
/>
|
||||
<p className="ml-4">Active</p>
|
||||
</div>
|
||||
</Td> */}
|
||||
<Td className="flex items-center">
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("trustedIp", {
|
||||
trustedIpId: _id,
|
||||
ipAddress,
|
||||
comment,
|
||||
prefix,
|
||||
isActive
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("deleteTrustedIp", {
|
||||
trustedIpId: _id
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{isLoading && <TableSkeleton columns={4} key="ip-access-ranges" />}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState
|
||||
title="No IP addresses added"
|
||||
icon={faGlobe}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IPAllowlistSection } from "./IPAllowlistSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { IPAllowlistPage } from "./IPAllowlistPage";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import updateRiskStatus, { RiskStatus } from "@app/pages/api/secret-scanning/updateRiskStatus";
|
||||
|
||||
export const RiskStatusSelection = ({riskId, currentSelection}: {riskId: any, currentSelection: any }) => {
|
||||
const [selectedRiskStatus, setSelectedRiskStatus] = useState(currentSelection);
|
||||
useEffect(()=>{
|
||||
if (currentSelection !== selectedRiskStatus){
|
||||
const updateSelection = async () =>{
|
||||
await updateRiskStatus(String(localStorage.getItem("orgData.id")), riskId, selectedRiskStatus)
|
||||
}
|
||||
updateSelection()
|
||||
}
|
||||
},[selectedRiskStatus])
|
||||
|
||||
return (
|
||||
<select
|
||||
value={selectedRiskStatus}
|
||||
onChange={(e) => setSelectedRiskStatus(e.target.value)}
|
||||
className="block w-full py-2 px-3 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option>Unresolved</option>
|
||||
<option value={RiskStatus.RESOLVED_FALSE_POSITIVE}>This is a false positive, resolved</option>
|
||||
<option value={RiskStatus.RESOLVED_REVOKED}>I have rotated the secret, resolved</option>
|
||||
<option value={RiskStatus.RESOLVED_NOT_REVOKED}>No rotate needed, resolved</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr} from "@app/components/v2";
|
||||
import timeSince from "@app/ee/utilities/timeSince";
|
||||
import getRisksByOrganization, { GitRisks } from "@app/pages/api/secret-scanning/getRisksByOrganization";
|
||||
|
||||
import { RiskStatusSelection } from "./RiskStatusSelection";
|
||||
|
||||
export const SecretScanningLogsTable = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [gitRisks, setGitRisks] = useState<GitRisks[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRisks = async () => {
|
||||
setIsLoading(true);
|
||||
const risks = await getRisksByOrganization(String(localStorage.getItem("orgData.id")))
|
||||
setGitRisks(risks);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchRisks();
|
||||
},[])
|
||||
|
||||
|
||||
return (
|
||||
<TableContainer className="mt-8">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="flex-1">Date</Th>
|
||||
<Th className="flex-1">Secret Type</Th>
|
||||
<Th className="flex-1">View Risk</Th>
|
||||
<Th className="flex-1">Info</Th>
|
||||
<Th className="flex-1">Status</Th>
|
||||
<Th className="flex-1">Action</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{!isLoading && gitRisks && gitRisks?.map((risk) => {
|
||||
return (
|
||||
<Tr key={risk.ruleID} className="h-10">
|
||||
<Td>{timeSince(new Date(risk.createdAt))}</Td>
|
||||
<Td>{risk.ruleID}</Td>
|
||||
<Td>
|
||||
<a
|
||||
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`}
|
||||
target="_blank"
|
||||
className="text-red-500" rel="noreferrer"
|
||||
>
|
||||
View Exposed Secret
|
||||
</a>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="font-bold">
|
||||
<a href={`https://github.com/${risk.repositoryFullName}`}>
|
||||
{risk.repositoryFullName}
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span>{risk.file}</span><br/>
|
||||
<br/>
|
||||
<span className="font-bold">{risk.author}</span><br/>
|
||||
<span>{risk.email}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
|
||||
<Td>
|
||||
<RiskStatusSelection riskId={risk._id} currentSelection={risk.status}/>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{isLoading && <TableSkeleton columns={7} key="gitRisks" />}
|
||||
{!isLoading && gitRisks && gitRisks?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={7}>
|
||||
<EmptyState
|
||||
title="No risks detected."
|
||||
icon={faCheck}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretScanningLogsTable } from "./SecretScanningLogsTable";
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
|
||||
import {
|
||||
BillingTabGroup
|
||||
} from "./components";
|
||||
@@ -9,10 +7,9 @@ import {
|
||||
export const BillingSettingsPage = () => {
|
||||
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("billing.title")} />
|
||||
<div className="my-8">
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full h-full">
|
||||
<div className="max-w-7xl px-6 w-full">
|
||||
<div className="my-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export const CurrentPlanSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 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>
|
||||
|
||||
+32
-7
@@ -2,15 +2,18 @@ import { Button } from "@app/components/v2";
|
||||
import { useOrganization,useSubscription } from "@app/context";
|
||||
import {
|
||||
useCreateCustomerPortalSession,
|
||||
useGetOrgPlanBillingInfo} from "@app/hooks/api";
|
||||
useGetOrgPlanBillingInfo,
|
||||
useGetOrgTrialUrl
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { ManagePlansModal } from "./ManagePlansModal";
|
||||
|
||||
export const PreviewSection = () => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { subscription, isLoading: isSubscriptionLoading } = useSubscription();
|
||||
const { subscription } = useSubscription();
|
||||
const { data, isLoading } = useGetOrgPlanBillingInfo(currentOrg?._id ?? "");
|
||||
const getOrgTrialUrl = useGetOrgTrialUrl();
|
||||
const createCustomerPortalSession = useCreateCustomerPortalSession();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
@@ -42,24 +45,46 @@ export const PreviewSection = () => {
|
||||
.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
const handleUpgradeBtnClick = async () => {
|
||||
try {
|
||||
if (!subscription || !currentOrg) return;
|
||||
|
||||
if (!subscription.has_used_trial) {
|
||||
// direct user to start pro trial
|
||||
const url = await getOrgTrialUrl.mutateAsync({
|
||||
orgId: currentOrg._id,
|
||||
success_url: window.location.href
|
||||
});
|
||||
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// open compare plans modal
|
||||
handlePopUpOpen("managePlan");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
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 mb-6 flex items-center bg-mineshaft-600 max-w-screen-lg">
|
||||
{subscription && 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 mb-6 flex items-center bg-mineshaft-600">
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handlePopUpOpen("managePlan")}
|
||||
// onClick={() => handlePopUpOpen("managePlan")}
|
||||
onClick={() => handleUpgradeBtnClick()}
|
||||
color="mineshaft"
|
||||
>
|
||||
Upgrade
|
||||
{!subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && subscription && data && (
|
||||
<div className="flex mb-6 max-w-screen-lg">
|
||||
<div className="flex mb-6">
|
||||
<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 text-mineshaft-50 font-semibold mb-8">
|
||||
|
||||
+3
-2
@@ -7,7 +7,8 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input} from "@app/components/v2";
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useGetOrgBillingDetails,
|
||||
@@ -63,7 +64,7 @@ export const CompanyNameSection = () => {
|
||||
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 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
Business name
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => {
|
||||
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 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
|
||||
Invoice email recipient
|
||||
|
||||
+2
-2
@@ -26,10 +26,10 @@ export const PmtMethodsSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 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
|
||||
Payment methods
|
||||
</h2>
|
||||
<Button
|
||||
onClick={handleAddPmtMethodBtnClick}
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr} from "@app/components/v2";
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useDeleteOrgPmtMethod,
|
||||
|
||||
+20
-19
@@ -10,7 +10,8 @@ import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem} from "@app/components/v2";
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useAddOrgTaxId } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
@@ -138,22 +139,22 @@ export const TaxIDModal = ({
|
||||
defaultValue="eu_vat"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Type"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
label="Type"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{taxIDTypes.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{taxIDTypes.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -163,9 +164,9 @@ export const TaxIDModal = ({
|
||||
name="value"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Value"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export const TaxIDSection = () => {
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 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
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { InvoicesTable } from "./InvoicesTable";
|
||||
|
||||
export const BillingReceiptsTab = () => {
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
|
||||
<InvoicesTable />
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr} from "@app/components/v2";
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useGetOrgInvoices
|
||||
|
||||
+2
-2
@@ -14,13 +14,13 @@ const tabs = [
|
||||
export const BillingTabGroup = () => {
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="mt-8 mb-6 border-b-2 border-mineshaft-800 max-w-screen-lg">
|
||||
<Tab.List className="mt-8 mb-6 border-b-2 border-mineshaft-800">
|
||||
{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"}`}
|
||||
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
|
||||
@@ -1,65 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
|
||||
import {
|
||||
OrgIncidentContactsSection,
|
||||
OrgMembersSection,
|
||||
OrgNameChangeSection,
|
||||
OrgServiceAccountsTable
|
||||
} from "./components";
|
||||
import { OrgTabGroup } from "./components";
|
||||
|
||||
export const OrgSettingsPage = () => {
|
||||
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.org.title")} />
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
|
||||
</div>
|
||||
<OrgNameChangeSection />
|
||||
<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="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 className="flex justify-center bg-bunker-800 text-white w-full py-6">
|
||||
<div className="max-w-7xl w-full px-6">
|
||||
<div className="mb-4">
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
|
||||
</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> */}
|
||||
<OrgTabGroup />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { OrgSSOSection } from "./OrgSSOSection";
|
||||
|
||||
export const OrgAuthTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<OrgSSOSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, Switch, UpgradePlanModal } from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import {
|
||||
useGetSSOConfig,
|
||||
useUpdateSSOConfig
|
||||
} from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { SSOModal } from "./SSOModal";
|
||||
|
||||
const ssoAuthProviderMap: { [key: string]: string } = {
|
||||
"okta-saml": "Okta SAML 2.0"
|
||||
}
|
||||
|
||||
export const OrgSSOSection = (): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { subscription } = useSubscription();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { data, isLoading } = useGetSSOConfig(currentOrg?._id ?? "");
|
||||
const { mutateAsync } = useUpdateSSOConfig();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"upgradePlan",
|
||||
"addSSO"
|
||||
] as const);
|
||||
|
||||
const handleSamlSSOToggle = async (value: boolean) => {
|
||||
try {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
await mutateAsync({
|
||||
organizationId: currentOrg?._id,
|
||||
isActive: value
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${value ? "enabled" : "disabled"} SAML SSO`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to ${value ? "enable" : "disable"} SAML SSO`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
|
||||
<div className="flex items-center mb-8">
|
||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||
SAML SSO Configuration
|
||||
</h2>
|
||||
{!isLoading && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription?.samlSSO) {
|
||||
handlePopUpOpen("addSSO");
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
{data ? "Update SAML SSO" : "Set up SAML SSO"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && data && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Switch
|
||||
id="enable-saml-sso"
|
||||
onCheckedChange={(value) => handleSamlSSOToggle(value)}
|
||||
isChecked={data.isActive}
|
||||
>
|
||||
Enable SAML SSO
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-mineshaft-400 text-sm">SSO identifier</h3>
|
||||
<p className="text-gray-400 text-md">{data._id}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-mineshaft-400 text-sm">Type</h3>
|
||||
<p className="text-gray-400 text-md">{ssoAuthProviderMap[data.authProvider]}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-mineshaft-400 text-sm">Audience</h3>
|
||||
<p className="text-gray-400 text-md">{data.audience}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-mineshaft-400 text-sm">Entrypoint</h3>
|
||||
<p className="text-gray-400 text-md">{data.entryPoint}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-mineshaft-400 text-sm">Issuer</h3>
|
||||
<p className="text-gray-400 text-md">{data.issuer}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SSOModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use SAML SSO if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect } from "react";
|
||||
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,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useCreateSSOConfig,
|
||||
useGetSSOConfig,
|
||||
useUpdateSSOConfig
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const ssoAuthProviders = [
|
||||
{ label: "Okta SAML 2.0", value: "okta-saml" }
|
||||
];
|
||||
|
||||
const schema = yup.object({
|
||||
authProvider: yup.string().required("SSO Type is required"),
|
||||
entryPoint: yup.string().required("IDP entrypoint is required"),
|
||||
issuer: yup.string().required("Issuer string is required"),
|
||||
cert: yup.string().required("IDP's public signing certificate is required"),
|
||||
audience: yup.string().required("Expected SAML response audience is required"),
|
||||
}).required();
|
||||
|
||||
export type AddSSOFormData = yup.InferType<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addSSO"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["addSSO"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addSSO"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const SSOModal = ({
|
||||
popUp,
|
||||
handlePopUpClose,
|
||||
handlePopUpToggle
|
||||
}: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateSSOConfig();
|
||||
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateSSOConfig();
|
||||
const { data } = useGetSSOConfig(currentOrg?._id ?? "");
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<AddSSOFormData>({
|
||||
defaultValues: {
|
||||
authProvider: "okta-saml"
|
||||
},
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({
|
||||
authProvider: data?.authProvider ?? "",
|
||||
entryPoint: data?.entryPoint ?? "",
|
||||
issuer: data?.issuer ?? "",
|
||||
cert: data?.cert ?? "",
|
||||
audience: data?.audience ?? ""
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onSSOModalSubmit = async ({
|
||||
authProvider,
|
||||
entryPoint,
|
||||
issuer,
|
||||
cert,
|
||||
audience
|
||||
}: AddSSOFormData) => {
|
||||
try {
|
||||
if (!currentOrg) return;
|
||||
|
||||
if (!data) {
|
||||
await createMutateAsync({
|
||||
organizationId: currentOrg._id,
|
||||
authProvider,
|
||||
isActive: false,
|
||||
entryPoint,
|
||||
issuer,
|
||||
cert,
|
||||
audience
|
||||
});
|
||||
} else {
|
||||
await updateMutateAsync({
|
||||
organizationId: currentOrg._id,
|
||||
authProvider,
|
||||
isActive: false,
|
||||
entryPoint,
|
||||
issuer,
|
||||
cert,
|
||||
audience
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("addSSO");
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${!data ? "added" : "updated"} SAML SSO configuration`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to ${!data ? "add" : "update"} SAML SSO configuration`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authProvider = watch("authProvider");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addSSO?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addSSO", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add SSO">
|
||||
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="authProvider"
|
||||
defaultValue="okta-saml"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Type"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{ssoAuthProviders.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{authProvider && authProvider === "okta-saml" && (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="audience"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Audience"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://your-domain.com"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="entryPoint"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Entrypoint"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://your-domain.okta.com/app/app-name/xxx/sso/saml"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="issuer"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Issuer"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="http://www.okta.com/xxx"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="cert"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Certificate"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<TextArea
|
||||
{...field}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={createIsLoading || updateIsLoading}
|
||||
>
|
||||
{!data ? "Add" : "Update"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("addSSO")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgAuthTab } from "./OrgAuthTab";
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
|
||||
import { OrgNameChangeSection } from "../OrgNameChangeSection";
|
||||
import { OrgServiceAccountsTable } from "../OrgServiceAccountsTable";
|
||||
|
||||
export const OrgGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<OrgNameChangeSection />
|
||||
<div className="p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600 mb-6">
|
||||
<OrgServiceAccountsTable />
|
||||
</div>
|
||||
<OrgIncidentContactsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgGeneralTab } from "./OrgGeneralTab";
|
||||
+2
-2
@@ -17,8 +17,8 @@ export const OrgIncidentContactsSection = () => {
|
||||
] 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">
|
||||
<div className="p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="min-w-max text-xl font-semibold">
|
||||
{t("section.incident.incident-contacts")}
|
||||
</p>
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
-304
@@ -1,304 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
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";
|
||||
|
||||
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 { 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([
|
||||
"removeMember",
|
||||
"setUpEmail"
|
||||
] as const);
|
||||
|
||||
const isLoading = isOrgUserLoading || IsWsMembershipLoading;
|
||||
const userId = currentUser?._id || "";
|
||||
const onRemoveMember = async (membershipId: string) => {
|
||||
if (!currentOrg?._id) return;
|
||||
|
||||
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 () => {
|
||||
const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id;
|
||||
await onRemoveMember(orgMembershipId);
|
||||
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 ? members.find(({ user }) => userId === user?._id)?.role === "owner" : [],
|
||||
[userId, members]
|
||||
);
|
||||
|
||||
const filterdUser = useMemo(
|
||||
() =>
|
||||
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]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Projects</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={5} key="org-memberships" />}
|
||||
{!isLoading &&
|
||||
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
||||
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
||||
const email = user?.email || inviteEmail;
|
||||
const userWs = workspaceMemberships?.[user?._id];
|
||||
|
||||
return (
|
||||
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
{status === "accepted" && (
|
||||
<Select
|
||||
defaultValue={role}
|
||||
isDisabled={userId === user?._id}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
onRoleChange(orgMembershipId, selectedRole)
|
||||
}
|
||||
>
|
||||
{(isIamOwner || role === "owner") && (
|
||||
<SelectItem value="owner">owner</SelectItem>
|
||||
)}
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="member">member</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && (
|
||||
<Button className='w-40' colorSchema="primary" variant="outline_bg" onClick={() => onInviteMember(email)}>
|
||||
Resend Invite
|
||||
</Button>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
onClick={() => onGrantAccess(user?._id, user?.publicKey)}
|
||||
>
|
||||
Grant Access
|
||||
</Button>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{userWs ? (
|
||||
userWs?.map(({ name: wsName, _id }) => (
|
||||
<Tag key={`user-${currentUser._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 colorSchema="red">This user isn't part of any projects yet</Tag>}
|
||||
{router.query.id !== "undefined" && !((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && <button
|
||||
type="button"
|
||||
onClick={() => router.push(`/users/${router.query.id}`)}
|
||||
className='text-sm bg-mineshaft w-max px-1.5 py-0.5 hover:bg-primary duration-200 hover:text-black cursor-pointer rounded-sm'
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="mr-1" />
|
||||
Add to projects
|
||||
</button>}
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== user?._id && (
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="danger"
|
||||
isDisabled={userId === user?._id}
|
||||
onClick={() => handlePopUpOpen("removeMember", { id: orgMembershipId })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && filterdUser?.length === 0 && (
|
||||
<EmptyState title="No project members found" icon={faUsers} />
|
||||
)}
|
||||
</TableContainer>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this user from the org?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||
onDeleteApproved={onRemoveOrgMemberApproved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
+4
-4
@@ -52,9 +52,9 @@ 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 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-4">
|
||||
Organization name
|
||||
</p>
|
||||
<div className="mb-2 max-w-md">
|
||||
@@ -71,8 +71,8 @@ export const OrgNameChangeSection = (): JSX.Element => {
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
color="primary"
|
||||
colorSchema="secondary"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
|
||||
+1
-1
@@ -277,7 +277,7 @@ export const OrgServiceAccountsTable = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between mb-8">
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Fragment } from "react"
|
||||
import { Tab } from "@headlessui/react"
|
||||
|
||||
import { useOrganization,useUser } from "@app/context";
|
||||
import {
|
||||
useGetOrgUsers
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import { OrgAuthTab } from "../OrgAuthTab";
|
||||
import { OrgGeneralTab } from "../OrgGeneralTab";
|
||||
|
||||
export const OrgTabGroup = () => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { user } = useUser();
|
||||
const { data } = useGetOrgUsers(currentOrg?._id ?? "");
|
||||
|
||||
const isRoleSufficient = data?.some((orgUser) => {
|
||||
return orgUser.role !== "member" && orgUser.user._id === user._id;
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ name: "General", key: "tab-org-general" },
|
||||
];
|
||||
|
||||
if (isRoleSufficient) {
|
||||
tabs.push(
|
||||
{ name: "Authentication", key: "tab-org-auth" }
|
||||
);
|
||||
}
|
||||
|
||||
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 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<OrgGeneralTab />
|
||||
</Tab.Panel>
|
||||
{isRoleSufficient && (
|
||||
<Tab.Panel>
|
||||
<OrgAuthTab />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OrgTabGroup } from "./OrgTabGroup";
|
||||
@@ -1,4 +1 @@
|
||||
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||
export { OrgMembersSection } from "./OrgMembersSection";
|
||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||
export { OrgTabGroup } from "./OrgTabGroup";
|
||||
@@ -15,7 +15,7 @@ export const APIKeySection = () => {
|
||||
] as const);
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 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")}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from "react";
|
||||
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,
|
||||
Select,
|
||||
SelectItem} from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import {
|
||||
useUpdateUserAuthProvider
|
||||
} from "@app/hooks/api";
|
||||
|
||||
const authMethods = [
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Google SSO", value: "google" },
|
||||
{ label: "Okta SAML 2.0", value: "okta-saml" },
|
||||
];
|
||||
|
||||
const schema = yup.object({
|
||||
authMethod: yup.string().required("Auth method is required")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
export const AuthMethodSection = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { user } = useUser();
|
||||
const { mutateAsync, isLoading } = useUpdateUserAuthProvider();
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
defaultValues: {
|
||||
authMethod: user?.authProvider ?? "email"
|
||||
},
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
authMethod: user?.authProvider ?? "email"
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const onFormSubmit = async ({
|
||||
authMethod
|
||||
}: FormData) => {
|
||||
try {
|
||||
if (authMethod === "okta-saml") {
|
||||
createNotification({
|
||||
text: "Okta SAML 2.0 can only be configured in your organization settings",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
authProvider: authMethod
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated authentication method",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update authentication method",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
Authentication Method
|
||||
</h2>
|
||||
<div className="max-w-md mb-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="authMethod"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-800 border border-mineshaft-600"
|
||||
>
|
||||
{authMethods.map((authMethod) => {
|
||||
return (
|
||||
<SelectItem
|
||||
value={authMethod.value}
|
||||
key={`auth-method-${authMethod.value}`}
|
||||
>
|
||||
{authMethod.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthMethodSection } from "./AuthMethodSection";
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const ChangeLanguageSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
{t("settings.personal.change-language")}
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export const ChangePasswordSection = () => {
|
||||
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 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||
Change password
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ export const EmergencyKitSection = () => {
|
||||
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 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
|
||||
Emergency Kit
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AuthMethodSection } from "../AuthMethodSection";
|
||||
import { ChangePasswordSection } from "../ChangePasswordSection";
|
||||
import { MFASection } from "../SecuritySection";
|
||||
|
||||
export const PersonalAuthTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<MFASection />
|
||||
<AuthMethodSection />
|
||||
<ChangePasswordSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalAuthTab } from "./PersonalAuthTab";
|
||||
+3
-5
@@ -1,16 +1,14 @@
|
||||
import { ChangeLanguageSection } from "../ChangeLanguageSection";
|
||||
import { ChangePasswordSection } from "../ChangePasswordSection";
|
||||
import { EmergencyKitSection } from "../EmergencyKitSection";
|
||||
import { SecuritySection } from "../SecuritySection";
|
||||
import { SessionsSection } from "../SessionsSection";
|
||||
import { UserNameSection } from "../UserNameSection";
|
||||
|
||||
export const PersonalSecurityTab = () => {
|
||||
export const PersonalGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<UserNameSection />
|
||||
<ChangeLanguageSection />
|
||||
<SecuritySection />
|
||||
<SessionsSection />
|
||||
<ChangePasswordSection />
|
||||
<EmergencyKitSection />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { PersonalGeneralTab } from "./PersonalGeneralTab";
|
||||
@@ -1 +0,0 @@
|
||||
export { PersonalSecurityTab } from "./PersonalSecurityTab";
|
||||
@@ -1,16 +1,13 @@
|
||||
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">
|
||||
<div className="flex justify-center bg-bunker-800 text-white w-full px-6">
|
||||
<div className="max-w-6xl w-full">
|
||||
<div className="mt-6 mb-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">
|
||||
{t("settings.personal.title")}
|
||||
</p>
|
||||
|
||||
+10
-5
@@ -2,23 +2,25 @@ import { Fragment } from "react"
|
||||
import { Tab } from "@headlessui/react"
|
||||
|
||||
import { PersonalAPIKeyTab } from "../PersonalAPIKeyTab";
|
||||
import { PersonalSecurityTab } from "../PersonalSecurityTab";
|
||||
import { PersonalAuthTab } from "../PersonalAuthTab";
|
||||
import { PersonalGeneralTab } from "../PersonalGeneralTab";
|
||||
|
||||
const tabs = [
|
||||
{ name: "General", key: "tab-account-security" },
|
||||
{ name: "General", key: "tab-account-general" },
|
||||
{ name: "Authentication", key: "tab-account-auth" },
|
||||
{ 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">
|
||||
<Tab.List className="mb-4 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"}`}
|
||||
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
@@ -28,7 +30,10 @@ export const PersonalTabGroup = () => {
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<PersonalSecurityTab />
|
||||
<PersonalGeneralTab />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PersonalAuthTab />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<PersonalAPIKeyTab />
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import { useGetUser } from "../../../../hooks/api";
|
||||
import { User } from "../../../../hooks/api/types";
|
||||
import updateMyMfaEnabled from "../../../../pages/api/user/updateMyMfaEnabled";
|
||||
|
||||
export const SecuritySection = () => {
|
||||
export const MFASection = () => {
|
||||
const [isMfaEnabled, setIsMfaEnabled] = useState(false);
|
||||
const { data: user } = useGetUser();
|
||||
const { createNotification } = useNotificationContext();
|
||||
@@ -51,7 +51,7 @@ export const SecuritySection = () => {
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 max-w-6xl rounded-lg border border-mineshaft-600">
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||
Two-factor Authentication
|
||||
</p>
|
||||
@@ -1 +1 @@
|
||||
export { SecuritySection } from "./SecuritySection";
|
||||
export { MFASection } from "./MFASection";
|
||||
+1
-1
@@ -21,7 +21,7 @@ export const SessionsSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="p-4 mb-6 bg-mineshaft-900 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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from "react";
|
||||
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 } from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import { useRenameUser } from "@app/hooks/api/users/queries";
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("User Name"),
|
||||
});
|
||||
|
||||
type FormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const UserNameSection = (): JSX.Element => {
|
||||
const { user } = useUser();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset
|
||||
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
||||
const { mutateAsync, isLoading } = useRenameUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({ name: `${user?.firstName}${user?.lastName && " "}${user?.lastName}` });
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
try {
|
||||
if (!user?._id) return;
|
||||
if (name === "") return;
|
||||
|
||||
await mutateAsync({ newName: name});
|
||||
createNotification({
|
||||
text: "Successfully renamed user",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to rename user",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"
|
||||
>
|
||||
<p className="text-xl font-semibold text-mineshaft-100 mb-4">
|
||||
Name
|
||||
</p>
|
||||
<div className="mb-2 max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input placeholder={`${user?.firstName} ${user?.lastName}`} {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { UserNameSection } from "./UserNameSection";
|
||||
@@ -1,23 +1,57 @@
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
|
||||
import { ProjectTabGroup } from "./components";
|
||||
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
|
||||
import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab";
|
||||
import { WebhooksTab } from "./components/WebhooksTab";
|
||||
|
||||
const tabs = [
|
||||
{ name: "General", key: "tab-project-general" },
|
||||
{ name: "Service Tokens", key: "tab-project-service-tokens" },
|
||||
{ name: "Webhooks", key: "tab-project-webhooks" }
|
||||
];
|
||||
|
||||
export const ProjectSettingsPage = () => {
|
||||
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">
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 px-6 text-white">
|
||||
<div className="w-full max-w-screen-lg">
|
||||
<div className="relative right-5 ml-4">
|
||||
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="my-8">
|
||||
<p className="text-3xl font-semibold text-gray-200">
|
||||
{t("settings.project.title")}
|
||||
</p>
|
||||
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
|
||||
</div>
|
||||
<ProjectTabGroup />
|
||||
<Tab.Group>
|
||||
<Tab.List className="mb-4 w-full border-b-2 border-mineshaft-800">
|
||||
{tabs.map((tab) => (
|
||||
<Tab as={Fragment} key={tab.key}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<ProjectGeneralTab />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<ProjectServiceTokensTab />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<WebhooksTab />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export const AutoCapitalizationSection = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
|
||||
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-primary"
|
||||
|
||||
+7
-9
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useDeleteWorkspace
|
||||
@@ -14,7 +14,8 @@ export const DeleteProjectSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace, workspaces } = useWorkspace();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentOrg } = useOrganization()
|
||||
const [isDeleting, setIsDeleting] = useToggle();
|
||||
const [deleteProjectInput, setDeleteProjectInput] = useState("");
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
@@ -26,12 +27,9 @@ export const DeleteProjectSection = () => {
|
||||
await deleteWorkspace.mutateAsync({
|
||||
workspaceID: currentWorkspace?._id
|
||||
});
|
||||
// redirect user to first workspace user is part of
|
||||
const ws = workspaces.find(({ _id }) => _id !== currentWorkspace?._id);
|
||||
if (!ws) {
|
||||
router.push("/noprojects");
|
||||
}
|
||||
router.push(`/dashboard/${ws?._id}`);
|
||||
// redirect user to the org overview
|
||||
router.push(`/org/${currentOrg?._id}/overview`);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted workspace",
|
||||
type: "success"
|
||||
@@ -48,7 +46,7 @@ export const DeleteProjectSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-red">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-red">
|
||||
<p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p>
|
||||
<p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p>
|
||||
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ export const E2EESection = () => {
|
||||
};
|
||||
|
||||
return bot ? (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
|
||||
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const EnvironmentSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
|
||||
<div className="flex justify-between mb-8">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">
|
||||
Environments
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user