MVP preliminary idea for initial sync behavior

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