Fix merge conflicts

This commit is contained in:
Tuan Dang
2024-03-15 13:07:29 -07:00
523 changed files with 22146 additions and 7191 deletions
@@ -1,401 +0,0 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Select, SelectItem } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission";
import {
useDeleteUserFromWorkspace,
useGetUserWsKey,
useUpdateUserWorkspaceRole,
useUploadWsKey
} from "@app/hooks/api";
import { decryptAssymmetric, encryptAssymmetric } from "../../utilities/cryptography/crypto";
import guidGenerator from "../../utilities/randomId";
import Button from "../buttons/Button";
import UpgradePlanModal from "../dialog/UpgradePlan";
// const roles = ['admin', 'user'];
// TODO: Set type for this
type Props = {
userData: any[];
changeData: (users: any[]) => void;
myUser: string;
filter: string;
isUserListLoading: boolean;
};
type EnvironmentProps = {
name: string;
slug: string;
};
/**
* This is the component that shows the users of a certin project
* #TODO: add the possibility of choosing and doing operations on multiple users.
* @param {*} props
* @returns
*/
const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => {
const { currentWorkspace } = useWorkspace();
const { subscription } = useSubscription();
const { data: wsKey } = useGetUserWsKey(currentWorkspace?.id ?? "");
const { mutateAsync: deleteUserFromWorkspaceMutateAsync } = useDeleteUserFromWorkspace();
const { mutateAsync: uploadWsKeyMutateAsync } = useUploadWsKey();
const { mutateAsync: updateUserWorkspaceRoleMutateAsync } = useUpdateUserWorkspaceRole();
// const [roleSelected, setRoleSelected] = useState(
// Array(userData?.length).fill(userData.map((user) => user.role))
// );
const router = useRouter();
const [myRole, setMyRole] = useState("member");
const [workspaceEnvs, setWorkspaceEnvs] = useState<EnvironmentProps[]>([]);
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { createNotification } = useNotificationContext();
const workspaceId = router.query.id as string;
// Delete the row in the table (e.g. a user)
// #TODO: Add a pop-up that warns you that the user is going to be deleted.
const handleDelete = async (membershipId: string) => {
await deleteUserFromWorkspaceMutateAsync({ membershipId, workspaceId });
};
const handleRoleUpdate = async (index: number, e: string) => {
await updateUserWorkspaceRoleMutateAsync({
workspaceId,
membershipId: userData[index].membershipId,
role: e.toLowerCase()
});
createNotification({
text: "Successfully changed user role.",
type: "success"
});
};
const handlePermissionUpdate = (
index: number,
val: string,
membershipId: string,
slug: string
) => {
let denials: { ability: string; environmentSlug: string }[];
if (val === "Read Only") {
denials = [
{
ability: "write",
environmentSlug: slug
}
];
} else if (val === "No Access") {
denials = [
{
ability: "write",
environmentSlug: slug
},
{
ability: "read",
environmentSlug: slug
}
];
} else if (val === "Add Only") {
denials = [
{
ability: "read",
environmentSlug: slug
}
];
} else {
denials = [];
}
if (subscription?.rbac === false) {
setIsUpgradeModalOpen(true);
} else {
const allDenials = userData[index].deniedPermissions
.filter(
(perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug
)
.concat(denials);
updateUserProjectPermission({ membershipId, denials: allDenials });
changeData([
...userData.slice(0, index),
...[
{
key: userData[index].key,
firstName: userData[index].firstName,
lastName: userData[index].lastName,
email: userData[index].email,
role: userData[index].role,
status: userData[index].status,
userId: userData[index].userId,
membershipId: userData[index].membershipId,
publicKey: userData[index].publicKey,
deniedPermissions: allDenials
}
],
...userData.slice(index + 1, userData?.length)
]);
createNotification({
text: "Successfully changed user permissions.",
type: "success"
});
}
};
useEffect(() => {
setMyRole(userData.filter((user) => user.email === myUser)[0]?.role);
(async () => {
if (currentWorkspace) {
setWorkspaceEnvs(currentWorkspace.environments);
}
})();
}, [userData, myUser, currentWorkspace]);
const grantAccess = async (id: string, publicKey: string) => {
if (wsKey) {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
// 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 uploadWsKeyMutateAsync({
workspaceId,
userId: id,
encryptedKey: ciphertext,
nonce
});
router.reload();
}
};
const closeUpgradeModal = () => {
setIsUpgradeModalOpen(false);
};
return (
<div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-600 bg-bunker">
<div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
{subscription && (
<UpgradePlanModal
isOpen={isUpgradeModalOpen}
onClose={closeUpgradeModal}
text={
subscription.slug === null
? "You can use RBAC under an Enterprise license"
: "You can use RBAC if you switch to Infisical's Team Plan."
}
/>
)}
<table className="my-0.5 w-full">
<thead className="bg-mineshaft-800 text-xs font-light text-gray-400">
<tr>
<th className="py-3.5 pl-4 text-left">NAME</th>
<th className="py-3.5 pl-4 text-left">EMAIL</th>
<th className="py-3.5 pl-6 pr-10 text-left">ROLE</th>
{workspaceEnvs.map((env) => (
<th key={guidGenerator()} className="max-w-min break-normal py-1 pl-2 text-left">
<span>
{env.slug.toUpperCase()}
<br />
</span>
{/* <span>PERMISSION</span> */}
</th>
))}
<th aria-label="buttons" />
</tr>
</thead>
<tbody>
{!isUserListLoading &&
userData?.filter(
(user) =>
user.firstName?.toLowerCase().includes(filter) ||
user.lastName?.toLowerCase().includes(filter) ||
user.email?.toLowerCase().includes(filter)
).length > 0 &&
userData
?.filter(
(user) =>
user.firstName?.toLowerCase().includes(filter) ||
user.lastName?.toLowerCase().includes(filter) ||
user.email?.toLowerCase().includes(filter)
)
.map((row, index) => (
<tr key={guidGenerator()} className="bg-mineshaft-800 text-sm">
<td className="border-t border-mineshaft-600 py-2 pl-4 text-gray-300">
{row.firstName} {row.lastName}
</td>
<td className="border-t border-mineshaft-600 py-2 pl-4 text-gray-300">
{row.email}
</td>
<td className="border-t border-mineshaft-600 py-2 pl-6 pr-10 text-gray-300">
<div className="flex h-full flex-row items-center justify-start">
<Select
className="w-36 bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-700"
// open={isOpen}
onValueChange={(e) => handleRoleUpdate(index, e)}
value={row.role}
isDisabled={myRole !== "admin" || myUser === row.email}
// onOpenChange={(open) => setIsOpen(open)}
>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</Select>
{row.status === "completed" && myUser !== row.email && (
<div className="rounded-md border border-mineshaft-700 bg-white/5 text-white duration-200 hover:bg-primary hover:text-black">
<Button
onButtonPressed={() => grantAccess(row.userId, row.publicKey)}
color="mineshaft"
text="Grant Access"
size="md"
/>
</div>
)}
</div>
</td>
{workspaceEnvs.map((env) => (
<td
key={guidGenerator()}
className="border-t border-mineshaft-700 py-2 pl-2 text-gray-300"
>
<Select
className="w-16 bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-800 border border-mineshaft-600 text-bunker-200"
position="item-aligned"
// open={isOpen}
onValueChange={(val) =>
handlePermissionUpdate(index, val, row.membershipId, env.slug)
}
value={
// eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? "No Access"
: // eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
!row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? "Read Only"
: !row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? "Add Only"
: "Read & Write"
}
icon={
// eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? faEyeSlash
: // eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
!row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? faEye
: !row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("write") &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes("read")
? faPlus
: faPenToSquare
}
isDisabled={myRole !== "admin"}
// onOpenChange={(open) => setIsOpen(open)}
>
<SelectItem value="No Access" customIcon={faEyeSlash}>
No Access
</SelectItem>
<SelectItem value="Read Only" customIcon={faEye}>
Read Only
</SelectItem>
<SelectItem value="Add Only" customIcon={faPlus}>
Add Only
</SelectItem>
<SelectItem value="Read & Write" customIcon={faPenToSquare}>
Read & Write
</SelectItem>
</Select>
</td>
))}
<td className="border-0.5 flex flex-row justify-end border-t border-mineshaft-700 py-2 pl-8 pr-8">
{myUser !== row.email &&
// row.role !== "admin" &&
myRole !== "member" ? (
<div className="mt-0.5 flex items-center opacity-50 hover:opacity-100">
<Button
onButtonPressed={() => handleDelete(row.membershipId)}
color="red"
size="icon-sm"
icon={faX}
/>
</div>
) : (
<div className="h-9 w-9" />
)}
</td>
</tr>
))}
{isUserListLoading && (
<>
<tr
key={guidGenerator()}
className="h-14 w-full animate-pulse bg-mineshaft-800 text-sm"
/>
<tr
key={guidGenerator()}
className="h-14 w-full animate-pulse bg-mineshaft-800 text-sm"
/>
</>
)}
</tbody>
</table>
</div>
);
};
export default ProjectUsersTable;
@@ -56,18 +56,20 @@ export default function NavHeader({
return (
<div className="flex flex-row items-center pt-6">
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black">
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black min-w-[1.25rem]">
{currentOrg?.name?.charAt(0)}
</div>
<Link passHref legacyBehavior href={`/org/${currentOrg?.id}/overview`}>
<a className="pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
<a className="truncate pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
{currentOrg?.name}
</a>
</Link>
{isProjectRelated && (
<>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" />
<div className="text-sm font-semibold text-bunker-300">{currentWorkspace?.name}</div>
<div className="truncate text-sm font-semibold text-bunker-300">
{currentWorkspace?.name}
</div>
</>
)}
{isOrganizationRelated && (
@@ -1,7 +1,9 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import axios from "axios";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { useSendVerificationEmail } from "@app/hooks/api";
import { Button, Input } from "../v2";
@@ -25,7 +27,8 @@ export default function EnterEmailStep({
setEmail,
incrementStep
}: DownloadBackupPDFStepProps): JSX.Element {
const { mutateAsync } = useSendVerificationEmail();
const { createNotification } = useNotificationContext();
const { mutateAsync, isLoading } = useSendVerificationEmail();
const [emailError, setEmailError] = useState(false);
const { t } = useTranslation();
@@ -46,8 +49,18 @@ export default function EnterEmailStep({
// If everything is correct, go to the next step
if (!emailCheckBool) {
await mutateAsync({ email });
incrementStep();
try {
await mutateAsync({ email });
incrementStep();
} catch(e) {
if (axios.isAxiosError(e)) {
const { message = "Something went wrong" } = e.response?.data as { message: string};
createNotification({
type: "error",
text: message
})
}
}
}
};
@@ -78,6 +91,8 @@ export default function EnterEmailStep({
className='h-14'
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
isDisabled={isLoading}
> {String(t("signup.step1-submit"))} </Button>
</div>
</div>
@@ -245,6 +245,7 @@ export default function UserInfoStep({
placeholder="Infisical"
onChange={(e) => setOrganizationName(e.target.value)}
value={organizationName}
maxLength={64}
isRequired
className="h-12"
/>
@@ -13,12 +13,12 @@ nacl.util = require("tweetnacl-util");
*/
const generateKeyPair = () => {
const pair = nacl.box.keyPair();
return ({
publicKey: nacl.util.encodeBase64(pair.publicKey),
privateKey: nacl.util.encodeBase64(pair.secretKey)
});
}
return {
publicKey: nacl.util.encodeBase64(pair.publicKey),
privateKey: nacl.util.encodeBase64(pair.secretKey)
};
};
type EncryptAsymmetricProps = {
plaintext: string;
@@ -29,27 +29,19 @@ type EncryptAsymmetricProps = {
/**
* Verify that private key [privateKey] is the one that corresponds to
* the public key [publicKey]
* @param {Object}
* @param {Object}
* @param {String} - base64-encoded Nacl private key
* @param {String} - base64-encoded Nacl public key
*/
const verifyPrivateKey = ({
privateKey,
publicKey
}: {
privateKey: string;
publicKey: string;
}) => {
const verifyPrivateKey = ({ privateKey, publicKey }: { privateKey: string; publicKey: string }) => {
const derivedPublicKey = nacl.util.encodeBase64(
nacl.box.keyPair.fromSecretKey(
nacl.util.decodeBase64(privateKey)
).publicKey
nacl.box.keyPair.fromSecretKey(nacl.util.decodeBase64(privateKey)).publicKey
);
if (derivedPublicKey !== publicKey) {
throw new Error("Failed to verify private key");
}
}
};
/**
* Derive a key from password [password] and salt [salt] using Argon2id
@@ -218,7 +210,14 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps):
try {
plaintext = aes.decrypt({ ciphertext, iv, tag, secret: key });
} catch (err) {
console.log("Failed to perform decryption");
console.log("Failed to decrypt with the following parameters", {
ciphertext,
iv,
tag,
key
});
console.log("Failed to perform decryption", err);
process.exit(1);
}
@@ -229,7 +228,8 @@ export {
decryptAssymmetric,
decryptSymmetric,
deriveArgonKey,
encryptAssymmetric,
encryptAssymmetric,
encryptSymmetric,
generateKeyPair,
verifyPrivateKey};
verifyPrivateKey
};
@@ -56,7 +56,6 @@ const encryptSecrets = async ({
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
} else {
// case: a (shared) key does not exist for the workspace
randomBytes = crypto.randomBytes(16).toString("hex");
@@ -116,7 +115,6 @@ const encryptSecrets = async ({
return result;
});
} catch (error) {
console.log("Error while encrypting secrets");
}
@@ -30,7 +30,7 @@ export const Checkbox = ({
<div className="flex items-center font-inter text-bunker-300">
<CheckboxPrimitive.Root
className={twMerge(
"flex items-center justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
"flex items-center flex-shrink-0 justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
isChecked && "bg-primary hover:bg-primary",
Boolean(children) && "mr-3",
@@ -46,7 +46,7 @@ export const Checkbox = ({
<FontAwesomeIcon icon={faCheck} size="sm" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<label className="text-sm whitespace-nowrap" htmlFor={id}>
<label className="text-sm whitespace-nowrap truncate" htmlFor={id}>
{children}
{isRequired && <span className="pl-1 text-red">*</span>}
</label>
+11 -11
View File
@@ -42,27 +42,27 @@ export const MenuItem = <T extends ElementType = "button">({
const iconRef = useRef();
return (
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
<div onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
<li
className={twMerge(
"group px-1 py-2 mt-0.5 font-inter flex flex-col text-sm text-bunker-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
"duration-50 group mt-0.5 flex cursor-pointer flex-col rounded px-1 py-2 font-inter text-sm text-bunker-100 transition-all hover:bg-mineshaft-700",
isSelected && "bg-mineshaft-600 hover:bg-mineshaft-600",
isDisabled && "hover:bg-transparent cursor-not-allowed",
isDisabled && "cursor-not-allowed hover:bg-transparent",
className
)}
>
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm">
<motion.span className="flex w-full flex-row items-center justify-start rounded-sm">
<Item
type="button"
role="menuitem"
className="flex items-center relative"
className="relative flex items-center"
ref={inputRef}
{...props}
>
<div
className={`${
isSelected ? "visisble" : "invisible"
} -left-[0.28rem] absolute w-[0.07rem] rounded-md h-5 bg-primary`}
} absolute -left-[0.28rem] h-5 w-[0.07rem] rounded-md bg-primary`}
/>
{/* {icon && <span className="mr-3 ml-4 w-5 block group-hover:hidden">{icon}</span>} */}
{icon && (
@@ -81,7 +81,7 @@ export const MenuItem = <T extends ElementType = "button">({
{description && <span className="mt-2 text-xs">{description}</span>}
</motion.span>
</li>
</a>
</div>
);
};
@@ -103,16 +103,16 @@ export const SubMenuItem = <T extends ElementType = "button">({
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
<li
className={twMerge(
"group px-1 py-1 mt-0.5 font-inter flex flex-col text-sm text-mineshaft-300 hover:text-mineshaft-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
isDisabled && "hover:bg-transparent cursor-not-allowed",
"duration-50 group mt-0.5 flex cursor-pointer flex-col rounded px-1 py-1 font-inter text-sm text-mineshaft-300 transition-all hover:bg-mineshaft-700 hover:text-mineshaft-100",
isDisabled && "cursor-not-allowed hover:bg-transparent",
className
)}
>
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm pl-6">
<motion.span className="flex w-full flex-row items-center justify-start rounded-sm pl-6">
<Item
type="button"
role="menuitem"
className="flex items-center relative"
className="relative flex items-center"
ref={inputRef}
{...props}
>
@@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root;
export type PopoverContentProps = {
children?: ReactNode;
arrowClassName?: string;
hideCloseBtn?: boolean;
} & PopoverPrimitive.PopoverContentProps;
@@ -19,6 +20,7 @@ export const PopoverContent = ({
children,
className,
hideCloseBtn,
arrowClassName,
...props
}: PopoverContentProps) => (
<PopoverPrimitive.Portal>
@@ -48,7 +50,7 @@ export const PopoverContent = ({
</IconButton>
</PopoverPrimitive.Close>
)}
<PopoverPrimitive.Arrow className="fill-inherit" />
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
@@ -0,0 +1,40 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { twMerge } from "tailwind-merge";
export type RadioGroupProps = RadioGroupPrimitive.RadioGroupProps;
// Note this component is not customizable (Heroku integration and potentially other pages depend on it)
export const RadioGroup = ({ className, children, ...props }: RadioGroupProps) => (
<RadioGroupPrimitive.Root
className={twMerge("flex flex-row gap-5 px-6 mb-6", className)}
defaultValue="App"
aria-label="View density"
{...props}
>
<div className="flex items-center">
<RadioGroupPrimitive.Item
className="bg-bunker-400/20 w-[20px] h-[20px] rounded-full hover:bg-bunker-400/40 border border-bunker-400/60 duration-200 outline-none cursor-default"
value="App"
id="r1"
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center w-full h-full relative after:content-[''] after:block after:w-[11px] after:h-[11px] after:rounded-[50%] after:bg-primary" />
</RadioGroupPrimitive.Item>
<label className="text-bunker-200 text-sm leading-none pl-2" htmlFor="r1">
App
</label>
</div>
<div className="flex items-center">
<RadioGroupPrimitive.Item
className="bg-bunker-400/20 w-[22px] h-[22px] rounded-full hover:bg-bunker-400/40 border border-bunker-400/60 duration-200 outline-none cursor-default"
value="Pipeline"
id="r2"
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center w-full h-full relative after:content-[''] after:block after:w-[13px] after:h-[13px] after:rounded-[50%] after:bg-primary" />
</RadioGroupPrimitive.Item>
<label className="text-bunker-200 text-sm leading-none pl-2" htmlFor="r2">
Pipeline
</label>
</div>
</RadioGroupPrimitive.Root>
);
@@ -0,0 +1,2 @@
export type { RadioGroupProps } from "./RadioGroup";
export { RadioGroup } from "./RadioGroup";
@@ -0,0 +1,47 @@
import { useRouter } from "next/router";
import { Spinner } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetUpgradeProjectStatus } from "@app/hooks/api/workspace/queries";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
export const UpgradeOverlay = () => {
const router = useRouter();
const { currentWorkspace } = useWorkspace();
const [isUpgrading, setIsUpgrading] = useToggle(false);
const isProjectRoute = router.pathname.includes("/project");
const { isLoading: isUpgradeStatusLoading } = useGetUpgradeProjectStatus({
projectId: currentWorkspace?.id ?? "",
enabled: isProjectRoute && currentWorkspace && currentWorkspace.version === ProjectVersion.V1,
refetchInterval: 5_000,
onSuccess: (data) => {
if (!data) return;
if (data.status !== "IN_PROGRESS") {
setIsUpgrading.off();
} else if (data?.status === "IN_PROGRESS") {
setIsUpgrading.on();
}
}
});
// make sure only to display this on /project routes
if (!currentWorkspace || !isProjectRoute) {
return null;
}
return !isUpgradeStatusLoading && isUpgrading ? (
<div className="absolute top-0 left-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80">
<Spinner size="lg" className="text-primary" />
<div className="ml-4 flex flex-col space-y-1">
<div className="text-3xl font-medium text-white">Please wait</div>
<span className="inline-block text-white">Upgrading your project...</span>
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1 @@
export { UpgradeOverlay } from "./UpgradeOverlay";
@@ -0,0 +1,145 @@
import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { faWarning } 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 { useProjectPermission } from "@app/context";
import { useGetUpgradeProjectStatus, useUpgradeProject } from "@app/hooks/api";
import { Workspace } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { Button } from "../Button";
import { Tooltip } from "../Tooltip";
export type UpgradeProjectAlertProps = {
project: Workspace;
};
export const UpgradeProjectAlert = ({ project }: UpgradeProjectAlertProps): JSX.Element | null => {
const { createNotification } = useNotificationContext();
const router = useRouter();
const { membership } = useProjectPermission();
const upgradeProject = useUpgradeProject();
const [currentStatus, setCurrentStatus] = useState<string | null>(null);
const [isUpgrading, setIsUpgrading] = useState(false);
const {
data: projectStatus,
isLoading: statusIsLoading,
refetch: manualProjectStatusRefetch
} = useGetUpgradeProjectStatus({
projectId: project.id,
enabled: membership.role === "admin" && project.version === ProjectVersion.V1,
refetchInterval: 5_000,
onSuccess: (data) => {
if (membership.role !== "admin") {
return;
}
if (data && data?.status !== null) {
if (data.status === "IN_PROGRESS") {
setCurrentStatus("Your upgrade is being processed.");
} else if (data.status === "FAILED") {
setCurrentStatus("Upgrade failed, please try again.");
}
}
if (currentStatus !== null && data?.status === null) {
router.reload();
}
}
});
const onUpgradeProject = useCallback(async () => {
if (upgradeProject.isLoading) {
return;
}
setIsUpgrading(true);
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) {
createNotification({
type: "error",
text: "Private key not found"
});
return;
}
await upgradeProject.mutateAsync({
projectId: project.id,
privateKey: PRIVATE_KEY
});
manualProjectStatusRefetch();
setTimeout(() => setIsUpgrading(false), 5_000);
}, []);
const isLoading =
isUpgrading ||
((upgradeProject.isLoading ||
currentStatus !== null ||
(currentStatus === null && statusIsLoading)) &&
projectStatus?.status !== "FAILED");
if (project.version !== ProjectVersion.V1) return null;
return (
<div
className={twMerge(
"mt-4 flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white",
membership.role !== "admin" && "opacity-80"
)}
>
<FontAwesomeIcon icon={faWarning} className="pr-6 text-6xl text-white/80" />
<div className="flex w-full flex-col text-sm">
<span className="mb-2 text-lg font-semibold">Upgrade your project</span>
{membership.role === "admin" ? (
<>
<p>
Upgrade your project version to continue receiving the latest improvements and
patches.
</p>
<Link href="https://infisical.com/docs/documentation/platform/project-upgrade">
<a target="_blank" className="text-primary-400">
Learn more
</a>
</Link>
</>
) : (
<>
<p>
<span className="font-bold">Please ask a project admin to upgrade the project.</span>
<br />
Upgrading the project version is required to continue receiving the latest
improvements and patches.
</p>
<Link href="https://infisical.com/docs/documentation/platform/project-upgrade">
<a target="_blank" className="text-primary-400">
Learn more
</a>
</Link>
</>
)}
{currentStatus && <p className="mt-2 opacity-80">Status: {currentStatus}</p>}
</div>
<div className="my-2">
<Tooltip
className={twMerge(membership.role === "admin" && "hidden")}
content="You need to be an admin to upgrade the project."
>
<Button
isLoading={isLoading}
isDisabled={isLoading || membership.role !== "admin"}
onClick={onUpgradeProject}
>
Upgrade
</Button>
</Tooltip>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { UpgradeProjectAlert } from "./UpgradeProjectAlert";
@@ -13,7 +13,9 @@ export enum OrgPermissionSubjects {
Member = "member",
Settings = "settings",
IncidentAccount = "incident-contact",
Scim = "scim",
Sso = "sso",
Ldap = "ldap",
Billing = "billing",
SecretScanning = "secret-scanning",
Identity = "identity"
@@ -26,7 +28,9 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Member]
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
+6 -32
View File
@@ -1,10 +1,5 @@
import crypto from "crypto";
import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
import encryptSecrets from "@app/components/utilities/secrets/encryptSecrets";
import { uploadWsKey } from "@app/hooks/api/keys/queries";
import { createSecret } from "@app/hooks/api/secrets/mutations";
import { fetchUserDetails } from "@app/hooks/api/users/queries";
import { createWorkspace } from "@app/hooks/api/workspace/queries";
const secretsToBeAdded = [
@@ -91,43 +86,22 @@ const initProjectHelper = async ({
}) => {
// create new project
const {
data: { workspace }
data: { project }
} = await createWorkspace({
workspaceName: projectName,
organizationId
});
// create and upload new (encrypted) project key
const randomBytes = crypto.randomBytes(16).toString("hex");
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) throw new Error("Failed to find private key");
const user = await fetchUserDetails();
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: randomBytes,
publicKey: user.publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey({
workspaceId: workspace.id,
userId: user.id,
encryptedKey: ciphertext,
nonce
organizationId,
projectName
});
// encrypt and upload secrets to new project
const secrets = await encryptSecrets({
secretsToEncrypt: secretsToBeAdded,
workspaceId: workspace.id,
workspaceId: project.id,
env: "dev"
});
secrets?.forEach((secret) => {
createSecret({
workspaceId: workspace.id,
workspaceId: project.id,
environment: secret.environment,
type: secret.type,
secretKey: secret.secretName,
@@ -147,7 +121,7 @@ const initProjectHelper = async ({
});
});
return workspace;
return project;
};
export { initProjectHelper };
+7 -1
View File
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries";
import { User } from "../users/types";
import { adminQueryKeys } from "./queries";
import { TCreateAdminUserDTO, TServerConfig } from "./types";
@@ -9,7 +10,11 @@ import { TCreateAdminUserDTO, TServerConfig } from "./types";
export const useCreateAdminUser = () => {
const queryClient = useQueryClient();
return useMutation<{ user: User; token: string }, {}, TCreateAdminUserDTO>({
return useMutation<
{ user: User; token: string; organization: { id: string } },
{},
TCreateAdminUserDTO
>({
mutationFn: async (opt) => {
const { data } = await apiRequest.post("/api/v1/admin/signup", opt);
return data;
@@ -34,6 +39,7 @@ export const useUpdateServerConfig = () => {
onSuccess: (data) => {
queryClient.setQueryData(adminQueryKeys.serverConfig(), data);
queryClient.invalidateQueries(adminQueryKeys.serverConfig());
queryClient.invalidateQueries(organizationKeys.getUserOrganizations);
}
});
};
+1
View File
@@ -1,6 +1,7 @@
export type TServerConfig = {
initialized: boolean;
allowSignUp: boolean;
allowedSignUpDomain?: string | null;
isMigrationModeOn?: boolean;
};
+7
View File
@@ -14,6 +14,8 @@ import {
Login1Res,
Login2DTO,
Login2Res,
LoginLDAPDTO,
LoginLDAPRes,
ResetPasswordDTO,
SendMfaTokenDTO,
SRP1DTO,
@@ -37,6 +39,11 @@ export const login2 = async (loginDetails: Login2DTO) => {
return data;
};
export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => {
const { data } = await apiRequest.post<LoginLDAPRes>("/api/v1/ldap/login", loginLDAPDetails); // return if account is complete or not + provider auth token
return data;
}
export const useLogin1 = () => {
return useMutation({
mutationFn: async (details: {
+10
View File
@@ -53,6 +53,16 @@ export type Login2Res = {
tag?: string;
}
export type LoginLDAPDTO = {
organizationSlug: string;
username: string;
password: string;
}
export type LoginLDAPRes = {
nextUrl: string;
}
export type SRP1DTO = {
clientPublicKey: string;
}
+13 -4
View File
@@ -1,4 +1,4 @@
import { TOrgRole, TProjectRole } from "../roles/types";
import { TOrgRole } from "../roles/types";
import { IdentityAuthMethod } from "./enums";
export type IdentityTrustedIp = {
@@ -29,9 +29,18 @@ export type IdentityMembershipOrg = {
export type IdentityMembership = {
id: string;
identity: Identity;
organization: string;
role: "admin" | "member" | "viewer" | "no-access" | "custom";
customRole?: TProjectRole;
roles: {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
createdAt: string;
updatedAt: string;
};
+2
View File
@@ -8,8 +8,10 @@ export * from "./incidentContacts";
export * from "./integrationAuth";
export * from "./integrations";
export * from "./keys";
export * from "./ldapConfig";
export * from "./organization";
export * from "./roles";
export * from "./scim";
export * from "./secretApproval";
export * from "./secretApprovalRequest";
export * from "./secretFolders";
@@ -8,6 +8,7 @@ import {
BitBucketWorkspace,
ChecklyGroup,
Environment,
HerokuPipelineCoupling,
IntegrationAuth,
NorthflankSecretGroup,
Org,
@@ -67,6 +68,8 @@ const integrationAuthKeys = {
environmentId: string;
scope: "job" | "application" | "container";
}) => [{ integrationAuthId, environmentId, scope }, "integrationAuthQoveryScopes"] as const,
getIntegrationAuthHerokuPipelines: ({ integrationAuthId }: { integrationAuthId: string; }) =>
[{ integrationAuthId}, "integrationAuthHerokuPipelines"] as const,
getIntegrationAuthRailwayEnvironments: ({
integrationAuthId,
appId
@@ -319,6 +322,20 @@ const fetchIntegrationAuthQoveryScopes = async ({
return undefined;
};
const fetchIntegrationAuthHerokuPipelines = async ({ integrationAuthId }: {
integrationAuthId: string;
}) => {
const {
data: { pipelines }
} = await apiRequest.get<{ pipelines: HerokuPipelineCoupling[] }>(
`/api/v1/integration-auth/${integrationAuthId}/heroku/pipelines`
);
console.log(99999, pipelines)
return pipelines;
};
const fetchIntegrationAuthRailwayEnvironments = async ({
integrationAuthId,
appId
@@ -594,6 +611,23 @@ export const useGetIntegrationAuthQoveryScopes = ({
});
};
export const useGetIntegrationAuthHerokuPipelines = ({
integrationAuthId
}: {
integrationAuthId: string;
}) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthHerokuPipelines({
integrationAuthId
}),
queryFn: () =>
fetchIntegrationAuthHerokuPipelines({
integrationAuthId
}),
enabled: true
});
};
export const useGetIntegrationAuthRailwayEnvironments = ({
integrationAuthId,
appId
@@ -17,6 +17,17 @@ export type App = {
secretGroups?: string[];
};
export type Pipeline = {
pipelineId: string;
name: string;
};
export type HerokuPipelineCoupling = {
app: { appId: string };
stage: string;
pipeline: { pipelineId: string; name: string };
};
export type Team = {
name: string;
teamId: string;
@@ -61,6 +61,7 @@ export const useCreateIntegration = () => {
metadata?: {
secretPrefix?: string;
secretSuffix?: string;
initialSyncBehavior?: string;
}
}) => {
const { data: { integration } } = await apiRequest.post("/api/v1/integration", {
+16 -2
View File
@@ -24,10 +24,24 @@ export type TIntegration = {
region?: string;
scope?: string;
integration: string;
metadata?: Record<string, any>;
integrationAuthId: string;
envId: string;
secretPath: string;
createdAt: string;
updatedAt: string;
};
__v: number;
metadata?: {
secretSuffix?: string;
syncBehavior?: IntegrationSyncBehavior;
scope: string;
org: string;
project: string;
environment: string;
};
};
export enum IntegrationSyncBehavior {
OVERWRITE_TARGET = "overwrite-target",
PREFER_TARGET = "prefer-target",
PREFER_SOURCE = "prefer-source"
}
+2 -2
View File
@@ -8,9 +8,9 @@ const encKeyKeys = {
getUserWorkspaceKey: (workspaceID: string) => ["workspace-key-pair", { workspaceID }] as const
};
export const fetchUserWsKey = async (workspaceID: string) => {
export const fetchUserWsKey = async (projectId: string) => {
const { data } = await apiRequest.get<UserWsKeyPair>(
`/api/v2/workspace/${workspaceID}/encrypted-key`
`/api/v2/workspace/${projectId}/encrypted-key`
);
return data;
@@ -0,0 +1,5 @@
export {
useCreateLDAPConfig,
useGetLDAPConfig,
useUpdateLDAPConfig
} from "./queries";
@@ -0,0 +1,103 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
const ldapConfigKeys = {
getLDAPConfig: (orgId: string) => [{ orgId }, "organization-ldap"] as const,
}
export const useGetLDAPConfig = (organizationId: string) => {
return useQuery({
queryKey: ldapConfigKeys.getLDAPConfig(organizationId),
queryFn: async () => {
const { data } = await apiRequest.get(
`/api/v1/ldap/config?organizationId=${organizationId}`
);
return data;
},
enabled: true
});
}
export const useCreateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}: {
organizationId: string;
isActive: boolean;
url: string;
bindDN: string;
bindPass: string;
searchBase: string;
caCert?: string;
}) => {
const { data } = await apiRequest.post(
"/api/v1/ldap/config",
{
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
});
};
export const useUpdateLDAPConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}: {
organizationId: string;
isActive?: boolean;
url?: string;
bindDN?: string;
bindPass?: string;
searchBase?: string;
caCert?: string;
}) => {
const { data } = await apiRequest.patch(
"/api/v1/ldap/config",
{
organizationId,
isActive,
url,
bindDN,
bindPass,
searchBase,
caCert
}
);
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ldapConfigKeys.getLDAPConfig(dto.organizationId));
}
});
};
@@ -71,12 +71,14 @@ export const useUpdateOrg = () => {
mutationFn: ({
name,
authEnforced,
scimEnabled,
slug,
orgId
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
authEnforced,
scimEnabled,
slug
});
},
@@ -4,6 +4,7 @@ export type Organization = {
createAt: string;
updatedAt: string;
authEnforced: boolean;
scimEnabled: boolean;
slug: string;
};
@@ -11,6 +12,7 @@ export type UpdateOrgDTO = {
orgId: string;
name?: string;
authEnforced?: boolean;
scimEnabled?: boolean;
slug?: string;
};
+5
View File
@@ -0,0 +1,5 @@
export {
useCreateScimToken,
useDeleteScimToken
} from "./mutations";
export { useGetScimTokens } from "./queries";
+45
View File
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { scimKeys } from "./queries";
import {
CreateScimTokenDTO,
CreateScimTokenRes,
DeleteScimTokenDTO
} from "./types";
export const useCreateScimToken = () => {
const queryClient = useQueryClient();
return useMutation<CreateScimTokenRes, {}, CreateScimTokenDTO>({
mutationFn: async ({
organizationId,
description,
ttlDays
}) => {
const { data } = await apiRequest.post("/api/v1/scim/scim-tokens", {
organizationId,
description,
ttlDays
});
return data;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
}
});
};
export const useDeleteScimToken = () => {
const queryClient = useQueryClient();
return useMutation<CreateScimTokenRes, {}, DeleteScimTokenDTO>({
mutationFn: async ({ scimTokenId }) => {
const { data } = await apiRequest.delete(`/api/v1/scim/scim-tokens/${scimTokenId}`);
return data;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries(scimKeys.getScimTokens(organizationId));
}
});
};
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ScimTokenData } from "./types";
export const scimKeys = {
getScimTokens: (orgId: string) => [{ orgId }, "organization-scim-token"] as const,
};
export const useGetScimTokens = (organizationId: string) => {
return useQuery({
queryKey: scimKeys.getScimTokens(organizationId),
queryFn: async () => {
if (organizationId === "") {
return undefined;
}
const { data: { scimTokens } } = await apiRequest.get<{ scimTokens: ScimTokenData[] }>(`/api/v1/scim/scim-tokens?organizationId=${organizationId}`);
return scimTokens;
},
enabled: true
});
};
+24
View File
@@ -0,0 +1,24 @@
export type ScimTokenData = {
id: string;
ttlDays: number;
description: string;
tokenSuffix: string;
orgId: string;
createdAt: string;
updatedAt: string;
};
export type CreateScimTokenDTO = {
organizationId: string;
description?: string;
ttlDays?: number;
}
export type DeleteScimTokenDTO = {
organizationId: string;
scimTokenId: string;
}
export type CreateScimTokenRes = {
scimToken: string;
}
@@ -2,7 +2,6 @@ export type ServerStatus = {
date: string;
message: string;
emailConfigured: boolean;
inviteOnlySignup: boolean;
secretScanningConfigured: boolean
redisConfigured: boolean
};
};
+7 -2
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { organizationKeys } from "@app/hooks/api/organization/queries";
const ssoConfigKeys = {
getSSOConfig: (orgId: string) => [{ orgId }, "organization-saml-sso"] as const
@@ -82,8 +83,12 @@ export const useUpdateSSOConfig = () => {
return data;
},
onSuccess(_, dto) {
queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(dto.organizationId));
onSuccess(_, { organizationId, isActive }) {
if (isActive === false) {
queryClient.invalidateQueries(organizationKeys.getUserOrganizations);
}
queryClient.invalidateQueries(ssoConfigKeys.getSSOConfig(organizationId));
}
});
};
@@ -18,6 +18,8 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
scim: boolean;
ldap: boolean;
status:
| "incomplete"
| "incomplete_expired"
+1 -1
View File
@@ -22,7 +22,7 @@ export * from "./secrets/types";
export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types";
export type { SubscriptionPlan } from "./subscriptions/types";
export type { WsTag } from "./tags/types";
export type { AddUserToWsDTO, OrgUser, TWorkspaceUser, User, UserEnc } from "./users/types";
export type { AddUserToWsDTOE2EE, OrgUser, TWorkspaceUser, User, UserEnc } from "./users/types";
export type { TWebhook } from "./webhooks/types";
export type {
CreateEnvironmentDTO,
+1 -1
View File
@@ -1,4 +1,4 @@
export { useAddUserToWs } from "./mutation";
export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation";
export {
fetchOrgUsers,
useAddUserToOrg,
+19 -3
View File
@@ -7,12 +7,12 @@ import {
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries";
import { AddUserToWsDTO } from "./types";
import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types";
export const useAddUserToWs = () => {
export const useAddUserToWsE2EE = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, AddUserToWsDTO>({
return useMutation<{}, {}, AddUserToWsDTOE2EE>({
mutationFn: async ({ workspaceId, members, decryptKey, userPrivateKey }) => {
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
@@ -45,3 +45,19 @@ export const useAddUserToWs = () => {
}
});
};
export const useAddUserToWsNonE2EE = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, AddUserToWsDTONonE2EE>({
mutationFn: async ({ projectId, usernames }) => {
const { data } = await apiRequest.post(`/api/v2/workspace/${projectId}/memberships`, {
usernames
});
return data;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId));
}
});
};
+39 -5
View File
@@ -7,13 +7,15 @@ export enum AuthMethod {
GITLAB = "gitlab",
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml"
JUMPCLOUD_SAML = "jumpcloud-saml",
LDAP = "ldap"
}
export type User = {
createdAt: Date;
updatedAt: Date;
email: string;
username: string;
email?: string;
superAdmin: boolean;
firstName?: string;
lastName?: string;
@@ -38,7 +40,8 @@ export type UserEnc = {
export type OrgUser = {
id: string;
user: {
email: string;
username: string;
email?: string;
firstName: string;
lastName: string;
id: string;
@@ -61,9 +64,35 @@ export type TProjectMembership = {
roleId: string;
};
export type TWorkspaceUser = OrgUser;
export type TWorkspaceUser = {
id: string;
user: {
email: string;
username: string;
firstName: string;
lastName: string;
id: string;
publicKey: string;
};
inviteEmail: string;
organization: string;
roles: {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
};
export type AddUserToWsDTO = {
export type AddUserToWsDTOE2EE = {
workspaceId: string;
decryptKey: UserWsKeyPair;
userPrivateKey: string;
@@ -73,6 +102,11 @@ export type AddUserToWsDTO = {
}[];
};
export type AddUserToWsDTONonE2EE = {
projectId: string;
usernames: string[];
};
export type UpdateOrgUserRoleDTO = {
organizationId: string;
membershipId: string;
+3 -2
View File
@@ -1,12 +1,12 @@
export {
useAddIdentityToWorkspace,
useAddUserToWorkspace,
useCreateWorkspace,
useCreateWsEnvironment,
useDeleteIdentityFromWorkspace,
useDeleteUserFromWorkspace,
useDeleteWorkspace,
useDeleteWsEnvironment,
useGetUpgradeProjectStatus,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces,
useGetWorkspaceAuthorizations,
@@ -21,5 +21,6 @@ export {
useToggleAutoCapitalization,
useUpdateIdentityWorkspaceRole,
useUpdateUserWorkspaceRole,
useUpdateWsEnvironment
useUpdateWsEnvironment,
useUpgradeProject
} from "./queries";
+59 -53
View File
@@ -14,7 +14,10 @@ import {
DeleteWorkspaceDTO,
NameWorkspaceSecretsDTO,
RenameWorkspaceDTO,
TGetUpgradeProjectStatusDTO,
ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
UpdateEnvironmentDTO,
Workspace
} from "./types";
@@ -24,6 +27,7 @@ export const workspaceKeys = {
getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, "workspace-secrets"] as const,
getWorkspaceIndexStatus: (workspaceId: string) =>
[{ workspaceId }, "workspace-index-status"] as const,
getProjectUpgradeStatus: (workspaceId: string) => [{ workspaceId }, "workspace-upgrade-status"],
getWorkspaceMemberships: (orgId: string) => [{ orgId }, "workspace-memberships"],
getWorkspaceAuthorization: (workspaceId: string) => [{ workspaceId }, "workspace-authorizations"],
getWorkspaceIntegrations: (workspaceId: string) => [{ workspaceId }, "workspace-integrations"],
@@ -51,6 +55,14 @@ const fetchWorkspaceIndexStatus = async (workspaceId: string) => {
return data;
};
const fetchProjectUpgradeStatus = async (projectId: string) => {
const { data } = await apiRequest.get<{ status: string }>(
`/api/v2/workspace/${projectId}/upgrade/status`
);
return data;
};
export const fetchWorkspaceSecrets = async (workspaceId: string) => {
const {
data: { secrets }
@@ -61,6 +73,36 @@ export const fetchWorkspaceSecrets = async (workspaceId: string) => {
return secrets;
};
export const useUpgradeProject = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, { projectId: string; privateKey: string }>({
mutationFn: ({ projectId, privateKey }) => {
return apiRequest.post(`/api/v2/workspace/${projectId}/upgrade`, {
userPrivateKey: privateKey
});
},
onSuccess: () => {
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
}
});
};
export const useGetUpgradeProjectStatus = ({
projectId,
onSuccess,
enabled,
refetchInterval
}: TGetUpgradeProjectStatusDTO) => {
return useQuery({
queryKey: workspaceKeys.getProjectUpgradeStatus(projectId),
queryFn: () => fetchProjectUpgradeStatus(projectId),
enabled,
onSuccess,
refetchInterval
});
};
const fetchUserWorkspaces = async () => {
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace");
return data.workspaces;
@@ -158,19 +200,19 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) =>
export const createWorkspace = ({
organizationId,
workspaceName
}: CreateWorkspaceDTO): Promise<{ data: { workspace: Workspace } }> => {
return apiRequest.post("/api/v1/workspace", { workspaceName, organizationId });
projectName
}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => {
return apiRequest.post("/api/v2/workspace", { projectName, organizationId });
};
export const useCreateWorkspace = () => {
const queryClient = useQueryClient();
return useMutation<{ data: { workspace: Workspace } }, {}, CreateWorkspaceDTO>({
mutationFn: async ({ organizationId, workspaceName }) =>
return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({
mutationFn: async ({ organizationId, projectName }) =>
createWorkspace({
organizationId,
workspaceName
projectName
}),
onSuccess: () => {
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
@@ -279,40 +321,16 @@ export const useGetWorkspaceUsers = (workspaceId: string) => {
});
};
export const useAddUserToWorkspace = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ email, workspaceId }: { email: string; workspaceId: string }) => {
const {
data: { invitee, latestKey }
} = await apiRequest.post(`/api/v1/workspace/${workspaceId}/invite-signup`, { email });
return {
invitee,
latestKey
};
},
onSuccess: (_, dto) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(dto.workspaceId));
}
});
};
export const useDeleteUserFromWorkspace = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
membershipId,
workspaceId
}: {
workspaceId: string;
membershipId: string;
}) => {
mutationFn: async ({ usernames, workspaceId }: { workspaceId: string; usernames: string[] }) => {
const {
data: { deletedMembership }
} = await apiRequest.delete(`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`);
} = await apiRequest.delete(`/api/v2/workspace/${workspaceId}/memberships`, {
data: { usernames }
});
return deletedMembership;
},
onSuccess: (_, { workspaceId }) => {
@@ -324,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => {
export const useUpdateUserWorkspaceRole = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
membershipId,
role,
workspaceId
}: {
membershipId: string;
role: string;
workspaceId: string;
}) => {
mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => {
const {
data: { membership }
} = await apiRequest.patch<{ membership: { projectId: string } }>(
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
{
role
roles
}
);
return membership;
},
onSuccess: (res) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId));
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
}
});
};
@@ -384,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => {
mutationFn: async ({
identityId,
workspaceId,
role
}: {
identityId: string;
workspaceId: string;
role?: string;
}) => {
roles
}:TUpdateWorkspaceIdentityRoleDTO)=> {
const {
data: { identityMembership }
} = await apiRequest.patch(
`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`,
{
role
roles
}
);
+56 -1
View File
@@ -1,10 +1,22 @@
export enum ProjectVersion {
V1 = 1,
V2 = 2
}
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
export type Workspace = {
__v: number;
id: string;
name: string;
orgId: string;
version: ProjectVersion;
upgradeStatus: string | null;
autoCapitalization: boolean;
environments: WorkspaceEnv[];
slug: string;
};
export type WorkspaceEnv = {
@@ -23,9 +35,16 @@ export type NameWorkspaceSecretsDTO = {
}[];
};
export type TGetUpgradeProjectStatusDTO = {
projectId: string;
onSuccess?: (data?: { status: string }) => void;
enabled?: boolean;
refetchInterval?: number;
};
// mutation dto
export type CreateWorkspaceDTO = {
workspaceName: string;
projectName: string;
organizationId: string;
};
@@ -57,3 +76,39 @@ export type UpdateEnvironmentDTO = {
};
export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
export type TUpdateWorkspaceUserRoleDTO = {
membershipId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
export type TUpdateWorkspaceIdentityRoleDTO = {
identityId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
+5 -4
View File
@@ -1,6 +1,6 @@
import { initReactI18next } from "react-i18next";
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
// don't want to use this?
// have a look at the Quick start guide
@@ -13,17 +13,18 @@ i18n
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// .use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
lng:"en",
fallbackLng: "en",
supportedLngs: ["en", "ko", "fr", "pt-BR", "pt-PT", "es"],
// supportedLngs: ["en", "ko", "fr", "pt-BR", "pt-PT", "es"],
debug: process.env.NODE_ENV === "development",
detection: {
lookupLocalStorage: "lang"
// lookupLocalStorage: "lang"
},
ns: ["translations"],
interpolation: {
@@ -91,7 +91,7 @@ export const AdminLayout = ({ children }: LayoutProps) => {
console.error(error);
}
};
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
@@ -121,7 +121,7 @@ export const AdminLayout = ({ children }: LayoutProps) => {
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.username}</div>
<Link href="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
+76 -84
View File
@@ -4,7 +4,6 @@
/* eslint-disable vars-on-top */
/* eslint-disable no-var */
/* eslint-disable func-names */
import crypto from "crypto";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -29,12 +28,12 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { twMerge } from "tailwind-merge";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage";
import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
import {
Button,
Checkbox,
@@ -51,6 +50,7 @@ import {
SelectItem,
UpgradePlanModal
} from "@app/components/v2";
import { UpgradeOverlay } from "@app/components/v2/UpgradeOverlay";
import {
OrgPermissionActions,
OrgPermissionSubjects,
@@ -62,16 +62,14 @@ import {
import { usePopUp } from "@app/hooks";
import {
fetchOrgUsers,
useAddUserToWs,
useAddUserToWsNonE2EE,
useCreateWorkspace,
useGetOrgTrialUrl,
useGetSecretApprovalRequestCount,
useGetUserAction,
useLogoutUser,
useRegisterUserAction,
useUploadWsKey
useRegisterUserAction
} from "@app/hooks/api";
import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { CreateOrgModal } from "@app/views/Org/components";
interface LayoutProps {
@@ -102,7 +100,7 @@ const supportOptions = [
];
const formSchema = yup.object({
name: yup.string().required().label("Project Name").trim(),
name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members")
});
@@ -129,8 +127,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
: true;
const createWs = useCreateWorkspace();
const uploadWsKey = useUploadWsKey();
const addWsUser = useAddUserToWs();
const addUsersToProject = useAddUserToWsNonE2EE();
const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -220,59 +218,43 @@ export const AppLayout = ({ children }: LayoutProps) => {
const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => {
// type check
if (!currentOrg?.id) return;
if (!currentOrg) return;
if (!user) return;
try {
const {
data: {
workspace: { id: newWorkspaceId }
project: { id: newProjectId }
}
} = await createWs.mutateAsync({
organizationId: currentOrg?.id,
workspaceName: name
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY"));
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: randomBytes,
publicKey: user.publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
encryptedKey: ciphertext,
nonce,
userId: user?.id,
workspaceId: newWorkspaceId
organizationId: currentOrg.id,
projectName: name
});
if (addMembers) {
// not using hooks because need at this point only
const orgUsers = await fetchOrgUsers(currentOrg.id);
const decryptKey = await fetchUserWsKey(newWorkspaceId);
await addWsUser.mutateAsync({
workspaceId: newWorkspaceId,
decryptKey,
userPrivateKey: PRIVATE_KEY,
members: orgUsers
.filter(
({ status, user: orgUser }) => status === "accepted" && user.email !== orgUser.email
)
.map(({ user: orgUser, id: orgMembershipId }) => ({
userPublicKey: orgUser.publicKey,
orgMembershipId
}))
await addUsersToProject.mutateAsync({
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
});
}
// eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected.
await new Promise((resolve) => setTimeout(resolve, 2_000));
// eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected.
await new Promise((resolve) => setTimeout(resolve, 2_000));
createNotification({ text: "Workspace created", type: "success" });
handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets/overview`);
router.push(`/project/${newProjectId}/secrets/overview`);
} catch (err) {
console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" });
}
};
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
@@ -280,6 +262,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div>
<UpgradeOverlay />
{!router.asPath.includes("personal") && (
<div className="flex h-12 cursor-default items-center px-3 pt-6">
{(router.asPath.includes("project") ||
@@ -291,13 +274,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
</Link>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
<DropdownMenuTrigger
asChild
className="max-w-[160px] data-[state=open]:bg-mineshaft-600"
>
<div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm">
<div className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-md bg-primary text-sm">
{currentOrg?.name.charAt(0)}
</div>
<div
className="overflow-hidden text-ellipsis pl-2 text-sm text-mineshaft-100"
className="overflow-hidden truncate text-ellipsis pl-2 text-sm text-mineshaft-100"
style={{ maxWidth: "140px" }}
>
{currentOrg?.name}
@@ -309,42 +295,44 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
{orgs?.map((org) => {
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.username}</div>
{orgs?.map((org) => {
return (
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
<DropdownMenuItem key={org.id}>
<Button
onClick={async () => {
if (currentOrg?.id === org.id) return;
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/saml2/organizations/${org.slug}`);
window.close();
return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
await logout.mutateAsync();
window.open(
`/api/v1/sso/redirect/saml2/organizations/${org.slug}`
);
window.close();
return;
}
changeOrg(org?.id);
}}
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
changeOrg(org?.id)
}}
variant="plain"
colorSchema="secondary"
size="xs"
className="flex w-full items-center justify-start p-0 font-normal"
leftIcon={
currentOrg?.id === org.id && (
<FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
)
}
>
<div className="flex w-full items-center justify-between">
{org.name}
</div>
</Button>
</DropdownMenuItem>
)
>
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name}
</div>
</Button>
</DropdownMenuItem>
);
})}
{/* <DropdownMenuItem key="add-org">
<Button
@@ -382,7 +370,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.username}</div>
<Link href="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
@@ -438,7 +426,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
<Select
defaultValue={currentWorkspace?.id}
value={currentWorkspace?.id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
className="w-full [&>*:first-child]:truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => {
router.push(`/project/${value}/secrets/overview`);
localStorage.setItem("projectData.id", value);
@@ -453,7 +441,10 @@ export const AppLayout = ({ children }: LayoutProps) => {
<SelectItem
key={`ws-layout-list-${id}`}
value={id}
className={`${currentWorkspace?.id === id && "bg-mineshaft-600"}`}
className={twMerge(
currentWorkspace?.id === id && "bg-mineshaft-600",
"truncate"
)}
>
{name}
</SelectItem>
@@ -620,7 +611,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem>
</a>
</Link>
{(window.location.origin.includes("https://app.infisical.com")) && (
{(window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com")) && (
<Link href={`/org/${currentOrg?.id}/billing`} passHref>
<a>
<MenuItem
+15
View File
@@ -0,0 +1,15 @@
/**
* Sorts an array of items into groups. The return value is a map where the keys are
* the group ids the given getGroupId function produced and the value is an array of
* each item in that group.
*/
export const groupBy = <T, Key extends string | number | symbol>(
array: readonly T[],
getGroupId: (item: T) => Key
): Record<Key, T[]> =>
array.reduce((acc, item) => {
const groupId = getGroupId(item);
if (!acc[groupId]) acc[groupId] = [];
acc[groupId].push(item);
return acc;
}, {} as Record<Key, T[]>);
@@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import queryString from "query-string";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api";
import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2";
import { Button, Card, CardTitle, FormControl, Input, Select, SelectItem } from "../../../components/v2";
import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById
@@ -13,6 +15,7 @@ import {
export default function CloudflareWorkersIntegrationPage() {
const router = useRouter();
const { mutateAsync } = useCreateIntegration();
const { createNotification } = useNotificationContext();
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]);
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? "");
@@ -22,6 +25,8 @@ export default function CloudflareWorkersIntegrationPage() {
});
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [secretPath, setSecretPath] = useState("/");
const [targetApp, setTargetApp] = useState("");
const [targetAppId, setTargetAppId] = useState("");
@@ -56,7 +61,7 @@ export default function CloudflareWorkersIntegrationPage() {
app: targetApp,
appId: targetAppId,
sourceEnvironment: selectedSourceEnvironment,
secretPath: "/"
secretPath
});
setIsLoading(false);
@@ -64,6 +69,18 @@ export default function CloudflareWorkersIntegrationPage() {
router.push(`/integrations/${localStorage.getItem("projectData.id")}`);
} catch (err) {
console.error(err);
let errorMessage: string = "Something went wrong!";
if (axios.isAxiosError(err)) {
const { message } = err?.response?.data as { message: string };
errorMessage = message;
}
createNotification({
text: errorMessage,
type: "error"
});
setIsLoading(false);
}
};
@@ -96,6 +113,13 @@ export default function CloudflareWorkersIntegrationPage() {
))}
</Select>
</FormControl>
<FormControl label="Infisical Secret Path" className="mt-2 px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
/>
</FormControl>
<FormControl label="Cloudflare Workers Project" className="mt-4 px-6">
<Select
value={targetApp}
+334 -71
View File
@@ -1,8 +1,25 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import {
faArrowUpRightFromSquare,
faBookOpen,
faBugs,
// faCircleInfo
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
import queryString from "query-string";
// import { useGetIntegrationAuthHerokuPipelines } from "@app/hooks/api/integrationAuth/queries";
// import { App, Pipeline } from "@app/hooks/api/integrationAuth/types";
import * as yup from "yup";
// import { RadioGroup } from "@app/components/v2/RadioGroup";
import { useCreateIntegration } from "@app/hooks/api";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
import {
Button,
@@ -17,54 +34,172 @@ import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById
} from "../../../hooks/api/integrationAuth";
import { useGetWorkspaceById } from "../../../hooks/api/workspace";
import {
// useCreateWsEnvironment,
useGetWorkspaceById
} from "../../../hooks/api/workspace";
const initialSyncBehaviors = [
{ label: "No Import - Overwrite all values in Heroku", value: IntegrationSyncBehavior.OVERWRITE_TARGET },
{ label: "Import - Prefer values from Heroku", value: IntegrationSyncBehavior.PREFER_TARGET },
{ label: "Import - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE }
];
const schema = yup.object({
selectedSourceEnvironment: yup.string().required("Source environment is required"),
secretPath: yup.string().required("Secret path is required"),
targetApp: yup.string().required("Heroku app is required"),
initialSyncBehavior: yup
.string()
.oneOf(initialSyncBehaviors.map((b) => b.value), "Invalid initial sync behavior")
.required("Initial sync behavior is required")
});
type FormData = yup.InferType<typeof schema>;
export default function HerokuCreateIntegrationPage() {
const router = useRouter();
const { control, handleSubmit, setValue, watch } = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
secretPath: "/",
initialSyncBehavior: IntegrationSyncBehavior.PREFER_SOURCE
}
});
const selectedSourceEnvironment = watch("selectedSourceEnvironment");
const { mutateAsync } = useCreateIntegration();
// const { mutateAsync: mutateAsyncEnv } = useCreateWsEnvironment();
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]);
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? "");
const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? "");
const { data: integrationAuthApps } = useGetIntegrationAuthApps({
const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({
integrationAuthId: (integrationAuthId as string) ?? ""
});
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [targetApp, setTargetApp] = useState("");
const [secretPath, setSecretPath] = useState("/");
// const { data: integrationAuthPipelineCouplings } = useGetIntegrationAuthHerokuPipelines({
// integrationAuthId: (integrationAuthId as string) ?? ""
// });
// const [uniquePipelines, setUniquePipelines] = useState<Pipeline[]>();
// const [selectedPipeline, setSelectedPipeline] = useState("");
// const [selectedPipelineApps, setSelectedPipelineApps] = useState<App[]>();
// const [integrationType, setIntegrationType] = useState("App");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
setValue("selectedSourceEnvironment", workspace.environments[0].slug);
}
}, [workspace]);
// useEffect(() => {
// if (integrationAuthPipelineCouplings) {
// const uniquePipelinesConst = Array.from(
// new Set(
// integrationAuthPipelineCouplings
// .map(({ pipeline: { pipelineId, name } }) => ({
// name,
// pipelineId
// }))
// .map((obj) => JSON.stringify(obj))
// )).map((str) => JSON.parse(str)) as { pipelineId: string; name: string }[]
// [... (new Set())]
// setUniquePipelines(uniquePipelinesConst);
// if (uniquePipelinesConst) {
// if (uniquePipelinesConst!.length > 0) {
// setSelectedPipeline(uniquePipelinesConst![0].name);
// } else {
// setSelectedPipeline("none");
// }
// }
// }
// }, [integrationAuthPipelineCouplings]);
// useEffect(() => {
// if (integrationAuthPipelineCouplings) {
// setSelectedPipelineApps(integrationAuthApps?.filter(app => integrationAuthPipelineCouplings
// .filter((pipelineCoupling) => pipelineCoupling.pipeline.name === selectedPipeline)
// .map(coupling => coupling.app.appId).includes(String(app.appId))))
// }
// }, [selectedPipeline]);
useEffect(() => {
if (integrationAuthApps) {
if (integrationAuthApps.length > 0) {
setTargetApp(integrationAuthApps[0].name);
setValue("targetApp", integrationAuthApps[0].name);
} else {
setTargetApp("none");
setValue("targetApp", "none");
}
}
}, [integrationAuthApps]);
const handleButtonClick = async () => {
try {
setIsLoading(true);
// const handleButtonClick = async () => {
// try {
// setIsLoading(true);
// if (!integrationAuth?.id) return;
// if (integrationType === "App") {
// await mutateAsync({
// integrationAuthId: integrationAuth?.id,
// isActive: true,
// app: targetApp,
// sourceEnvironment: selectedSourceEnvironment,
// secretPath
// });
// } else if (integrationType === "Pipeline") {
// selectedPipelineApps?.map(async (app, index) => {
// setTimeout(async () => {
// await mutateAsyncEnv({
// workspaceId: String(localStorage.getItem("projectData.id")),
// name: app.name,
// slug: app.name.toLowerCase().replaceAll(" ", "-")
// });
// await mutateAsync({
// integrationAuthId: integrationAuth?.id,
// isActive: true,
// app: app.name,
// sourceEnvironment: app.name.toLowerCase().replaceAll(" ", "-"),
// secretPath
// })
// }, 1000*index)
// })
// }
// setIsLoading(false);
// router.push(`/integrations/${localStorage.getItem("projectData.id")}`);
// } catch (err) {
// console.error(err);
// }
// };
const onFormSubmit = async ({
secretPath,
targetApp,
initialSyncBehavior,
}: FormData) => {
try {
if (!integrationAuth?.id) return;
setIsLoading(true);
await mutateAsync({
integrationAuthId: integrationAuth?.id,
isActive: true,
app: targetApp,
sourceEnvironment: selectedSourceEnvironment,
secretPath
secretPath,
metadata: {
initialSyncBehavior
}
});
setIsLoading(false);
@@ -72,75 +207,203 @@ export default function HerokuCreateIntegrationPage() {
} catch (err) {
console.error(err);
}
};
}
return integrationAuth &&
workspace &&
selectedSourceEnvironment &&
integrationAuthApps &&
targetApp ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Heroku Integration</CardTitle>
<FormControl label="Project Environment" className="mt-4">
<Select
value={selectedSourceEnvironment}
onValueChange={(val) => setSelectedSourceEnvironment(val)}
className="w-full border border-mineshaft-500"
>
{workspace?.environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
integrationAuthApps ? (
<div className="flex flex-col h-full w-full items-center justify-center">
<Head>
<title>Set Up Heroku Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600 p-0">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Select which environment or folder in Infisical you want to sync to Heroku's environment variables."
>
<div className="flex flex-row items-center">
<div className="flex items-center">
<Image
src="/images/integrations/Heroku.png"
height={30}
width={30}
alt="Heroku logo"
/>
</div>
<span className="ml-2">Heroku Integration </span>
<Link href="https://infisical.com/docs/integrations/cloud/heroku" passHref>
<a target="_blank" rel="noopener noreferrer">
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="ml-1.5 mb-[0.07rem] text-xxs"
/>
</div>
</a>
</Link>
</div>
</CardTitle>
<form onSubmit={handleSubmit(onFormSubmit)} className="px-6">
<Controller
control={control}
name="selectedSourceEnvironment"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project Environment"
errorText={error?.message}
isError={Boolean(error)}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
<FormControl label="Secrets Path">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{workspace?.environments.map((sourceEnvironment) => (
<SelectItem
value={sourceEnvironment.slug}
key={`source-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</FormControl>
<FormControl label="Heroku App" className="mt-4">
<Select
value={targetApp}
onValueChange={(val) => setTargetApp(val)}
className="w-full border border-mineshaft-500"
<Controller
control={control}
defaultValue=""
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secrets Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="/" />
</FormControl>
)}
/>
<Controller
control={control}
name="targetApp"
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Heroku App"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
{...field}
onValueChange={(e) => {
if (e === "") return;
onChange(e);
}}
className="w-full"
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
<SelectItem
value={String(integrationAuthApp.name as string)}
key={`target-app-${String(integrationAuthApp.appId)}`}
>
{integrationAuthApp.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No apps found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
<Controller
control={control}
name="initialSyncBehavior"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Initial Sync Behavior"
errorText={error?.message}
isError={Boolean(error)}
>
<Select {...field} onValueChange={(e) => onChange(e)} className="w-full">
{initialSyncBehaviors.map((b) => {
return (
<SelectItem value={b.value} key={`sync-behavior-${b.value}`}>
{b.label}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Button
colorSchema="primary"
variant="outline_bg"
className="mb-6 mt-2 ml-auto"
size="sm"
type="submit"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
{integrationAuthApps.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => (
<SelectItem
value={integrationAuthApp.name}
key={`target-app-${integrationAuthApp.name}`}
>
{integrationAuthApp.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No apps found
</SelectItem>
)}
</Select>
</FormControl>
<Button
onClick={handleButtonClick}
color="mineshaft"
className="mt-4"
isLoading={isLoading}
isDisabled={integrationAuthApps.length === 0}
>
Create Integration
</Button>
Create Integration
</Button>
</form>
</Card>
{/* {integrationType === "App" && <>
<div className="mt-6 w-full max-w-md border-t border-mineshaft-800" />
<div className="mt-6 flex w-full max-w-lg flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="flex flex-row items-center">
<FontAwesomeIcon icon={faCircleInfo} className="text-xl text-mineshaft-200" />{" "}
<span className="text-md ml-3 text-mineshaft-100">Pro Tips</span>
</div>
<span className="mt-4 text-sm text-mineshaft-300">
After creating an integration, your secrets will start syncing immediately. This might
cause an unexpected override of current secrets in Heroku with secrets from Infisical.
</span>
</div></>} */}
</div>
) : (
<div />
<div className="flex h-full w-full items-center justify-center">
<Head>
<title>Set Up Vercel Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isIntegrationAuthAppsLoading ? (
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="infisical loading indicator"
/>
) : (
<div className="flex h-max max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6 text-center text-mineshaft-200">
<FontAwesomeIcon icon={faBugs} className="inlineli my-2 text-6xl" />
<p>
Something went wrong. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:[email protected]"
>
support@infisical.com
</a>{" "}
if the issue persists.
</p>
</div>
)}
</div>
);
}
@@ -74,16 +74,6 @@ export default function RailwayCreateIntegrationPage() {
}
}, [targetEnvironments]);
useEffect(() => {
if (targetServices) {
if (targetServices.length > 0) {
setTargetServiceId(targetServices[0].serviceId);
} else {
setTargetServiceId("none");
}
}
}, [targetServices]);
const handleButtonClick = async () => {
try {
setIsLoading(true);
@@ -124,11 +114,14 @@ export default function RailwayCreateIntegrationPage() {
}
};
const filteredTargetServices = targetServices ? [ { name: "", serviceId: "none" }, ...targetServices ] : [ { name: "", serviceId: "none" } ];
return workspace &&
selectedSourceEnvironment &&
integrationAuthApps &&
targetEnvironments &&
targetServices ? (
targetServices &&
filteredTargetServices ? (
<div className="flex h-full w-full items-center justify-center">
<Card className="max-w-md rounded-md p-8">
<CardTitle className="text-center">Railway Integration</CardTitle>
@@ -208,20 +201,14 @@ export default function RailwayCreateIntegrationPage() {
className="w-full border border-mineshaft-500"
isDisabled={targetServices.length === 0}
>
{targetServices.length > 0 ? (
targetServices.map((targetService) => (
{filteredTargetServices.map((targetService) => (
<SelectItem
value={targetService.serviceId as string}
key={`target-service-${targetService.serviceId as string}`}
>
{targetService.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-service-none">
No services found
</SelectItem>
)}
))}
</Select>
</FormControl>
<Button
+24 -50
View File
@@ -1,7 +1,5 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import crypto from "crypto";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -23,7 +21,7 @@ import {
faNetworkWired,
faPlug,
faPlus,
faUserPlus,
faUserPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup";
@@ -33,7 +31,6 @@ import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck";
import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
import {
Button,
Checkbox,
@@ -54,12 +51,11 @@ import {
import { withPermission } from "@app/hoc";
import {
fetchOrgUsers,
useAddUserToWs,
useAddUserToWsNonE2EE,
useCreateWorkspace,
useRegisterUserAction,
useUploadWsKey
useRegisterUserAction
} from "@app/hooks/api";
import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -457,7 +453,7 @@ const LearningItemSquare = ({
};
const formSchema = yup.object({
name: yup.string().required().label("Project Name").trim(),
name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members")
});
@@ -475,7 +471,7 @@ const OrganizationPage = withPermission(
const currentOrg = String(router.query.id);
const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === currentOrg) || [];
const { createNotification } = useNotificationContext();
const addWsUser = useAddUserToWs();
const addUsersToProject = useAddUserToWsNonE2EE();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addNewWs",
@@ -497,61 +493,39 @@ const OrganizationPage = withPermission(
const [searchFilter, setSearchFilter] = useState("");
const createWs = useCreateWorkspace();
const { user } = useUser();
const uploadWsKey = useUploadWsKey();
const { data: serverDetails } = useFetchServerStatus();
const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => {
// type check
if (!currentOrg) return;
if (!user) return;
try {
const {
data: {
workspace: { id: newWorkspaceId }
project: { id: newProjectId }
}
} = await createWs.mutateAsync({
organizationId: currentOrg,
workspaceName: name
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY"));
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: randomBytes,
publicKey: user.publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey.mutateAsync({
encryptedKey: ciphertext,
nonce,
userId: user?.id,
workspaceId: newWorkspaceId
projectName: name
});
if (addMembers) {
// not using hooks because need at this point only
const orgUsers = await fetchOrgUsers(currentOrg);
const decryptKey = await fetchUserWsKey(newWorkspaceId);
const members = orgUsers
.filter(
({ status, user: orgUser }) => status === "accepted" && user.email !== orgUser.email
)
.map(({ user: orgUser, id: orgMembershipId }) => ({
userPublicKey: orgUser.publicKey,
orgMembershipId
}));
if (members.length) {
await addWsUser.mutateAsync({
workspaceId: newWorkspaceId,
decryptKey,
userPrivateKey: PRIVATE_KEY,
members
});
}
await addUsersToProject.mutateAsync({
usernames: orgUsers
.map((member) => member.user.username)
.filter((username) => username !== user.username),
projectId: newProjectId
});
}
createNotification({ text: "Workspace created", type: "success" });
// eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected.
await new Promise((resolve) => setTimeout(resolve, 2_000));
handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets/overview`);
createNotification({ text: "Workspace created", type: "success" });
router.push(`/project/${newProjectId}/secrets/overview`);
} catch (err) {
console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" });
@@ -659,7 +633,7 @@ const OrganizationPage = withPermission(
key={workspace.id}
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">{workspace.name}</div>
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
@@ -735,7 +709,7 @@ const OrganizationPage = withPermission(
new Date().getTime() - new Date(user?.createdAt).getTime() <
30 * 24 * 60 * 60 * 1000
) && (
<div className="mb-4 flex flex-col items-start justify-start px-6 pb-6 pb-0 text-3xl">
<div className="mb-4 flex flex-col items-start justify-start px-6 pb-0 text-3xl">
<p className="mr-4 mb-4 font-semibold text-white">Onboarding Guide</p>
<div className="mb-3 grid w-full grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<LearningItemSquare
+9 -1
View File
@@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import crypto from "crypto";
import { useState } from "react";
import { useEffect, useState } from "react";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
@@ -22,6 +22,7 @@ import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient";
import { useServerConfig } from "@app/context";
import { completeAccountSignupInvite, verifySignupInvite } from "@app/hooks/api/auth/queries";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
@@ -56,6 +57,13 @@ export default function SignupInvite() {
const token = parsedUrl.token as string;
const organizationId = parsedUrl.organization_id as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
const { config } = useServerConfig();
useEffect(() => {
if (!config.allowSignUp) {
router.push("/login");
}
}, [config.allowSignUp]);
// Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria.
const signupErrorCheck = async () => {
@@ -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,
@@ -59,7 +59,7 @@ export const IntegrationsSection = ({
<Link href={`/project/${workspaceId}/settings`} passHref>
<a className="underline underline-offset-2">project settings </a>
</Link>
to re-enable it .
to re-enable it.
</AlertDescription>
</Alert>
</div>
@@ -77,7 +77,7 @@ export const IntegrationsSection = ({
<div className="flex flex-col space-y-4 p-6 pt-0">
{integrations?.map((integration) => (
<div
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6 pb-2"
className="max-w-8xl flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 pb-0"
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
+6 -3
View File
@@ -7,9 +7,9 @@ import { getAuthToken, isLoggedIn } from "@app/reactQuery";
import {
InitialStep,
LDAPStep,
MFAStep,
SAMLSSOStep
} from "./components";
SAMLSSOStep} from "./components";
import { navigateUserToOrg } from "./Login.utils";
export const Login = () => {
@@ -72,7 +72,10 @@ export const Login = () => {
return (
<SAMLSSOStep setStep={setStep} />
);
case 3:
return (
<LDAPStep setStep={setStep} />
);
default:
return <div />;
}
+3 -3
View File
@@ -15,7 +15,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => {
const [password, setPassword] = useState("");
const {
email,
username,
isUserCompleted
} = jwt_decode(providerAuthToken) as any;
@@ -35,7 +35,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => {
return (
<PasswordStep
providerAuthToken={providerAuthToken}
email={email}
email={username}
password={password}
setPassword={setPassword}
setStep={setStep}
@@ -45,7 +45,7 @@ export const LoginSSO = ({ providerAuthToken }: Props) => {
return (
<MFAStep
providerAuthToken={providerAuthToken}
email={email}
email={username}
password={password}
/>
);
@@ -180,7 +180,20 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with SAML SSO
Continue with SAML
</Button>
</div>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
setStep(3);
}}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"
>
Continue with LDAP
</Button>
</div>
<div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6">
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, Input } from "@app/components/v2";
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
type Props = {
setStep: (step: number) => void;
}
export const LDAPStep = ({
setStep
}: Props) => {
const { createNotification } = useNotificationContext();
const [organizationSlug, setOrganizationSlug] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { t } = useTranslation();
// const queryParams = new URLSearchParams(window.location.search);
const handleSubmission = async (e:React.FormEvent) => {
e.preventDefault()
try {
const { nextUrl } = await loginLDAPRedirect({
organizationSlug,
username,
password
});
if (!nextUrl) {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
return;
}
createNotification({
text: "Successfully logged in",
type: "success"
});
// redirects either to /login/sso or /signup/sso
window.open(nextUrl);
window.close();
} catch (err) {
createNotification({
text: "Login unsuccessful. Double-check your credentials and try again.",
type: "error"
});
}
// TODO: add callback port support
// const callbackPort = queryParams.get("callback_port");
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
// window.close();
}
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&apos;s your LDAP Login?
</p>
<form onSubmit={handleSubmission}>
<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={organizationSlug}
onChange={(e) => setOrganizationSlug(e.target.value)}
type="text"
placeholder="Enter your organization slug..."
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
<div className="mt-2 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={username}
onChange={(e) => setUsername(e.target.value)}
type="text"
placeholder="Enter your LDAP username..."
isRequired
autoComplete="email"
id="email"
className="h-12"
/>
</div>
</div>
<div className="mt-2 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={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your LDAP password..."
isRequired
autoComplete="current-password"
id="current-password"
className="select:-webkit-autofill:focus h-10"
/>
</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
type="submit"
colorSchema="primary"
variant="outline_bg"
isFullWidth
className="h-14"
>
{t("login.login")}
</Button>
</div>
</form>
<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 { LDAPStep } from "./LDAPStep";
@@ -1,4 +1,5 @@
export { InitialStep } from "./InitialStep";
export { LDAPStep } from "./LDAPStep";
export { MFAStep } from "./MFAStep";
export { SAMLSSOStep } from "./SAMLSSOStep";
@@ -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;
@@ -113,8 +113,8 @@ export const OrgMembersSection = () => {
/>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure want to remove member with email ${
(popUp?.removeMember?.data as { email: string })?.email || ""
title={`Are you sure want to remove member with username ${
(popUp?.removeMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
deleteKey="confirm"
@@ -41,7 +41,7 @@ type Props = {
popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>,
data?: {
orgMembershipId?: string;
email?: string;
username?: string;
description?: string;
}
) => void;
@@ -141,10 +141,11 @@ 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?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
);
@@ -162,7 +163,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
<THead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Username</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
@@ -174,10 +175,11 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status }) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (
<Tr key={`org-membership-${orgMembershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>{username}</Td>
<Td>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
@@ -206,7 +208,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
))}
</Select>
)}
{(status === "invited" || status === "verified") &&
{(status === "invited" || status === "verified") && email &&
serverDetails?.emailConfigured && (
<Button
isDisabled={!isAllowed}
@@ -231,7 +233,15 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("removeMember", { orgMembershipId, email });
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, username });
}}
size="lg"
colorSchema="danger"
@@ -81,6 +81,18 @@ const SIMPLE_PERMISSION_OPTIONS = [
subtitle: "Define organization level SSO requirements",
icon: faSignIn,
formName: "sso"
},
{
title: "LDAP",
subtitle: "Define organization level LDAP requirements",
icon: faSignIn,
formName: "ldap"
},
{
title: "SCIM",
subtitle: "Define organization level SCIM requirements",
icon: faUsers,
formName: "scim"
}
] as const;
@@ -34,6 +34,8 @@ export const formSchema = z.object({
"incident-contact": generalPermissionSchema,
"secret-scanning": generalPermissionSchema,
sso: generalPermissionSchema,
scim: generalPermissionSchema,
ldap: generalPermissionSchema,
billing: generalPermissionSchema,
identity: generalPermissionSchema
})
@@ -5,12 +5,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import {
IdentityTab,
MemberListTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components";
enum TabSections {
Member = "members",
@@ -23,19 +18,14 @@ export const MembersPage = withProjectPermission(
() => {
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">
Project Access Control
</p>
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
<Tabs defaultValue={TabSections.Member}>
<TabList>
<Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
New
</div>
</div>
</Tab>
<Tab value={TabSections.ServiceTokens}>Service Tokens</Tab>
@@ -0,0 +1,459 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const IdentityTemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Set Role Temporarily
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
identityId: string;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const IdentityRoles = ({
roles = [],
disableEdit = false,
identityId
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
try {
await updateIdentityWorkspaceRole.mutateAsync({
workspaceId,
identityId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated identity role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update identity role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<IdentityTemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
@@ -19,13 +16,11 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetProjectRoles,
useGetWorkspaceIdentityMemberships,
useUpdateIdentityWorkspaceRole
} from "@app/hooks/api";
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityRoles } from "./IdentityRoles";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
@@ -37,39 +32,9 @@ type Props = {
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
const { data: roles } = useGetProjectRoles(workspaceId);
const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole();
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try {
await updateMutateAsync({
identityId,
workspaceId,
role
});
createNotification({
text: "Successfully updated identity role",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update identity role";
createNotification({
text,
type: "error"
});
}
};
return (
<TableContainer>
<Table>
@@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{!isLoading &&
data &&
data.length > 0 &&
data.map(({ identity: { id, name }, role, customRole, createdAt }) => {
data.map(({ identity: { id, name }, roles, createdAt }) => {
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
@@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => {
return (
<Select
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
{(isAllowed) => (
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Link from "next/link";
@@ -9,10 +9,6 @@ import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
decryptAssymmetric,
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
@@ -38,22 +34,21 @@ import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useSubscription,
useUser,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAddUserToWs,
useAddUserToWsE2EE,
useAddUserToWsNonE2EE,
useDeleteUserFromWorkspace,
useGetOrgUsers,
useGetProjectRoles,
useGetUserWsKey,
useGetWorkspaceUsers,
useUpdateUserWorkspaceRole,
useUploadWsKey
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { MemberRoles } from "./MemberRoles";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -63,7 +58,6 @@ type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
export const MemberListTab = () => {
const { createNotification } = useNotificationContext();
const { subscription } = useSubscription();
const { t } = useTranslation();
const { currentOrg } = useOrganization();
@@ -74,12 +68,10 @@ export const MemberListTab = () => {
const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { data: wsKey } = useGetUserWsKey(workspaceId);
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -95,12 +87,12 @@ export const MemberListTab = () => {
formState: { isSubmitting }
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
const { mutateAsync: addUserToWorkspace } = useAddUserToWs();
const { mutateAsync: uploadWsKey } = useUploadWsKey();
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
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");
@@ -113,13 +105,27 @@ export const MemberListTab = () => {
const orgUser = (orgUsers || []).find(({ id }) => id === orgMembershipId);
if (!orgUser) return;
try {
await addUserToWorkspace({
workspaceId,
userPrivateKey,
decryptKey: wsKey,
members: [{ orgMembershipId, userPublicKey: orgUser.user.publicKey }]
});
try { // TODO: update
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,
usernames: [orgUser.user.username]
});
} 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 +142,11 @@ export const MemberListTab = () => {
};
const handleRemoveUser = async () => {
const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
const username = (popUp?.removeMember?.data as { username: string })?.username;
if (!currentOrg?.id) return;
try {
await removeUserFromWorkspace({ workspaceId, membershipId });
await removeUserFromWorkspace({ workspaceId, usernames: [username] });
createNotification({
text: "Successfully removed user from project",
type: "success"
@@ -155,105 +161,29 @@ export const MemberListTab = () => {
handlePopUpClose("removeMember");
};
const isIamOwner = useMemo(
() => members?.find(({ user: u }) => userId === u?.id)?.role === "owner",
[userId, members]
);
const findRoleFromId = useCallback(
(roleId: string) => {
return (roles || []).find(({ id }) => id === roleId);
},
[roles]
);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
try {
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
role as ProjectMembershipRole
);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
description: "You can assign custom roles to members if you upgrade your Infisical plan."
});
return;
}
await updateUserWorkspaceRole({ membershipId, role, workspaceId });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const filterdUsers = useMemo(
() =>
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?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase())
),
[members, searchMemberFilter]
);
const filteredOrgUsers = useMemo(() => {
const wsUserEmails = new Map();
const wsUserUsernames = new Map();
members?.forEach((member) => {
wsUserEmails.set(member.user.email, true);
wsUserUsernames.set(member.user.username, true);
});
return (orgUsers || []).filter(
({ status, user: u }) => status === "accepted" && !wsUserEmails.has(u.email)
({ status, user: u }) => status === "accepted" && !wsUserUsernames.has(u.username)
);
}, [orgUsers, members]);
const onGrantAccess = async (grantedUserId: string, publicKey: string) => {
try {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
if (!PRIVATE_KEY || !wsKey) return;
// assymmetrically decrypt symmetric key with local private key
const key = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey,
privateKey: PRIVATE_KEY
});
await uploadWsKey({
userId: grantedUserId,
nonce,
encryptedKey: ciphertext,
workspaceId: currentWorkspace?.id || ""
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to grant access to user",
type: "error"
});
}
};
const isLoading = isMembersLoading || isRolesLoading;
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
@@ -284,95 +214,69 @@ export const MemberListTab = () => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Username</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isLoading &&
filterdUsers?.map(
({ user: u, inviteEmail, id: membershipId, status, roleId, role }) => {
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
return (
<Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
return (
<Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<MemberRoles
roles={roles}
disableEdit={u.id === user?.id || !isAllowed}
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
}
membershipId={membershipId}
/>
)}
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<>
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(membershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
{status === "completed" && user.email !== email && (
<div className="rounded-md border border-mineshaft-700 bg-white/5 text-white duration-200 hover:bg-primary hover:text-black">
<Button
colorSchema="secondary"
isDisabled={!isAllowed}
onClick={() => onGrantAccess(u?.id, u?.publicKey)}
>
Grant Access
</Button>
</div>
)}
</>
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { username: u.username })
}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { id: membershipId })
}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</Td>
</Tr>
);
}
)}
)}
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && filterdUsers?.length === 0 && (
{!isMembersLoading && filterdUsers?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} />
)}
</TableContainer>
@@ -389,20 +293,20 @@ export const MemberListTab = () => {
<form onSubmit={handleSubmit(onAddMember)}>
<Controller
control={control}
defaultValue={filteredOrgUsers?.[0]?.user?.email}
defaultValue={filteredOrgUsers?.[0]?.user?.username}
name="orgMembershipId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<FormControl label="Username" isError={Boolean(error)} errorText={error?.message}>
<Select
position="popper"
className="w-full"
defaultValue={filteredOrgUsers?.[0]?.user?.email}
defaultValue={filteredOrgUsers?.[0]?.user?.username}
value={field.value}
onValueChange={field.onChange}
>
{filteredOrgUsers.map(({ id: orgUserId, user: u }) => (
<SelectItem value={orgUserId} key={`org-membership-join-${orgUserId}`}>
{u?.email}
{u?.username}
</SelectItem>
))}
</Select>
@@ -441,7 +345,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}
/>
@@ -0,0 +1,473 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const TemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
membershipId: string;
onOpenUpgradeModal: (description: string) => void;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const MemberRoles = ({
roles = [],
disableEdit = false,
membershipId,
onOpenUpgradeModal
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const { subscription } = useSubscription();
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateMembershipRole = useUpdateUserWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
const hasCustomRoleSelected = selectedRoles.some(
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
);
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
onOpenUpgradeModal(
"You can assign custom roles to members if you upgrade your Infisical plan."
);
return;
}
try {
await updateMembershipRole.mutateAsync({
workspaceId,
membershipId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id}>
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Access expired" : "Temporary access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<TemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -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">
@@ -1,10 +1,11 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } 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 { ContentLoader } from "@app/components/v2";
@@ -27,6 +28,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";
@@ -46,9 +48,10 @@ const LOADER_TEXT = [
export const SecretMainPage = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const router = useRouter();
const { permission } = useProjectPermission();
const { createNotification } = useNotificationContext();
const [isVisible, setIsVisible] = useState(false);
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
@@ -74,6 +77,20 @@ export const SecretMainPage = () => {
ProjectPermissionSub.SecretRollback
);
useEffect(() => {
if (
!isWorkspaceLoading &&
!currentWorkspace?.environments.find((env) => env.slug === environment) &&
router.isReady
) {
router.push(`/project/${workspaceId}/secrets/overview`);
createNotification({
text: "No envronment found with given slug",
type: "error"
});
}
}, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]);
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
// fetch secrets
@@ -219,6 +236,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,6 +1,6 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faClock } from "@fortawesome/free-solid-svg-icons";
import { faClock, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
@@ -9,20 +9,28 @@ import { z } from "zod";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
const ReminderFormSchema = z.object({
note: z.string().optional(),
note: z.string().optional().nullable(),
days: z
.number()
.min(1, { message: "Must be at least 1 day" })
.max(365, { message: "Must be less than 365 days" })
.nullable()
});
export type TReminderFormSchema = z.infer<typeof ReminderFormSchema>;
interface ReminderFormProps {
isOpen: boolean;
repeatDays?: number | null;
note?: string | null;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
}
export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps) => {
export const CreateReminderForm = ({
isOpen,
onOpenChange,
repeatDays,
note
}: ReminderFormProps) => {
const {
register,
control,
@@ -31,32 +39,31 @@ export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps)
handleSubmit,
formState: { isSubmitting }
} = useForm<TReminderFormSchema>({
defaultValues: {
days: repeatDays || undefined,
note: note || ""
},
resolver: zodResolver(ReminderFormSchema)
});
const handleFormSubmit = async (data: TReminderFormSchema) => {
console.log(data);
onOpenChange(false, data);
};
useEffect(() => {
if (isOpen) {
reset();
reset({
days: repeatDays || undefined,
note: note || ""
});
}
}, [isOpen]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Create secret reminder"
// ? QUESTION: Should this specifically say its for secret rotation?
// ? Or should we be call it something more generic?
subTitle={
<div>
Set up a reminder for when this secret should be rotated. Everyone with access to this
project will be notified when the reminder is triggered.
</div>
}
title={`${repeatDays ? "Update" : "Create"} reminder`}
subTitle="Set up a reminder for when this secret should be rotated. Everyone with access to this project will be notified when the reminder is triggered."
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-2">
@@ -68,7 +75,7 @@ export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps)
<>
<FormControl
className="mb-0"
label="How many days between"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
@@ -76,6 +83,7 @@ export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps)
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number"
placeholder="31"
value={field.value || undefined}
/>
</FormControl>
<div
@@ -84,7 +92,8 @@ export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps)
field.value ? "opacity-60" : "opacity-0"
)}
>
Every {field.value > 1 ? `${field.value} days` : "day"}
A reminder will be sent every{" "}
{field.value && field.value > 1 ? `${field.value} days` : "day"}
</div>
</>
)}
@@ -102,17 +111,27 @@ export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps)
/>
</FormControl>
</div>
<div className="mt-7 flex items-center">
<div className="mt-7 flex items-center space-x-4">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
className=""
leftIcon={<FontAwesomeIcon icon={faClock} />}
type="submit"
>
Create reminder
{repeatDays ? "Update" : "Create"} reminder
</Button>
{repeatDays && (
<Button
key="layout-cancel-create-project"
onClick={() => onOpenChange(false, { days: null, note: null })}
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
>
Delete reminder
</Button>
)}
<Button
key="layout-cancel-create-project"
onClick={() => onOpenChange(false)}
@@ -5,6 +5,7 @@ import {
faCheckCircle,
faCircle,
faCircleDot,
faClock,
faPlus,
faTag
} from "@fortawesome/free-solid-svg-icons";
@@ -33,9 +34,11 @@ import {
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetSecretVersion } from "@app/hooks/api";
import { DecryptedSecret, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
import { CreateReminderForm } from "./CreateReminderForm";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
@@ -147,262 +150,317 @@ export const SecretDetailSidebar = ({
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
};
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote");
return (
<Drawer
onOpenChange={(state) => {
if (isOpen && isDirty) {
if (
// eslint-disable-next-line no-alert
window.confirm("You have edited the secret. Are you sure you want to reset the change?")
) {
onToggle(false);
reset();
} else return;
}
onToggle(state);
}}
isOpen={isOpen}
>
<DrawerContent title="Secret">
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
<div className="flex h-full flex-col">
<FormControl label="Key">
<Input isDisabled {...register("key")} />
</FormControl>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<FormControl label="Value">
<SecretInput
isReadOnly={isReadOnly}
key="secret-value"
isDisabled={isOverridden || !isAllowed}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
autoFocus={false}
/>
</FormControl>
)}
/>
)}
</ProjectPermissionCan>
<div className="mb-2 border-b border-mineshaft-600 pb-4">
<>
<CreateReminderForm
repeatDays={secretReminderRepeatDays}
note={secretReminderNote}
isOpen={createReminderFormOpen}
onOpenChange={(_, data) => {
setCreateReminderFormOpen.toggle();
if (data) {
setValue("reminderRepeatDays", data.days, { shouldDirty: true });
setValue("reminderNote", data.note, { shouldDirty: true });
}
}}
/>
<Drawer
onOpenChange={(state) => {
if (isOpen && isDirty) {
if (
// eslint-disable-next-line no-alert
window.confirm(
"You have edited the secret. Are you sure you want to reset the change?"
)
) {
onToggle(false);
reset();
} else return;
}
onToggle(state);
}}
isOpen={isOpen}
>
<DrawerContent title="Secret">
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
<div className="flex h-full flex-col">
<FormControl label="Key">
<Input isDisabled {...register("key")} />
</FormControl>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Switch
isDisabled={!isAllowed}
id="personal-override"
onCheckedChange={handleOverrideClick}
isChecked={isOverridden}
>
Override with a personal value
</Switch>
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<FormControl label="Value">
<SecretInput
isReadOnly={isReadOnly}
key="secret-value"
isDisabled={isOverridden || !isAllowed}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
autoFocus={false}
/>
</FormControl>
)}
/>
)}
</ProjectPermissionCan>
</div>
{isOverridden && (
<Controller
name="valueOverride"
control={control}
render={({ field }) => (
<FormControl label="Value Override">
<SecretInput
isReadOnly={isReadOnly}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
/>
</FormControl>
)}
/>
)}
<FormControl label="Tags" className="">
<div className="grid auto-cols-min grid-flow-col gap-2 overflow-hidden pt-2">
{fields.map(({ tagColor, id: formId, name, id }) => (
<Tag
className="flex w-min items-center space-x-2"
key={formId}
onClose={() => {
if (cannotEditSecret) {
createNotification({ type: "error", text: "Access denied" });
return;
}
const tag = tags?.find(({ id: tagId }) => id === tagId);
if (tag) handleTagSelect(tag);
}}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add"
variant="outline_bg"
size="xs"
className="rounded-md"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end" className="z-[100]">
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, name, color } = tag;
const isSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
);
})}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<DropdownMenuItem asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
isDisabled={!isAllowed}
>
Create a tag
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FormControl>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<div className="my-2 mb-6 border-b border-mineshaft-600 pb-4">
<Controller
control={control}
name="skipMultilineEncoding"
render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Switch
id="skipmultiencoding-option"
onCheckedChange={(isChecked) => onChange(!isChecked)}
isChecked={!value}
onBlur={onBlur}
isDisabled={!isAllowed}
className="items-center"
>
Enable multi line encoding
<Tooltip
content="Infisical encodes multiline secrets by escaping newlines and wrapping in quotes. To disable, enable this option"
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-1" size="sm" />
</Tooltip>
</Switch>
)}
</ProjectPermissionCan>
)}
/>
</div>
<div className="dark mb-4 flex-grow text-sm text-bunker-300">
<div className="mb-2">Version History</div>
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, value, id }, i) => (
<div key={id} className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
<div className="break-all font-mono">{value}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-4">
<div className="mb-2 border-b border-mineshaft-600 pb-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
isFullWidth
type="submit"
isDisabled={isSubmitting || !isDirty || !isAllowed}
isLoading={isSubmitting}
<Switch
isDisabled={!isAllowed}
id="personal-override"
onCheckedChange={handleOverrideClick}
isChecked={isOverridden}
>
Save Changes
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
Delete
</Button>
Override with a personal value
</Switch>
)}
</ProjectPermissionCan>
</div>
{isOverridden && (
<Controller
name="valueOverride"
control={control}
render={({ field }) => (
<FormControl label="Value Override">
<SecretInput
isReadOnly={isReadOnly}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
/>
</FormControl>
)}
/>
)}
<FormControl label="Tags" className="">
<div className="grid auto-cols-min grid-flow-col gap-2 overflow-hidden pt-2">
{fields.map(({ tagColor, id: formId, name, id }) => (
<Tag
className="flex w-min items-center space-x-2"
key={formId}
onClose={() => {
if (cannotEditSecret) {
createNotification({ type: "error", text: "Access denied" });
return;
}
const tag = tags?.find(({ id: tagId }) => id === tagId);
if (tag) handleTagSelect(tag);
}}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{name}</div>
</Tag>
))}
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add"
variant="outline_bg"
size="xs"
className="rounded-md"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end" className="z-[100]">
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, name, color } = tag;
const isSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
);
})}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<DropdownMenuItem asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
isDisabled={!isAllowed}
>
Create a tag
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FormControl>
<FormControl label="Reminder">
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
<div className="mt-2 ml-1 flex items-center justify-between">
<div className="flex items-center space-x-2">
<FontAwesomeIcon className="text-primary-500" icon={faClock} />
<span className="text-sm text-bunker-300">
Reminder every {secretReminderRepeatDays}{" "}
{secretReminderRepeatDays > 1 ? "days" : "day"}
</span>
</div>
<div>
<Button
className="px-2 py-1"
variant="outline_bg"
onClick={() => setCreateReminderFormOpen.on()}
>
Update
</Button>
</div>
</div>
) : (
<div className="mt-2 ml-1 flex items-center space-x-2">
<Button
className="px-2 py-1"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faClock} />}
onClick={() => setCreateReminderFormOpen.on()}
>
Create Reminder
</Button>
</div>
)}
</FormControl>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<div className="my-2 mb-6 border-b border-mineshaft-600 pb-4">
<Controller
control={control}
name="skipMultilineEncoding"
render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Switch
id="skipmultiencoding-option"
onCheckedChange={(isChecked) => onChange(!isChecked)}
isChecked={!value}
onBlur={onBlur}
isDisabled={!isAllowed}
className="items-center"
>
Enable multi line encoding
<Tooltip
content="Infisical encodes multiline secrets by escaping newlines and wrapping in quotes. To disable, enable this option"
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-1" size="sm" />
</Tooltip>
</Switch>
)}
</ProjectPermissionCan>
)}
/>
</div>
<div className="dark mb-4 flex-grow text-sm text-bunker-300">
<div className="mb-2">Version History</div>
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, value, id }, i) => (
<div key={id} className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
<div className="break-all font-mono">{value}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button
isFullWidth
type="submit"
isDisabled={isSubmitting || !isDirty || !isAllowed}
isLoading={isSubmitting}
>
Save Changes
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
Delete
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
</div>
</div>
</form>
</DrawerContent>
</Drawer>
</form>
</DrawerContent>
</Drawer>
</>
);
};
@@ -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,16 +104,19 @@ export const SecretItem = memo(
setValue,
reset,
getValues,
formState: { isDirty, isSubmitting }
trigger,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: secret,
values: secret,
resolver: zodResolver(formSchema)
});
const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote");
const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment"));
const hasReminder = Boolean(watch("reminderRepeatDays"));
const selectedTags = watch("tags", []);
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
@@ -191,6 +194,8 @@ export const SecretItem = memo(
return (
<>
<CreateReminderForm
repeatDays={secretReminderRepeatDays}
note={secretReminderNote}
isOpen={createReminderFormOpen}
onOpenChange={(_, data) => {
setCreateReminderFormOpen.toggle();
@@ -231,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"
/>
)}
/>
@@ -317,7 +325,7 @@ export const SecretItem = memo(
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, name, color } = tag;
@@ -380,22 +388,24 @@ export const SecretItem = memo(
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
hasReminder && "w-5 text-primary"
Boolean(secretReminderRepeatDays) && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-reminder"
>
<Tooltip content="Reminder">
<Tooltip
content={
secretReminderRepeatDays && secretReminderRepeatDays > 0
? `Every ${secretReminderRepeatDays} day${
Number(secretReminderRepeatDays) > 1 ? "s" : ""
}
`
: "Reminder"
}
>
<FontAwesomeIcon
onClick={() => {
if (!hasReminder) {
setCreateReminderFormOpen.on();
} else {
setValue("reminderRepeatDays", null, { shouldDirty: true });
setValue("reminderNote", null, { shouldDirty: true });
}
}}
onClick={() => setCreateReminderFormOpen.on()}
icon={faClock}
/>
</Tooltip>
@@ -491,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"
@@ -501,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
@@ -2,22 +2,35 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faArrowDown,
faArrowUp,
faFolderBlank,
faMagnifyingGlass
faFolderPlus,
faList,
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 { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
@@ -29,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,
@@ -39,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";
@@ -86,6 +108,11 @@ export const SecretOverviewPage = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = currentWorkspace?.environments || [];
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
useEffect(() => {
setVisibleEnvs(userAvailableEnvs);
}, [userAvailableEnvs]);
const {
data: secrets,
@@ -109,6 +136,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
@@ -149,6 +210,14 @@ export const SecretOverviewPage = () => {
}
};
const handleEnvSelect = (envId: string) => {
if (visibleEnvs.map((env) => env.id).includes(envId)) {
setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId));
} else {
setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId)));
}
};
const handleSecretUpdate = async (env: string, key: string, value: string, secretId?: string) => {
try {
await updateSecretV3({
@@ -225,7 +294,7 @@ export const SecretOverviewPage = () => {
}
}
const query: Record<string, string> = { ...router.query, env: slug };
const envIndex = userAvailableEnvs.findIndex((el) => slug === el.slug);
const envIndex = visibleEnvs.findIndex((el) => slug === el.slug);
if (envIndex !== -1) {
router.push({
pathname: "/project/[id]/secrets/[env]",
@@ -268,159 +337,315 @@ 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-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 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">
{userAvailableEnvs.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
size="sm"
className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10"
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
<Tooltip content="Choose visible environments" className="mb-2">
<FontAwesomeIcon icon={faList} />
</Tooltip>
</IconButton>
</div>
</Th>
{userAvailableEnvs?.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)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={() => handleEnvSelect(envId)}
key={envId}
icon={
isEnvSelected ? (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
) : (
<FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} />
)
}
iconPos="left"
>
{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"
/>
<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>
)}
{isTableEmpty && !isTableLoading && (
<Tr>
<Td colSpan={userAvailableEnvs.length + 1}>
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
<Link
href={{
pathname: "/project/[id]/secrets/[env]",
query: { id: workspaceId, env: userAvailableEnvs?.[0]?.slug }
}}
<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>
{userAvailableEnvs.length > 0 && (
<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
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</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"
/>
)}
{userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState title="You have no visible environments" iconSize="3x" />
</Td>
</Tr>
)}
{userAvailableEnvs.length === 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
title="You have no environments, start by adding some"
iconSize="3x"
>
<Link
href={{
pathname: "/project/[id]/settings",
query: { id: workspaceId },
hash: "environments"
}}
>
<Button
className="mt-4"
variant="outline_bg"
colorSchema="primary"
size="md"
>
Add environments
</Button>
</Link>
</EmptyState>
</Td>
</Tr>
)}
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
title={
searchFilter
? "No secret found for your search, add one now"
: "Let's add some secrets"
}
icon={faFolderBlank}
iconSize="3x"
>
<Button
className="mt-4"
variant="outline_bg"
colorSchema="primary"
size="md"
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
>
Go to {userAvailableEnvs?.[0]?.name}
Add Secrets
</Button>
</Link>
</EmptyState>
</Td>
</Tr>
)}
{!isTableLoading &&
filteredFolderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={userAvailableEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
/>
))}
{!isTableLoading &&
(userAvailableEnvs?.length > 0 ? (
</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}
@@ -428,43 +653,57 @@ export const SecretOverviewPage = () => {
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={userAvailableEnvs}
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>
{userAvailableEnvs.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>
))}
</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>
))}
</Tr>
</TFoot>
</Table>
</TableContainer>
{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>
</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,232 @@
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 py-2">
{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}`}
className="!justify-start"
>
<span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
<span title={env.name} className="truncate">
{env.name}
</span>
<span>
{getSecretByKey(env.slug, newSecretKey) && (
<Tooltip
className="max-w-[150px]"
content="Secret already exists, and it will be overwritten"
>
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
</Tooltip>
)}
</span>
</span>
</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"
@@ -15,6 +15,7 @@ import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
type Props = {
secretKey: string;
@@ -105,6 +106,13 @@ export const SecretOverviewTableRow = ({
width: `calc(${expandableColWidth}px - 1rem)`
}}
>
<SecretRenameRow
secretKey={secretKey}
environments={environments}
secretPath={secretPath}
getSecretByKey={getSecretByKey}
/>
<TableContainer>
<table className="secret-table">
<thead>
@@ -140,8 +148,13 @@ export const SecretOverviewTableRow = ({
key={`secret-expanded-${slug}-${secretKey}`}
className="hover:bg-mineshaft-700"
>
<td className="flex" style={{ padding: "0.25rem 1rem" }}>
<div className="flex h-8 items-center">{name}</div>
<td
className="flex h-full items-center"
style={{ padding: "0.25rem 1rem" }}
>
<div title={name} className="flex h-8 w-[8rem] items-center ">
<span className="truncate">{name}</span>
</div>
</td>
<td className="col-span-2 h-8 w-full">
<SecretEditRow
@@ -0,0 +1,258 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheck, faClose, faCopy } 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 { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api";
import { DecryptedSecret } from "@app/hooks/api/types";
import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils";
type Props = {
secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[];
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
};
export const formSchema = z.object({
key: z.string().trim().min(1, { message: "Secret key is required" })
});
type TFormSchema = z.infer<typeof formSchema>;
function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) {
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const { createNotification } = useNotificationContext();
const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey));
const isReadOnly = environments.some((env) => {
const environment = env.slug;
const isSecretInEnvReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
if (isSecretInEnvReadOnly) {
return true;
}
return false;
});
const isOverriden = secrets.some(
(secret) =>
secret?.overrideAction === SecretActionType.Created ||
secret?.overrideAction === SecretActionType.Modified
);
const workspaceId = currentWorkspace?.id || "";
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
const [isSecNameCopied, setIsSecNameCopied] = useToggle(false);
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const {
handleSubmit,
control,
reset,
trigger,
getValues,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: { key: secretKey },
values: { key: secretKey },
resolver: zodResolver(formSchema)
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSecNameCopied) {
timer = setTimeout(() => setIsSecNameCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isSecNameCopied]);
const handleFormSubmit = async (data: TFormSchema) => {
if (!data.key) {
createNotification({
type: "error",
text: "Secret name cannot be empty"
});
return;
}
const promises = secrets
.filter((secret) => !!secret)
.map((secret) => {
if (!secret) return null;
return updateSecretV3({
environment: secret?.env,
workspaceId,
secretPath,
secretName: secret.key,
secretId: secret.id,
secretValue: secret.value || "",
type: "shared",
latestFileKey: decryptFileKey!,
tags: secret.tags.map((tag) => tag.id),
secretComment: secret.comment,
secretReminderRepeatDays: secret.reminderRepeatDays,
secretReminderNote: secret.reminderNote,
skipMultilineEncoding: secret.skipMultilineEncoding,
newSecretName: data.key
});
});
await Promise.all(promises)
.then(() => {
createNotification({
type: "success",
text: "Successfully renamed the secret"
});
})
.catch(() => {
createNotification({
type: "error",
text: "Error renaming the secret"
});
});
};
const copyTokenToClipboard = () => {
const [key] = getValues(["key"]);
navigator.clipboard.writeText(key as string);
setIsSecNameCopied.on();
};
return (
<form
onSubmit={handleSubmit(handleFormSubmit)}
className="secret-table relative mb-2 flex w-full flex-row items-center justify-between overflow-hidden rounded-lg border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter"
>
<div className="flex h-11 flex-1 flex-shrink-0 items-center">
<span className="flex h-full min-w-[11rem] items-center justify-start border-r-2 border-mineshaft-600 px-4">
Key
</span>
<Controller
name="key"
control={control}
render={({ field, fieldState: { error } }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
isDisabled={isOverriden}
placeholder={error?.message}
onKeyUp={() => trigger("key")}
isError={Boolean(error)}
{...field}
className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
</div>
{isReadOnly || isOverriden ? (
<span className="mr-5 rounded-md bg-mineshaft-500 px-2">Read Only</span>
) : (
<div className="group flex w-20 items-center justify-center border-l border-mineshaft-500 py-1">
<AnimatePresence exitBeforeEnter>
{!isDirty ? (
<motion.div
key="options"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="Copy secret name">
<IconButton
ariaLabel="copy-value"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isSecNameCopied ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</motion.div>
) : (
<motion.div
key="options-save"
className="flex flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content={errors.key ? errors.key.message : "Save"}>
<IconButton
ariaLabel="more"
variant="plain"
type="submit"
size="md"
className={twMerge(
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting || Boolean(errors.key)}
>
{isSubmitting ? (
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon
icon={faCheck}
size="lg"
className={twMerge("text-primary", errors.key && "text-mineshaft-400")}
/>
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</form>
);
}
export default SecretRenameRow;
@@ -351,7 +351,7 @@ export const SecretRotationPage = withProjectPermission(
{!isRotationProviderLoading &&
secretRotationProviders?.providers.map((provider) => (
<div
className="group relative flex h-32 cursor-pointer flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10"
className="group relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10"
key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0}
role="button"
@@ -362,8 +362,8 @@ export const SecretRotationPage = withProjectPermission(
>
<img
src={`/images/secretRotation/${provider.image}`}
height={70}
width={70}
className="max-h-16"
style={{ maxWidth: "6rem" }}
alt="rotation provider logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
@@ -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) => (
@@ -7,7 +7,7 @@ export const OrgSettingsPage = () => {
return (
<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="max-w-4xl w-full px-6">
<div className="mb-4">
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
</div>
@@ -0,0 +1,229 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useCreateLDAPConfig,
useGetLDAPConfig,
useUpdateLDAPConfig
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const LDAPFormSchema = z.object({
url: z.string().default(""),
bindDN: z.string().default(""),
bindPass: z.string().default(""),
searchBase: z.string().default(""),
caCert: z.string().optional()
});
export type TLDAPFormData = z.infer<typeof LDAPFormSchema>;
type Props = {
popUp: UsePopUpState<["addLDAP"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addLDAP"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addLDAP"]>, state?: boolean) => void;
};
export const LDAPModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateLDAPConfig();
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateLDAPConfig();
const { data } = useGetLDAPConfig(currentOrg?.id?? "");
const {
control,
handleSubmit,
reset,
} = useForm<TLDAPFormData>({
resolver: zodResolver(LDAPFormSchema)
})
useEffect(() => {
if (data) {
reset({
url: data?.url ?? "",
bindDN: data?.bindDN ?? "",
bindPass: data?.bindPass ?? "",
searchBase: data?.searchBase ?? "",
caCert: data?.caCert ?? ""
});
}
}, [data]);
const onSSOModalSubmit = async ({
url,
bindDN,
bindPass,
searchBase,
caCert
}: TLDAPFormData) => {
try {
if (!currentOrg) return;
if (!data) {
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
caCert
});
} else {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
caCert
});
}
handlePopUpClose("addLDAP");
createNotification({
text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} LDAP configuration`,
type: "error"
});
}
}
return (
<Modal
isOpen={popUp?.addLDAP?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addLDAP", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP configuration">
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="URL"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="ldaps://ldap.myorg.com:636"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="bindDN"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bind DN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="cn=infisical,ou=Users,dc=example,dc=com"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bind Pass"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
type="password"
placeholder="********"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Search Base / User DN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="ou=people,dc=acme,dc=com"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="caCert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA 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("addLDAP")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}
@@ -2,14 +2,18 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgScimSection } from "./OrgSCIMSection";
import { OrgSSOSection } from "./OrgSSOSection";
export const OrgAuthTab = withPermission(
() => {
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgGeneralAuthSection />
<OrgSSOSection />
<OrgLDAPSection />
<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,27 +52,51 @@ 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"
});
}
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="flex-1 text-xl font-semibold text-white mb-8">Settings</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
>
Enforce SAML SSO
</Switch>
)}
</OrgPermissionCan>
</div>
<>
{/* <div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Allow users to send invites</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-org-invites"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Allow members to invite new users to this organization</p>
</div> */}
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce SAML SSO</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Enforce members to authenticate via SAML to access this organization</p>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
/>
</>
);
}
@@ -0,0 +1,139 @@
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 {
useCreateLDAPConfig,
useGetLDAPConfig,
useUpdateLDAPConfig
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { LDAPModal } from "./LDAPModal";
export const OrgLDAPSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { createNotification } = useNotificationContext();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateLDAPConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addLDAP",
"upgradePlan"
] as const);
const { mutateAsync: createMutateAsync } = useCreateLDAPConfig();
const handleLDAPToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} LDAP`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} LDAP`,
type: "error"
});
}
};
const addLDAPBtnClick = async () => {
try {
if (subscription?.ldap && currentOrg) {
if (!data) {
// case: LDAP is not configured
// -> initialize empty LDAP configuration
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url: "",
bindDN: "",
bindPass: "",
searchBase: "",
});
}
handlePopUpOpen("addLDAP");
} else {
handlePopUpOpen("upgradePlan");
}
} catch (err) {
console.error(err);
}
};
return (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Button
onClick={addLDAPBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable LDAP</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleLDAPToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
>
Enable
</Switch>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Allow members to authenticate into Infisical with LDAP</p>
</div>
)}
<LDAPModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use LDAP authentication if you switch to Infisical's Enterprise plan."
/>
</>
);
};
@@ -0,0 +1,117 @@
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 (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex justify-between items-center">
<h2 className="text-md text-mineshaft-100">SCIM</h2>
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Button
onClick={addScimTokenBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Manage SCIM configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex justify-between items-center">
<h2 className="text-md text-mineshaft-100">Enable SCIM</h2>
<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}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Allow member provisioning/deprovisioning with SCIM</p>
</div>
<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."
/>
</>
);
}
@@ -1,7 +1,3 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch, UpgradePlanModal } from "@app/components/v2";
@@ -16,12 +12,6 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { SSOModal } from "./SSOModal";
const ssoAuthProviderMap: { [key: string]: string } = {
"okta-saml": "Okta SAML",
"azure-saml": "Azure SAML",
"jumpcloud-saml": "JumpCloud SAML"
};
export const OrgSSOSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
@@ -38,6 +28,11 @@ export const OrgSSOSection = (): JSX.Element => {
const handleSamlSSOToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
@@ -83,74 +78,55 @@ export const OrgSSOSection = (): JSX.Element => {
};
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">SAML SSO Configuration</h2>
{!isLoading && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button
onClick={addSSOBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
{data ? "Update SAML SSO" : "Set up SAML SSO"}
</Button>
)}
</OrgPermissionCan>
)}
</div>
{data && (
<div className="mb-4">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleSamlSSOToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
>
Enable SAML SSO
</Switch>
)}
</OrgPermissionCan>
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">SAML</h2>
{!isLoading && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button
onClick={addSSOBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
)}
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">SSO identifier</h3>
<p className="text-md text-gray-400">{data && data.id !== "" ? data.id : "-"}</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">Type</h3>
<p className="text-md text-gray-400">
{data && data.authProvider !== "" ? ssoAuthProviderMap[data.authProvider] : "-"}
</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">Entrypoint</h3>
<p className="text-md text-gray-400">
{data && data.entryPoint !== "" ? data.entryPoint : "-"}
</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">Issuer</h3>
<p className="text-md text-gray-400">{data && data.issuer !== "" ? data.issuer : "-"}</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">Last Logged In</h3>
<p className="text-md text-gray-400">{data?.lastUsed ? format(new Date(data?.lastUsed), "yyyy-MM-dd HH:mm:ss") : "-"}</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>
<p className="text-sm text-mineshaft-300">Manage SAML authentication configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable SAML</h2>
{!isLoading && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleSamlSSOToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Allow members to authenticate into Infisical with SAML</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."
/>
</>
);
};
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
@@ -21,25 +21,27 @@ 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
const schema = z
.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")
authProvider: z.string().min(1, "SSO Type is required"),
entryPoint: z.string().default(""),
issuer: z.string().default(""),
cert: z.string().default("")
})
.required();
export type AddSSOFormData = yup.InferType<typeof schema>;
export type AddSSOFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addSSO"]>;
@@ -58,7 +60,7 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
defaultValues: {
authProvider: AuthProvider.OKTA_SAML
},
resolver: yupResolver(schema)
resolver: zodResolver(schema)
});
useEffect(() => {
@@ -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",
@@ -163,7 +173,7 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
reset();
}}
>
<ModalContent title="Add SSO">
<ModalContent title="Manage SAML configuration">
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
@@ -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>
);
};
@@ -45,18 +45,21 @@ export const OrgDeleteSection = () => {
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
<Button
isLoading={isLoading}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteOrg")}
isDisabled={Boolean(membership && membership.role !== "admin")}
>
{`Delete ${currentOrg?.name}`}
</Button>
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<p className="mb-4 text-md text-mineshaft-100">Danger Zone</p>
<Button
isLoading={isLoading}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteOrg")}
isDisabled={Boolean(membership && membership.role !== "admin")}
>
{`Delete ${currentOrg?.name}`}
</Button>
</div>
<DeleteActionModal
isOpen={popUp.deleteOrg.isOpen}
title="Are you sure want to delete this organization?"
@@ -65,6 +68,6 @@ export const OrgDeleteSection = () => {
deleteKey="confirm"
onDeleteApproved={handleDeleteOrgSubmit}
/>
</div>
</>
);
};
@@ -3,15 +3,12 @@ import { useOrgPermission } from "@app/context";
import { OrgDeleteSection } from "../OrgDeleteSection";
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection";
import { OrgSlugChangeSection } from "../OrgSlugChangeSection";
export const OrgGeneralTab = () => {
const { membership } = useOrgPermission();
return (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgNameChangeSection />
<OrgSlugChangeSection />
<OrgIncidentContactsSection />
{membership && membership.role === "admin" && <OrgDeleteSection />}
</div>
@@ -18,9 +18,10 @@ export const OrgIncidentContactsSection = () => {
const { permission } = useOrgPermission();
return (
<div className="p-4 bg-mineshaft-900 mb-6 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>
<>
<hr className="border-mineshaft-600" />
<div className="flex items-center justify-between pt-4">
<p className="text-md text-mineshaft-100">{t("section.incident.incident-contacts")}</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.IncidentAccount}>
{(isAllowed) => (
<Button
@@ -35,16 +36,18 @@ export const OrgIncidentContactsSection = () => {
)}
</OrgPermissionCan>
</div>
{permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount) ? (
<OrgIncidentContactsTable />
) : (
<PermissionDeniedBanner />
)}
<div className="py-4">
{permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount) ? (
<OrgIncidentContactsTable />
) : (
<PermissionDeniedBanner />
)}
</div>
<AddOrgIncidentContactModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
</>
);
};
@@ -10,7 +10,12 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
import { useUpdateOrg } from "@app/hooks/api";
const formSchema = yup.object({
name: yup.string().required().label("Project Name")
name: yup.string().required().label("Organization Name").max(64, "Too long, maximum length is 64 characters"),
slug: yup
.string()
.matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
.required()
.label("Organization Slug")
});
type FormData = yup.InferType<typeof formSchema>;
@@ -25,40 +30,44 @@ export const OrgNameChangeSection = (): JSX.Element => {
useEffect(() => {
if (currentOrg) {
reset({ name: currentOrg.name });
reset({
name: currentOrg.name,
slug: currentOrg.slug
});
}
}, [currentOrg]);
const onFormSubmit = async ({ name }: FormData) => {
const onFormSubmit = async ({ name, slug }: FormData) => {
try {
if (!currentOrg?.id) return;
if (name === "") return;
await mutateAsync({ orgId: currentOrg?.id, name });
await mutateAsync({
orgId: currentOrg?.id,
name,
slug
});
createNotification({
text: "Successfully renamed organization",
text: "Successfully updated organization details",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to rename organization",
text: "Failed to update organization details",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Organization Name</p>
<div className="mb-2 max-w-md">
<form onSubmit={handleSubmit(onFormSubmit)} className="py-4">
<div className="">
<h2 className="mb-2 text-md text-mineshaft-100">Organization Name</h2>
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<FormControl isError={Boolean(error)} errorText={error?.message} className="max-w-md">
<Input placeholder="Acme Corp" {...field} />
</FormControl>
)}
@@ -66,6 +75,19 @@ export const OrgNameChangeSection = (): JSX.Element => {
name="name"
/>
</div>
<div className="py-4">
<h2 className="mb-2 text-md text-mineshaft-100">Organization Slug</h2>
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="max-w-md">
<Input placeholder="acme" {...field} />
</FormControl>
)}
control={control}
name="slug"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
@@ -81,4 +103,4 @@ export const OrgNameChangeSection = (): JSX.Element => {
</OrgPermissionCan>
</form>
);
};
};

Some files were not shown because too many files have changed in this diff Show More