MVP preliminary idea for initial sync behavior

This commit is contained in:
Tuan Dang
2024-03-07 15:25:53 -08:00
368 changed files with 13516 additions and 3496 deletions
@@ -17,6 +17,7 @@ import {
useUpdateBotActiveStatus
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { CloudIntegrationSection } from "./components/CloudIntegrationSection";
import { FrameworkIntegrationSection } from "./components/FrameworkIntegrationSection";
@@ -92,7 +93,7 @@ export const IntegrationsPage = withProjectPermission(
isIntegrationsAuthorizedEmpty &&
isIntegrationsEmpty
) {
if (bot?.id)
if (bot?.id && currentWorkspace?.version === ProjectVersion.V1)
updateBotActiveStatusSync({
isActive: false,
botId: bot.id,
@@ -113,7 +114,7 @@ export const IntegrationsPage = withProjectPermission(
if (!selectedCloudIntegration) return;
try {
if (bot && !bot.isActive) {
if (bot && !bot.isActive && currentWorkspace?.version === ProjectVersion.V1) {
const botKey = generateBotKey(bot.publicKey, latestWsKey!);
await updateBotActiveStatus({
workspaceId,
@@ -46,7 +46,7 @@ export const OrgMembersSection = () => {
const handleAddMemberModal = () => {
if (currentOrg?.authEnforced) {
createNotification({
text: "You cannot invite users when org-level auth is configured for your organization",
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
@@ -141,10 +141,10 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
() =>
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
u?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
);
@@ -231,6 +231,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
{(isAllowed) => (
<IconButton
onClick={() => {
if (currentOrg?.authEnforced) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", { orgMembershipId, email });
}}
size="lg"
@@ -81,6 +81,12 @@ const SIMPLE_PERMISSION_OPTIONS = [
subtitle: "Define organization level SSO requirements",
icon: faSignIn,
formName: "sso"
},
{
title: "SCIM",
subtitle: "Define organization level SCIM requirements",
icon: faUsers,
formName: "scim"
}
] as const;
@@ -34,6 +34,7 @@ export const formSchema = z.object({
"incident-contact": generalPermissionSchema,
"secret-scanning": generalPermissionSchema,
sso: generalPermissionSchema,
scim: generalPermissionSchema,
billing: generalPermissionSchema,
identity: generalPermissionSchema
})
@@ -44,7 +44,8 @@ import {
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAddUserToWs,
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useDeleteUserFromWorkspace,
useGetOrgUsers,
useGetProjectRoles,
@@ -54,6 +55,7 @@ import {
useUploadWsKey
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -95,12 +97,14 @@ export const MemberListTab = () => {
formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
const { mutateAsync: addUserToWorkspace } = useAddUserToWs();
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const { mutateAsync: uploadWsKey } = useUploadWsKey();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
if (!currentWorkspace) return;
if (!currentOrg?.id) return;
// TODO(akhilmhdh): Move to memory storage
const userPrivateKey = localStorage.getItem("PRIVATE_KEY");
@@ -114,12 +118,26 @@ export const MemberListTab = () => {
if (!orgUser) return;
try {
await addUserToWorkspace({
workspaceId,
userPrivateKey,
decryptKey: wsKey,
members: [{ orgMembershipId, userPublicKey: orgUser.user.publicKey }]
});
if (currentWorkspace.version === ProjectVersion.V1) {
await addUserToWorkspace({
workspaceId,
userPrivateKey,
decryptKey: wsKey,
members: [{ orgMembershipId, userPublicKey: orgUser.user.publicKey }]
});
} else if (currentWorkspace.version === ProjectVersion.V2) {
await addUserToWorkspaceNonE2EE({
projectId: workspaceId,
emails: [orgUser.user.email]
});
} else {
createNotification({
text: "Failed to add user to project, unknown project type",
type: "error"
});
return;
}
createNotification({
text: "Successfully added user to the project",
type: "success"
@@ -136,11 +154,11 @@ export const MemberListTab = () => {
};
const handleRemoveUser = async () => {
const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
const email = (popUp?.removeMember?.data as { email: string })?.email;
if (!currentOrg?.id) return;
try {
await removeUserFromWorkspace({ workspaceId, membershipId });
await removeUserFromWorkspace({ workspaceId, emails: [email] });
createNotification({
text: "Successfully removed user from project",
type: "success"
@@ -200,10 +218,10 @@ export const MemberListTab = () => {
() =>
members?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
u?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
);
@@ -357,7 +375,7 @@ export const MemberListTab = () => {
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { id: membershipId })
handlePopUpOpen("removeMember", { email: u.email })
}
>
<FontAwesomeIcon icon={faXmark} />
@@ -441,7 +459,7 @@ export const MemberListTab = () => {
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
title="Do you want to remove this user from the org?"
title="Do you want to remove this user from the project?"
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
@@ -9,6 +9,7 @@ import {
faXmarkCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2";
@@ -170,9 +171,18 @@ export const SecretApprovalRequestChanges = ({
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</IconButton>
<div className="flex items-center space-x-2 rounded-3xl bg-red-600 px-4 py-2 text-white">
<div
className={twMerge(
"flex items-center space-x-2 rounded-3xl px-4 py-2 text-white",
secretApprovalRequestDetails.status === "close" ? "bg-red-600" : "bg-green-600"
)}
>
<FontAwesomeIcon icon={faCodeBranch} size="sm" />
<span>{secretApprovalRequestDetails.status}</span>
<span className="capitalize">
{secretApprovalRequestDetails.status === "close"
? "closed"
: secretApprovalRequestDetails.status}
</span>
</div>
<div className="flex flex-grow flex-col">
<div className="mb-1 text-lg">
@@ -27,6 +27,7 @@ import {
useGetWsTags
} from "@app/hooks/api";
import { ProjectIndexSecretsSection } from "../SecretOverviewPage/components/ProjectIndexSecretsSection";
import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderListView } from "./components/FolderListView";
@@ -219,6 +220,7 @@ export const SecretMainPage = () => {
protectionPolicyName={boardPolicy?.name}
/>
</div>
<ProjectIndexSecretsSection decryptFileKey={decryptFileKey!} />
{!isRollbackMode ? (
<>
<ActionBar
@@ -81,7 +81,7 @@ export const CreateSecretImportForm = ({
});
} catch (err) {
console.error(err);
const axiosError = err as AxiosError
const axiosError = err as AxiosError;
if (axiosError?.response?.status === 401) {
createNotification({
text: "You do not have access to the selected environment/path",
@@ -1,24 +1,4 @@
/* eslint-disable simple-import-sort/imports */
import { memo, useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faClock,
faClose,
faCodeBranch,
faComment,
faCopy,
faEllipsis,
faKey,
faTag,
faTags
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -48,9 +28,29 @@ import {
import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types";
import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faClock,
faClose,
faCodeBranch,
faComment,
faCopy,
faEllipsis,
faKey,
faTag,
faTags
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { memo, useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { CreateReminderForm } from "./CreateReminderForm";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
secret: DecryptedSecret;
@@ -104,7 +104,8 @@ export const SecretItem = memo(
setValue,
reset,
getValues,
formState: { isDirty, isSubmitting }
trigger,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: secret,
values: secret,
@@ -235,15 +236,18 @@ export const SecretItem = memo(
<Controller
name="key"
control={control}
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
isDisabled={isOverriden}
placeholder={error?.message}
isError={Boolean(error)}
onKeyUp={() => trigger("key")}
{...field}
className="w-full px-0 focus:text-bunker-100 focus:ring-transparent"
className="w-full px-0 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
@@ -497,7 +501,7 @@ export const SecretItem = memo(
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content="Save">
<Tooltip content={errors.key ? errors.key?.message : "Save"}>
<IconButton
ariaLabel="more"
variant="plain"
@@ -507,12 +511,16 @@ export const SecretItem = memo(
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting}
isDisabled={isSubmitting || Boolean(errors.key)}
>
{isSubmitting ? (
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
<FontAwesomeIcon
icon={faCheck}
size="lg"
className={twMerge("text-primary", errors.key && "text-mineshaft-300")}
/>
)}
</IconButton>
</Tooltip>
@@ -206,7 +206,7 @@ export const SecretListView = ({
reminderRepeatDays,
reminderNote
} = modSecret;
const hasKeyChanged = oldKey !== key;
const hasKeyChanged = oldKey !== key && key;
const tagIds = tags?.map(({ id }) => id);
const oldTagIds = (orgSecret?.tags || []).map(({ id }) => id);
@@ -8,7 +8,7 @@ export enum SecretActionType {
}
export const formSchema = z.object({
key: z.string().trim(),
key: z.string().trim().min(1, { message: "Secret key is required" }),
value: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
idOverride: z.string().trim().optional(),
valueOverride: z
@@ -3,18 +3,22 @@ import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { subject } from "@casl/ability";
import {
faAngleDown,
faArrowDown,
faArrowUp,
faFolderBlank,
faList,
faMagnifyingGlass
faFolderPlus,
faMagnifyingGlass,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
import { PermissionDeniedBanner } from "@app/components/permissions";
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
@@ -25,6 +29,8 @@ import {
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
@@ -36,7 +42,14 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { UpgradeProjectAlert } from "@app/components/v2/UpgradeProjectAlert";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useCreateFolder,
useCreateSecretV3,
@@ -46,10 +59,12 @@ import {
useGetUserWsKey,
useUpdateSecretV3
} from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSection";
// import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSection";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
@@ -117,6 +132,40 @@ export const SecretOverviewPage = () => {
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3();
const { mutateAsync: createFolder } = useCreateFolder();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addSecretsInAllEnvs",
"addFolder",
"misc"
] as const);
const handleFolderCreate = async (folderName: string) => {
const promises = userAvailableEnvs.map((env) => {
const environment = env.slug;
return createFolder({
name: folderName,
path: secretPath,
environment,
projectId: workspaceId
});
});
const results = await Promise.allSettled(promises);
const isFoldersAdded = results.some((result) => result.status === "fulfilled");
if (isFoldersAdded) {
handlePopUpClose("addFolder");
createNotification({
type: "success",
text: "Successfully created folder"
});
} else {
createNotification({
type: "error",
text: "Failed to create folder"
});
}
};
const handleSecretCreate = async (env: string, key: string, value: string) => {
try {
// create folder if not existing
@@ -284,249 +333,332 @@ export const SecretOverviewPage = () => {
filteredFolderNames?.length === 0;
return (
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<ProjectIndexSecretsSection decryptFileKey={latestFileKey!} />
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
<div className="mt-6">
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/api"
target="_blank"
rel="noopener noreferrer"
>
Infisical API
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
, and
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
>
more
</a>
.
</p>
</div>
<div className="mt-8 flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="w-96 flex flex-row">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
size="sm"
className="flex justify-center items-center overflow-hidden p-0 w-11 bg-mineshaft-800 hover:bg-primary/10 hover:border-primary/60 border border-mineshaft-600 mr-2"
>
<Tooltip content="Choose visible environments" className="mb-2">
<FontAwesomeIcon icon={faList} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((avaiableEnv) => {
const { id: envId, name } = avaiableEnv;
const isEnvSelected = visibleEnvs.map(env => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={() => handleEnvSelect(envId)}
key={envId}
icon={isEnvSelected && <FontAwesomeIcon className="text-primary" icon={faCheckCircle} />}
iconPos="left"
>
<div className="flex items-center">
{name}
</div>
</DropdownMenuItem>
);
})}
{/* <DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faHockeyPuck} />}
// onClick={onCreateTag}
>
Create an environment
</Button>
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret/folder name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<>
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<ProjectIndexSecretsSection decryptFileKey={latestFileKey!} />
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
</div>
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
<Table>
<THead>
<Tr className="sticky top-0 z-20 border-0">
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
Name
<div className="space-y-8">
<div className="mt-6">
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/api"
target="_blank"
rel="noopener noreferrer"
>
Infisical API
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
, and
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
>
more
</a>
.
</p>
</div>
{currentWorkspace?.version === ProjectVersion.V1 && (
<UpgradeProjectAlert project={currentWorkspace} />
)}
<div className="flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="flex flex-row items-center justify-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
size="sm"
className="flex justify-center items-center overflow-hidden p-0 w-11 bg-mineshaft-800 hover:bg-primary/10 hover:border-primary/60 border border-mineshaft-600 mr-2"
>
<Tooltip content="Choose visible environments" className="mb-2">
<FontAwesomeIcon icon={faList} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((avaiableEnv) => {
const { id: envId, name } = avaiableEnv;
const isEnvSelected = visibleEnvs.map(env => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={() => handleEnvSelect(envId)}
key={envId}
icon={isEnvSelected && <FontAwesomeIcon className="text-primary" icon={faCheckCircle} />}
iconPos="left"
>
<div className="flex items-center">
{name}
</div>
</DropdownMenuItem>
);
})}
{/* <DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faHockeyPuck} />}
// onClick={onCreateTag}
>
Create an environment
</Button>
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-80">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret/folder name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
className="h-10 rounded-r-none"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</div>
</Th>
{visibleEnvs?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount;
return (
<Th
className="min-table-row min-w-[11rem] border-b-0 p-0 text-center"
key={`secret-overview-${name}-${index + 1}`}
>
<div className="flex items-center justify-center border-b border-mineshaft-600 px-5 pt-3.5 pb-[0.83rem]">
<button
type="button"
className="text-sm font-medium duration-100 hover:text-mineshaft-100"
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
<Table>
<THead>
<Tr className="sticky top-0 z-20 border-0">
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
</IconButton>
</div>
</Th>
{visibleEnvs?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount;
return (
<Th
className="min-table-row min-w-[11rem] border-b-0 p-0 text-center"
key={`secret-overview-${name}-${index + 1}`}
>
<div className="flex items-center justify-center border-b border-mineshaft-600 px-5 pt-3.5 pb-[0.83rem]">
<button
type="button"
className="text-sm font-medium duration-100 hover:text-mineshaft-100"
onClick={() => handleExploreEnvClick(slug)}
>
{name}
</button>
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={`${missingKeyCount} secrets missing\n compared to other environments`}
>
<div className="ml-2 flex h-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red-600 p-1 text-xs font-medium text-bunker-100">
<span className="text-bunker-100">{missingKeyCount}</span>
</div>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{canViewOverviewPage && isTableLoading && (
<TableSkeleton
columns={visibleEnvs.length + 1}
innerKey="secret-overview-loading"
rows={5}
className="bg-mineshaft-700"
/>
)}
{isTableEmpty && !isTableLoading && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
<Link
href={{
pathname: "/project/[id]/secrets/[env]",
query: { id: workspaceId, env: visibleEnvs?.[0]?.slug }
}}
>
<Button
className="mt-4"
variant="outline_bg"
colorSchema="primary"
size="md"
>
Go to {visibleEnvs?.[0]?.name}
</Button>
</Link>
</EmptyState>
</Td>
</Tr>
)}
{!isTableLoading &&
filteredFolderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={visibleEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
/>
))}
{!isTableLoading &&
(visibleEnvs?.length > 0 ? (
filteredSecretNames.map((key, index) => (
<SecretOverviewTableRow
secretPath={secretPath}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
))
) : (
<PermissionDeniedBanner />
))}
</TBody>
<TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 p-0">
<div
className="w-full border-t border-r border-mineshaft-600"
style={{ height: "45px" }}
/>
</Td>
{visibleEnvs?.map(({ name, slug }) => (
<Td key={`explore-${name}-btn`} className="border-0 border-mineshaft-600 p-0">
<div className="flex w-full items-center justify-center border-r border-t border-mineshaft-600 px-5 py-2">
<Button
size="xs"
variant="outline_bg"
isFullWidth
onClick={() => handleExploreEnvClick(slug)}
>
{name}
</button>
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={`${missingKeyCount} secrets missing\n compared to other environments`}
>
<div className="ml-2 flex h-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red-600 p-1 text-xs font-medium text-bunker-100">
<span className="text-bunker-100">{missingKeyCount}</span>
</div>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{canViewOverviewPage && isTableLoading && (
<TableSkeleton
columns={userAvailableEnvs.length + 1}
innerKey="secret-overview-loading"
rows={5}
className="bg-mineshaft-700"
/>
)}
{isTableEmpty && !isTableLoading && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
<Link
href={{
pathname: "/project/[id]/secrets/[env]",
query: { id: workspaceId, env: visibleEnvs?.[0]?.slug }
}}
>
<Button
className="mt-4"
variant="outline_bg"
colorSchema="primary"
size="md"
>
Go to {visibleEnvs?.[0]?.name}
Explore
</Button>
</Link>
</EmptyState>
</Td>
</div>
</Td>
))}
</Tr>
)}
{!isTableLoading &&
filteredFolderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={visibleEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
/>
))}
{!isTableLoading &&
(visibleEnvs?.length > 0 ? (
filteredSecretNames.map((key, index) => (
<SecretOverviewTableRow
secretPath={secretPath}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
))
) : (
<PermissionDeniedBanner />
))}
</TBody>
<TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 p-0">
<div
className="w-full border-t border-r border-mineshaft-600"
style={{ height: "45px" }}
/>
</Td>
{visibleEnvs.map(({ name, slug }) => (
<Td key={`explore-${name}-btn`} className="border-0 border-mineshaft-600 p-0">
<div className="flex w-full items-center justify-center border-r border-t border-mineshaft-600 px-5 py-2">
<Button
size="xs"
variant="outline_bg"
isFullWidth
onClick={() => handleExploreEnvClick(slug)}
>
Explore
</Button>
</div>
</Td>
))}
</Tr>
</TFoot>
</Table>
</TableContainer>
</TFoot>
</Table>
</TableContainer>
</div>
</div>
</div>
<CreateSecretForm
secretPath={secretPath}
isOpen={popUp.addSecretsInAllEnvs.isOpen}
getSecretByKey={getSecretByKey}
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretsInAllEnvs", isOpen)}
onClose={() => handlePopUpClose("addSecretsInAllEnvs")}
decryptFileKey={latestFileKey!}
/>
<Modal
isOpen={popUp.addFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
>
<ModalContent title="Create Folder">
<FolderForm onCreateFolder={handleFolderCreate} />
</ModalContent>
</Modal>
</>
);
};
@@ -0,0 +1,222 @@
import { Controller, useForm } from "react-hook-form";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
SecretInput,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
const typeSchema = z
.object({
key: z.string().min(1, "Key is required"),
value: z.string().optional(),
environments: z.record(z.boolean().optional())
})
.refine((data) => data.key !== undefined, {
message: "Please enter secret name"
});
type TFormSchema = z.infer<typeof typeSchema>;
type Props = {
secretPath?: string;
decryptFileKey: UserWsKeyPair;
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
// modal props
isOpen?: boolean;
onClose: () => void;
onTogglePopUp: (isOpen: boolean) => void;
};
export const CreateSecretForm = ({
secretPath = "/",
decryptFileKey,
isOpen,
getSecretByKey,
onClose,
onTogglePopUp
}: Props) => {
const {
register,
handleSubmit,
control,
reset,
watch,
formState: { isSubmitting, errors }
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
const newSecretKey = watch("key");
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const environments = currentWorkspace?.environments || [];
const { createNotification } = useNotificationContext();
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const { mutateAsync: createFolder } = useCreateFolder();
const handleFormSubmit = async ({ key, value, environments: selectedEnv }: TFormSchema) => {
const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]);
const isEnvironmentsSelected = environmentsSelected.length;
if (!isEnvironmentsSelected) {
createNotification({ type: "error", text: "Select at least one environment" });
return;
}
const promises = environmentsSelected.map(async (env) => {
const environment = env.slug;
// create folder if not existing
if (secretPath !== "/") {
// /hello/world -> [hello","world"]
const pathSegment = secretPath.split("/").filter(Boolean);
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
const folderName = pathSegment.at(-1);
if (folderName && parentPath) {
await createFolder({
projectId: workspaceId,
path: parentPath,
environment,
name: folderName
});
}
}
const isEdit = getSecretByKey(environment, key) !== undefined;
if (isEdit) {
return updateSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value || "",
type: "shared",
latestFileKey: decryptFileKey
});
}
return createSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value || "",
secretComment: "",
type: "shared",
latestFileKey: decryptFileKey
});
});
const results = await Promise.allSettled(promises);
const isSecretsAdded = results.some((result) => result.status === "fulfilled");
if (isSecretsAdded) {
createNotification({
type: "success",
text: "Secrets created successfully"
});
onClose();
reset();
} else {
createNotification({
type: "error",
text: "Failed to create secrets"
});
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
<ModalContent
className="max-h-[80vh] overflow-y-auto"
title="Bulk Create & Update"
subTitle="Create & update a secret across many environments"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<FormControl label="Key" isError={Boolean(errors?.key)} errorText={errors?.key?.message}>
<Input
{...register("key")}
placeholder="Type your secret name"
autoCapitalization={currentWorkspace?.autoCapitalization}
/>
</FormControl>
<Controller
control={control}
name="value"
render={({ field }) => (
<FormControl
label="Value"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<FormLabel label="Environments" className="mb-2" />
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto ">
{environments.map((env) => {
return (
<Controller
name={`environments.${env.slug}`}
key={`secret-input-${env.slug}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`secret-input-${env.slug}`}
>
{env.name}
{getSecretByKey(env.slug, newSecretKey) && (
<Tooltip content="Secret exists. Will be overwritten">
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
</Tooltip>
)}
</Checkbox>
)}
/>
);
})}
</div>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
type="submit"
>
Create Secret
</Button>
<Button
key="layout-cancel-create-project"
onClick={onClose}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { CreateSecretForm } from "./CreateSecretForm"
@@ -90,7 +90,7 @@ export const CreateRotationForm = ({
<ModalContent
title={`Secret rotation for ${provider.name}`}
subTitle="Provide the required inputs needed for the rotation"
className="max-w-2xl"
className="max-w-2xl max-h-screen overflow-scroll my-4"
>
<Stepper activeStep={wizardStep} direction="horizontal" className="mb-4">
{WIZARD_STEPS.map(({ title, description }, index) => (
@@ -2,6 +2,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgScimSection } from "./OrgSCIMSection";
import { OrgSSOSection } from "./OrgSSOSection";
export const OrgAuthTab = withPermission(
@@ -10,6 +11,7 @@ export const OrgAuthTab = withPermission(
<div>
<OrgGeneralAuthSection />
<OrgSSOSection />
<OrgScimSection />
</div>
);
},
@@ -1,16 +1,25 @@
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch } from "@app/components/v2";
import {
Switch,
UpgradePlanModal
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization
useOrganization,
useSubscription
} from "@app/context";
import { useLogoutUser,useUpdateOrg } from "@app/hooks/api";
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGeneralAuthSection = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upgradePlan"
] as const);
const { mutateAsync } = useUpdateOrg();
@@ -19,6 +28,10 @@ export const OrgGeneralAuthSection = () => {
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
@@ -39,7 +52,7 @@ export const OrgGeneralAuthSection = () => {
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enforce" : "un-enforce"} org-level auth`,
text: (err as { response: { data: { message: string; }}}).response.data.message,
type: "error"
});
}
@@ -60,6 +73,11 @@ export const OrgGeneralAuthSection = () => {
</Switch>
)}
</OrgPermissionCan>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
/>
</div>
);
}
@@ -0,0 +1,113 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
Switch,
UpgradePlanModal
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription} from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ScimTokenModal } from "./ScimTokenModal";
export const OrgScimSection = () => {
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"scimToken",
"deleteScimToken",
"upgradePlan"
] as const);
const { mutateAsync } = useUpdateOrg();
const addScimTokenBtnClick = () => {
if (subscription?.scim) {
handlePopUpOpen("scimToken");
} else {
handlePopUpOpen("upgradePlan");
}
}
const handleEnableSCIMToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.scim) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
scimEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} SCIM provisioning`,
type: "success"
});
} catch (err) {
createNotification({
text: (err as { response: { data: { message: string; }}}).response.data.message,
type: "error"
});
}
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-8 flex items-center">
<h2 className="flex-1 text-xl font-semibold text-white">SCIM Configuration</h2>
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Button
onClick={addScimTokenBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Manage SCIM Tokens
</Button>
)}
</OrgPermissionCan>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Switch
id="enable-scim"
onCheckedChange={(value) => {
if (subscription?.scim) {
handleEnableSCIMToggle(value)
} else {
handlePopUpOpen("upgradePlan");
}
}}
isChecked={currentOrg?.scimEnabled ?? false}
isDisabled={!isAllowed}
>
Enable SCIM Provisioning
</Switch>
)}
</OrgPermissionCan>
<ScimTokenModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan."
/>
</div>
);
}
@@ -19,7 +19,8 @@ import { SSOModal } from "./SSOModal";
const ssoAuthProviderMap: { [key: string]: string } = {
"okta-saml": "Okta SAML",
"azure-saml": "Azure SAML",
"jumpcloud-saml": "JumpCloud SAML"
"jumpcloud-saml": "JumpCloud SAML",
"google-saml": "Google SAML"
};
export const OrgSSOSection = (): JSX.Element => {
@@ -21,13 +21,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
enum AuthProvider {
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml"
JUMPCLOUD_SAML = "jumpcloud-saml",
GOOGLE_SAML = "google-saml"
}
const ssoAuthProviders = [
{ label: "Okta SAML", value: AuthProvider.OKTA_SAML },
{ label: "Azure SAML", value: AuthProvider.AZURE_SAML },
{ label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML }
{ label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML },
{ label: "Google SAML", value: AuthProvider.GOOGLE_SAML }
];
const schema = yup
@@ -140,7 +142,15 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
issuer: "IdP Entity ID",
issuerPlaceholder: "xxx"
};
case AuthProvider.GOOGLE_SAML:
return {
acsUrl: "ACS URL",
entityId: "SP Entity ID",
entryPoint: "SSO URL",
entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx",
issuer: "IdP Entity ID",
issuerPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx"
};
default:
return {
acsUrl: "ACS URL",
@@ -0,0 +1,343 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { format } from "date-fns";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateScimToken,
useDeleteScimToken,
useGetScimTokens} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = yup.object({
description: yup.string(),
ttlDays: yup.string().required("TTL is required")
});
export type FormData = yup.InferType<typeof schema>;
type Props = {
popUp: UsePopUpState<["scimToken", "deleteScimToken"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteScimToken"]>,
data?: {
scimTokenId: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<
["scimToken", "deleteScimToken"]
>,
state?: boolean
) => void;
};
export const ScimTokenModal = ({
popUp,
handlePopUpOpen,
handlePopUpToggle
}: Props) => {
const { currentOrg } = useOrganization();
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const [token, setToken] = useState("");
const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false);
const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false);
const { data, isLoading } = useGetScimTokens(currentOrg?.id ?? "");
const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken();
const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
description: "",
ttlDays: "365"
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isScimUrlCopied) {
timer = setTimeout(() => setIsScimUrlCopied.off(), 2000);
}
if (isScimTokenCopied) {
timer = setTimeout(() => setIsScimTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isScimTokenCopied, isScimUrlCopied]);
const onFormSubmit = async ({ description, ttlDays }: FormData) => {
try {
if (!currentOrg?.id) return;
const { scimToken } = await createScimTokenMutateAsync({
organizationId: currentOrg.id,
description,
ttlDays: Number(ttlDays)
});
setToken(scimToken);
createNotification({
text: "Successfully created SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SCIM token",
type: "error"
});
}
};
const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
try {
if (!currentOrg?.id) return;
await deleteScimTokenMutateAsync({
organizationId: currentOrg.id,
scimTokenId
});
handlePopUpToggle("deleteScimToken", false);
createNotification({
text: "Successfully deleted SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SCIM token",
type: "error"
});
}
};
const hasToken = Boolean(token);
const scimUrl = `${window.origin}/api/v1/scim`;
return (
<Modal
isOpen={popUp?.scimToken?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("scimToken", isOpen);
reset();
setToken("");
}}
>
<ModalContent title="Manage SCIM credentials">
<h2 className="mb-4">SCIM URL</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimUrl}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(scimUrl);
setIsScimUrlCopied.on();
}}
>
<FontAwesomeIcon icon={isScimUrlCopied ? 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">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
<h2 className="mb-4">New SCIM Token</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this token once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsScimTokenCopied.on();
}}
>
<FontAwesomeIcon icon={isScimTokenCopied ? 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">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Description" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="365"
name="ttlDays"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (days)"
isError={Boolean(error)}
errorText={error?.message}
>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">SCIM Tokens</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Expires At</Th>
<Th>Created At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="org-scim-tokens" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(
({
id,
description,
ttlDays,
createdAt
}) => {
let expiresAt;
if (ttlDays > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400 * 1000);
}
return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteScimToken", {
scimTokenId: id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState
title="No SCIM tokens have been created yet"
icon={faKey}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteScimToken.isOpen}
title="Are you sure want to delete the SCIM token?"
onChange={(isOpen) => handlePopUpToggle("scimToken", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteScimTokenData = popUp?.deleteScimToken?.data as {
scimTokenId: string;
};
return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -1,4 +1,3 @@
import { ChangeLanguageSection } from "../ChangeLanguageSection";
import { DeleteAccountSection } from "../DeleteAccountSection";
import { EmergencyKitSection } from "../EmergencyKitSection";
import { SessionsSection } from "../SessionsSection";
@@ -8,10 +7,9 @@ export const PersonalGeneralTab = () => {
return (
<div>
<UserNameSection />
<ChangeLanguageSection />
<SessionsSection />
<EmergencyKitSection />
<DeleteAccountSection />
</div>
);
}
}
@@ -6,6 +6,7 @@ import {
import { Alert, AlertDescription, Checkbox } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
export const E2EESection = () => {
const { currentWorkspace } = useWorkspace();
@@ -20,6 +21,7 @@ export const E2EESection = () => {
* 3. Encrypt project key with bot's public key
* 4. Send encrypted project key to backend and set bot status to active
*/
const toggleBotActivate = async () => {
let botKey;
try {
@@ -75,7 +77,9 @@ export const E2EESection = () => {
}
};
return bot ? (
if (!currentWorkspace) return null;
return bot && currentWorkspace.version === ProjectVersion.V1 ? (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
<p className="mb-8 text-gray-400">
@@ -1,7 +1,24 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import { faAt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ContentLoader, Switch, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
ContentLoader,
FormControl,
Input,
Select,
SelectItem,
Tab,
TabList,
TabPanel,
Tabs
} from "@app/components/v2";
import { useOrganization, useServerConfig, useUser } from "@app/context";
import { useUpdateServerConfig } from "@app/hooks/api";
@@ -9,16 +26,47 @@ enum TabSections {
Settings = "settings"
}
enum SignUpModes {
Disabled = "disabled",
Anyone = "anyone"
}
const formSchema = z.object({
signUpMode: z.nativeEnum(SignUpModes),
allowedSignUpDomain: z.string().optional().nullable()
});
type TDashboardForm = z.infer<typeof formSchema>;
export const AdminDashboardPage = () => {
const router = useRouter();
const data = useServerConfig();
const { config } = data;
const {
control,
handleSubmit,
watch,
formState: { isSubmitting, isDirty }
} = useForm<TDashboardForm>({
resolver: zodResolver(formSchema),
values: {
// eslint-disable-next-line
signUpMode: config.allowSignUp ? SignUpModes.Anyone : SignUpModes.Disabled,
allowedSignUpDomain: config.allowedSignUpDomain
}
});
const signupMode = watch("signUpMode");
const { user, isLoading: isUserLoading } = useUser();
const { orgs } = useOrganization();
const { mutate: updateServerConfig } = useUpdateServerConfig();
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const { createNotification } = useNotificationContext();
const isNotAllowed = !user?.superAdmin;
// TODO(akhilmhdh): on nextjs 14 roadmap this will be properly addressed with context split
useEffect(() => {
if (isNotAllowed && !isUserLoading) {
if (orgs?.length) {
@@ -28,37 +76,115 @@ export const AdminDashboardPage = () => {
}
}, [isNotAllowed, isUserLoading]);
const onFormSubmit = async (formData: TDashboardForm) => {
try {
const { signUpMode, allowedSignUpDomain } = formData;
await updateServerConfig({
allowSignUp: signUpMode !== SignUpModes.Disabled,
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null
});
createNotification({
text: "Successfully changed sign up setting.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update sign up setting."
});
}
};
return (
<div className="container mx-auto max-w-7xl pb-12 text-white dark:[color-scheme:dark]">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<div className="container mx-auto max-w-7xl px-4 pb-12 text-white dark:[color-scheme:dark]">
<div className="mx-auto mb-6 w-full max-w-7xl pt-6">
<div className="mb-8 flex flex-col items-start justify-between text-xl">
<h1 className="text-3xl font-semibold">Admin Dashboard</h1>
<p className="text-base text-bunker-300">Manage your Infisical instance.</p>
</div>
{isUserLoading || isNotAllowed ? (
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
) : (
<div>
<Tabs defaultValue={TabSections.Settings}>
<TabList>
<div className="flex w-full flex-row border-b border-mineshaft-600">
<Tab value={TabSections.Settings}>General</Tab>
</div>
</TabList>
<TabPanel value={TabSections.Settings}>
<div className="flex items-center space-x-4">
<Switch
id="disable-invite"
isChecked={Boolean(config?.allowSignUp)}
onCheckedChange={(isChecked) => updateServerConfig({ allowSignUp: isChecked })}
/>
<div className="flex-grow">Enable signup or invite</div>
</div>
</TabPanel>
</Tabs>
</div>
)}
</div>
{isUserLoading || isNotAllowed ? (
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
) : (
<div>
<Tabs defaultValue={TabSections.Settings}>
<TabList>
<div className="flex w-full flex-row border-b border-mineshaft-600">
<Tab value={TabSections.Settings}>General</Tab>
</div>
</TabList>
<TabPanel value={TabSections.Settings}>
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex justify-between">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Allow user sign up
</div>
<Controller
control={control}
name="signUpMode"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="max-w-72 w-72"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
className="w-72 bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-700"
defaultValue={field.value}
onValueChange={(e) => onChange(e)}
{...field}
>
<SelectItem value={SignUpModes.Disabled}>Disabled</SelectItem>
<SelectItem value={SignUpModes.Anyone}>Anyone</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
{signupMode === "anyone" && (
<div className="mt-4 flex items-center justify-between">
<div className="mb-4 flex text-mineshaft-100">
Restrict sign up by email domain(s)
</div>
<Controller
control={control}
defaultValue=""
name="allowedSignUpDomain"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Leave blank to allow any email domains"
className="w-72"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
placeholder="gmail.com, aws.com, redhat.com"
leftIcon={<FontAwesomeIcon icon={faAt} />}
/>
</FormControl>
)}
/>
</div>
)}
<Button
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
</TabPanel>
</Tabs>
</div>
)}
</div>
);
};
@@ -84,6 +84,10 @@ export const SignUpPage = () => {
tag: userPass.encryptedPrivateKeyTag,
privateKey
});
// TODO(akhilmhdh): This is such a confusing pattern and too unreliable
// Will be refactored in next iteration to make it url based rather than local storage ones
// Part of migration to nextjs 14
localStorage.setItem("orgData.id", res.organization.id);
setStep(SignupSteps.BackupKey);
} catch (err) {
console.log(err);
@@ -130,8 +134,8 @@ export const SignUpPage = () => {
>
<div className="flex flex-col items-center space-y-2 text-center">
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
<div className="text-4xl pt-4">Welcome to Infisical</div>
<div className="text-bunker-300 pb-4">Create your first Super Admin Account</div>
<div className="pt-4 text-4xl">Welcome to Infisical</div>
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="mt-8">
@@ -199,7 +203,14 @@ export const SignUpPage = () => {
)}
/>
</div>
<Button type="submit" colorSchema="primary" variant="outline_bg" isFullWidth className="mt-4" isLoading={isSubmitting}>
<Button
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="mt-4"
isLoading={isSubmitting}
>
Continue
</Button>
</form>