mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-22 10:12:15 +00:00
Compare commits
40 Commits
daniel/upd
...
integratio
Author | SHA1 | Date | |
---|---|---|---|
|
fbfe694fc0 | ||
|
a078cb6059 | ||
|
132de1479d | ||
|
d4a76b3621 | ||
|
331dcd4d79 | ||
|
025f64f068 | ||
|
05d7f94518 | ||
|
b58e32c754 | ||
|
fb6a085bf9 | ||
|
6c533f89d3 | ||
|
1049f95952 | ||
|
e618d5ca5f | ||
|
d659250ce8 | ||
|
87363eabfe | ||
|
d1b9c316d8 | ||
|
b9867c0d06 | ||
|
afa2f383c5 | ||
|
39f7354fec | ||
|
c46c0cb1e8 | ||
|
6905ffba4e | ||
|
64fd423c61 | ||
|
da1a7466d1 | ||
|
d3f3f34129 | ||
|
c8fba7ce4c | ||
|
ae51fbb8f2 | ||
|
62910e93ca | ||
|
9e3c632a1f | ||
|
bb094f60c1 | ||
|
a18f3c2919 | ||
|
a852b15a1e | ||
|
dab8f0b261 | ||
|
4293665130 | ||
|
8afa65c272 | ||
|
4c739fd57f | ||
|
bcc2840020 | ||
|
8b3af92d23 | ||
|
9ca58894f0 | ||
|
d131314de0 | ||
|
9c03144f19 | ||
|
5495ffd78e |
@@ -472,9 +472,9 @@
|
||||
"pages": [
|
||||
"sdks/languages/node",
|
||||
"sdks/languages/python",
|
||||
"sdks/languages/java",
|
||||
"sdks/languages/go",
|
||||
"sdks/languages/ruby",
|
||||
"sdks/languages/java",
|
||||
"sdks/languages/csharp"
|
||||
]
|
||||
},
|
||||
|
@@ -1,12 +1,9 @@
|
||||
---
|
||||
title: "Infisical Java SDK"
|
||||
sidebarTitle: "Java"
|
||||
url: "https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-nodejs-sdk"
|
||||
icon: "java"
|
||||
---
|
||||
|
||||
{
|
||||
/*
|
||||
If you're working with Java, the official [Infisical Java SDK](https://github.com/Infisical/sdk/tree/main/languages/java) package is the easiest way to fetch and work with secrets for your application.
|
||||
|
||||
- [Maven Package](https://github.com/Infisical/sdk/packages/2019741)
|
||||
@@ -571,5 +568,4 @@ String decryptedString = client.decryptSymmetric(decryptOptions);
|
||||
</ParamField>
|
||||
|
||||
#### Returns (string)
|
||||
`Plaintext` (string): The decrypted plaintext.
|
||||
*/}
|
||||
`Plaintext` (string): The decrypted plaintext.
|
@@ -16,7 +16,7 @@ From local development to production, Infisical SDKs provide the easiest way for
|
||||
<Card href="https://github.com/Infisical/python-sdk-official" title="Python" icon="python" color="#4c8abe">
|
||||
Manage secrets for your Python application on demand
|
||||
</Card>
|
||||
<Card href="https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-nodejs-sdk" title="Java" icon="java" color="#e41f23">
|
||||
<Card href="/sdks/languages/java" title="Java" icon="java" color="#e41f23">
|
||||
Manage secrets for your Java application on demand
|
||||
</Card>
|
||||
<Card href="/sdks/languages/go" title="Go" icon="golang" color="#367B99">
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 23 KiB |
@@ -40,32 +40,39 @@ export const FilterableSelect = <T,>({
|
||||
...props.components
|
||||
}}
|
||||
classNames={{
|
||||
container: () => "w-full font-inter",
|
||||
control: ({ isFocused }) =>
|
||||
container: ({ isDisabled }) =>
|
||||
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
|
||||
control: ({ isFocused, isDisabled }) =>
|
||||
twMerge(
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
|
||||
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
|
||||
`border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
|
||||
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
|
||||
} `
|
||||
),
|
||||
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
|
||||
input: () => "pl-1 py-0.5",
|
||||
placeholder: () =>
|
||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||
input: () => "pl-1",
|
||||
valueContainer: () =>
|
||||
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`,
|
||||
`px-1 max-h-[8.2rem] ${
|
||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||
} gap-1`,
|
||||
singleValue: () => "leading-7 ml-1",
|
||||
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValueLabel: () => "leading-6 text-sm",
|
||||
multiValueRemove: () => "hover:text-red text-bunker-400",
|
||||
indicatorsContainer: () => "p-1 gap-1",
|
||||
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
|
||||
indicatorSeparator: () => "bg-bunker-400",
|
||||
dropdownIndicator: () => "text-bunker-200 p-1",
|
||||
menuList: () => "flex flex-col gap-1",
|
||||
menu: () =>
|
||||
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
"my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
|
||||
option: ({ isFocused, isSelected }) =>
|
||||
twMerge(
|
||||
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
|
||||
isSelected && "text-mineshaft-200",
|
||||
"hover:cursor-pointer mb-1 rounded text-xs px-3 py-2"
|
||||
"hover:cursor-pointer rounded text-xs px-3 py-2"
|
||||
),
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
}}
|
||||
|
@@ -54,7 +54,7 @@ export const Pagination = ({
|
||||
)}
|
||||
>
|
||||
{startAdornment}
|
||||
<div className="ml-auto mr-6 flex items-center space-x-2">
|
||||
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
|
||||
<div className="text-xs">
|
||||
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
||||
</div>
|
||||
|
12
frontend/src/helpers/members.ts
Normal file
12
frontend/src/helpers/members.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
export const getMemberLabel = (member: TWorkspaceUser) => {
|
||||
const {
|
||||
inviteEmail,
|
||||
user: { firstName, lastName, username, email }
|
||||
} = member;
|
||||
|
||||
return firstName || lastName
|
||||
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||
: username || email || inviteEmail;
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
enum OrgMembershipRole {
|
||||
Admin = "admin",
|
||||
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
|
||||
|
||||
export const isCustomProjectRole = (slug: string) =>
|
||||
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
||||
|
||||
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
|
||||
isCustomOrgRole(roleIdOrSlug)
|
||||
? roles.find((r) => r.id === roleIdOrSlug)
|
||||
: roles.find((r) => r.slug === roleIdOrSlug);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { components, MenuProps, OptionProps } from "react-select";
|
||||
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faEye, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -93,7 +93,7 @@ const ProjectOption = ({
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="mr-2 text-mineshaft-300" icon={faEye} size="sm" />
|
||||
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
|
||||
)}
|
||||
<p className="truncate">{children}</p>
|
||||
{data.isFavorite ? (
|
||||
|
@@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2";
|
||||
|
||||
@@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`;
|
||||
window.location.assign(link);
|
||||
};
|
||||
|
@@ -18,6 +18,7 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
enum AuthMethod {
|
||||
APP = "APP",
|
||||
@@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() {
|
||||
if (selectedAuthMethod === AuthMethod.APP) {
|
||||
router.push("/integrations/select-integration-auth?integrationSlug=github");
|
||||
} else {
|
||||
if (!githubIntegration?.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(
|
||||
"githubactions",
|
||||
"cicd",
|
||||
"connecting-with-github-oauth"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
|
@@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2";
|
||||
|
||||
@@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() {
|
||||
|
||||
if (!integrationOption) return;
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
(gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim();
|
||||
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
useGetOrgIntegrationAuths
|
||||
} from "@app/hooks/api";
|
||||
import { IntegrationAuth } from "@app/hooks/api/types";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
export default function SelectIntegrationAuthPage() {
|
||||
const router = useRouter();
|
||||
@@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() {
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (integrationSlug === "github") {
|
||||
if (!currentIntegration?.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification("githubactions", "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
// for now we only handle Github apps
|
||||
window.location.assign(
|
||||
`https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}`
|
||||
|
@@ -876,7 +876,7 @@ const OrganizationPage = () => {
|
||||
<Pagination
|
||||
className={
|
||||
projectsViewMode === ProjectsViewMode.GRID
|
||||
? "col-span-full border-transparent bg-transparent"
|
||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||
: "rounded-b-md border border-mineshaft-600"
|
||||
}
|
||||
perPage={perPage}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import {
|
||||
@@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) =
|
||||
return { encryptedKey: ciphertext, nonce };
|
||||
};
|
||||
|
||||
export const createIntegrationMissingEnvVarsNotification = (
|
||||
slug: string,
|
||||
type: "cloud" | "cicd" = "cloud",
|
||||
hashtag?: string
|
||||
) =>
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: (
|
||||
<a
|
||||
href={`https://infisical.com/docs/integrations/${type}/${slug}${
|
||||
hashtag ? `#${hashtag}` : ""
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Click here to view docs
|
||||
</a>
|
||||
),
|
||||
title: "Missing Environment Variables"
|
||||
});
|
||||
|
||||
export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => {
|
||||
try {
|
||||
// generate CSRF token for OAuth2 code-token exchange integrations
|
||||
@@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`;
|
||||
break;
|
||||
case "azure-key-vault":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "azure-app-configuration":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "aws-parameter-store":
|
||||
@@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/aws-secret-manager/authorize`;
|
||||
break;
|
||||
case "heroku":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
|
||||
break;
|
||||
case "vercel":
|
||||
if (!integrationOption.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
|
||||
break;
|
||||
case "netlify":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
|
||||
break;
|
||||
case "github":
|
||||
@@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/cloudflare-workers/authorize`;
|
||||
break;
|
||||
case "bitbucket":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
|
||||
break;
|
||||
case "codefresh":
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import {
|
||||
@@ -28,11 +30,17 @@ type Props = {
|
||||
}>;
|
||||
};
|
||||
|
||||
enum IntegrationView {
|
||||
List = "list",
|
||||
New = "new"
|
||||
}
|
||||
|
||||
export const IntegrationsPage = withProjectPermission(
|
||||
({ frameworkIntegrations, infrastructureIntegrations }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
|
||||
|
||||
const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
|
||||
useGetCloudIntegrations();
|
||||
@@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission(
|
||||
const {
|
||||
data: integrations,
|
||||
isLoading: isIntegrationLoading,
|
||||
isFetching: isIntegrationFetching
|
||||
isFetching: isIntegrationFetching,
|
||||
isFetched: isIntegrationsFetched
|
||||
} = useGetWorkspaceIntegrations(workspaceId);
|
||||
|
||||
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
|
||||
@@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission(
|
||||
isIntegrationsEmpty
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
|
||||
}, [isIntegrationsFetched]);
|
||||
|
||||
const handleProviderIntegration = async (provider: string) => {
|
||||
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
|
||||
if (!selectedCloudIntegration) return;
|
||||
@@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission(
|
||||
}
|
||||
};
|
||||
|
||||
if (isIntegrationLoading || isCloudIntegrationsLoading)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ContentLoader text={["Loading integrations..."]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl pb-12 text-white">
|
||||
<IntegrationsSection
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<CloudIntegrationSection
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
|
||||
<div className="relative">
|
||||
{view === IntegrationView.List ? (
|
||||
<motion.div
|
||||
key="view-integrations"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<IntegrationsSection
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
onAddIntegration={() => setView(IntegrationView.New)}
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="add-integration"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<CloudIntegrationSection
|
||||
onViewActiveIntegrations={
|
||||
integrations?.length ? () => setView(IntegrationView.List) : undefined
|
||||
}
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Integrations
|
||||
}
|
||||
);
|
||||
|
@@ -1,11 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faChevronLeft,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
@@ -22,6 +35,7 @@ type Props = {
|
||||
onIntegrationStart: (slug: string) => void;
|
||||
// cb: handle popUpClose child->parent communication pattern
|
||||
onIntegrationRevoke: (slug: string, cb: () => void) => void;
|
||||
onViewActiveIntegrations?: () => void;
|
||||
};
|
||||
|
||||
type TRevokeIntegrationPopUp = { provider: string };
|
||||
@@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegrations = [],
|
||||
integrationAuths = {},
|
||||
onIntegrationStart,
|
||||
onIntegrationRevoke
|
||||
onIntegrationRevoke,
|
||||
onViewActiveIntegrations
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({
|
||||
return sortedIntegrations;
|
||||
}, [cloudIntegrations, currentWorkspace?.environments]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) =>
|
||||
cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5">
|
||||
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({
|
||||
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
|
||||
{onViewActiveIntegrations && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onViewActiveIntegrations}
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
>
|
||||
Back to Integrations
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search cloud integrations..."
|
||||
containerClassName="flex-1 h-min text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
|
||||
))}
|
||||
{!isLoading &&
|
||||
sortedCloudIntegrations?.map((cloudIntegration) => (
|
||||
|
||||
{!isLoading && filteredIntegrations.length ? (
|
||||
filteredIntegrations.map((cloudIntegration) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
@@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegration.isAvailable
|
||||
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
|
||||
: "opacity-50"
|
||||
} flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
onClick={() => {
|
||||
if (!cloudIntegration.isAvailable) return;
|
||||
if (
|
||||
@@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({
|
||||
>
|
||||
<img
|
||||
src={`/images/integrations/${cloudIntegration.image}`}
|
||||
height={70}
|
||||
width={70}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
@@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
|
||||
title="No cloud integrations match search..."
|
||||
icon={faSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">
|
||||
|
@@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="mx-6 mt-4 grid grid-flow-dense gap-3"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{sortedFrameworks.map((framework) => (
|
||||
<a
|
||||
key={`framework-integration-${framework.slug}`}
|
||||
href={framework.docsLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 ${
|
||||
framework?.name?.split(" ").length > 1 ? "px-1 text-sm" : "px-2 text-xl"
|
||||
} w-full max-w-xs text-center`}
|
||||
>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={framework?.name ? 60 : 90}
|
||||
width={framework?.name ? 60 : 90}
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && framework?.image && <div className="h-2" />}
|
||||
{framework?.name && framework.name}
|
||||
</div>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && (
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{framework.name}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
@@ -58,13 +53,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/cli/commands/run"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-2 text-white/90" icon={faKeyboard} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
CLI
|
||||
</div>
|
||||
</a>
|
||||
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-1 text-white/90" icon={faComputer} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
SDKs
|
||||
</div>
|
||||
</a>
|
||||
|
@@ -1,291 +0,0 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowRight,
|
||||
faCalendarCheck,
|
||||
faEllipsis,
|
||||
faRefresh,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { integrationSlugNameMapping } from "public/data/frequentConstants";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
};
|
||||
|
||||
export const ConfiguredIntegrationItem = ({
|
||||
integration,
|
||||
environments,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
key={`integration-${integration?.id.toString()}`}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{environments.find((e) => e.id === integration.envId)?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Secret Path" />
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.secretPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex h-full items-center">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel
|
||||
tooltipText={
|
||||
integration.integration === "github" ? (
|
||||
<div className="text-xs">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "Syncing to all private repositories in the organization"
|
||||
: "Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
label="Integration"
|
||||
/>
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integrationSlugNameMapping[integration.integration]}
|
||||
</div>
|
||||
</div>
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Space" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<div className="flex flex-row">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Org" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.owner || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Project" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetService || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Env" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetEnvironment || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
|
||||
{(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
|
||||
`${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") &&
|
||||
`${integration.owner}/${integration.app}`) ||
|
||||
integration.app}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="min-w-[8rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Workspace" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Group" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Organization" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.owner}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Category" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.metadata?.secretSuffix || "-"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex cursor-default space-x-3">
|
||||
{integration.isSynced != null && integration.lastUsed != null && (
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<Tooltip
|
||||
center
|
||||
className="max-w-xs whitespace-normal break-words"
|
||||
content={
|
||||
<div className="flex max-h-[10rem] flex-col overflow-auto ">
|
||||
<div className="flex self-start">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
|
||||
<div className="text-sm">Last successful sync</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">
|
||||
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
{!integration.isSynced && (
|
||||
<>
|
||||
<div className="mt-2 flex self-start">
|
||||
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
|
||||
<div className="text-sm">Fail reason</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full items-center space-x-2">
|
||||
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
|
||||
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-x-1.5">
|
||||
<Tooltip className="text-center" content="Manually sync integration secrets">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<Tooltip content="View details">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,13 +1,16 @@
|
||||
import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem";
|
||||
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { IntegrationsTable } from "./components";
|
||||
|
||||
type Props = {
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
isLoading?: boolean;
|
||||
onIntegrationDelete: (
|
||||
integrationId: string,
|
||||
@@ -15,6 +18,7 @@ type Props = {
|
||||
cb: () => void
|
||||
) => Promise<void>;
|
||||
workspaceId: string;
|
||||
onAddIntegration: () => void;
|
||||
};
|
||||
|
||||
export const IntegrationsSection = ({
|
||||
@@ -22,58 +26,47 @@ export const IntegrationsSection = ({
|
||||
environments = [],
|
||||
isLoading,
|
||||
onIntegrationDelete,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
onAddIntegration,
|
||||
cloudIntegrations = []
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteConfirmation",
|
||||
"deleteSecretsConfirmation"
|
||||
] as const);
|
||||
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Current Integrations</h1>
|
||||
<div className="mx-6 mb-8">
|
||||
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Integrations</h1>
|
||||
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="p-6 pt-0">
|
||||
<Skeleton className="h-28" />
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={onAddIntegration}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !integrations.length && (
|
||||
<div className="mx-6">
|
||||
<EmptyState
|
||||
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
|
||||
title="No integrations found. Click on one of the below providers to sync secrets."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
|
||||
{integrations?.map((integration) => (
|
||||
<ConfiguredIntegrationItem
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
integration={integration}
|
||||
environments={environments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<IntegrationsTable
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrations={integrations}
|
||||
isLoading={isLoading}
|
||||
workspaceId={workspaceId}
|
||||
environments={environments}
|
||||
onDeleteIntegration={(integration) => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteConfirmation.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
|
@@ -0,0 +1,138 @@
|
||||
import { FormLabel } from "@app/components/v2";
|
||||
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
type Props = {
|
||||
integration: TIntegration;
|
||||
};
|
||||
|
||||
const FIELD_CLASSNAME =
|
||||
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
|
||||
|
||||
export const getIntegrationDestination = (integration: TIntegration) =>
|
||||
(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||
|
||||
integration.app ||
|
||||
"-";
|
||||
|
||||
export const IntegrationDetails = ({ integration }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div>
|
||||
<FormLabel label="Space" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<>
|
||||
<div>
|
||||
<FormLabel label="Org" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Project" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Env" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div>
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div>
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div>
|
||||
<FormLabel label="Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<FormLabel label="Workspace" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Group" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div>
|
||||
<FormLabel label="Organization" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Category" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") &&
|
||||
integration?.metadata?.secretSuffix && (
|
||||
<div>
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
|
||||
<div className="mt-2 text-xs text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "* Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "* Syncing to all private repositories in the organization"
|
||||
: "* Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faCalendarCheck,
|
||||
faCheck,
|
||||
faInfoCircle,
|
||||
faRefresh,
|
||||
faTrash,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TCloudIntegration } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environment?: { name: string; slug: string; id: string };
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
cloudIntegration: TCloudIntegration;
|
||||
};
|
||||
|
||||
export const IntegrationRow = ({
|
||||
integration,
|
||||
environment,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration,
|
||||
cloudIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { id, secretPath, syncMessage, isSynced } = integration;
|
||||
|
||||
const failureMessage = useMemo(() => {
|
||||
if (isSynced === false) {
|
||||
if (syncMessage)
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(syncMessage), null, 2);
|
||||
} catch (e) {
|
||||
return syncMessage;
|
||||
}
|
||||
|
||||
return "An Unknown Error Occurred.";
|
||||
}
|
||||
return null;
|
||||
}, [isSynced, syncMessage]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
className={twMerge(
|
||||
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
isSynced === false && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`integration-${id}`}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegration?.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegration?.image}`}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
|
||||
<p className="truncate">{secretPath}</p>
|
||||
</Tooltip>{" "}
|
||||
</Td>
|
||||
<Td>{environment?.name ?? "-"}</Td>
|
||||
<Td className="!min-w-[5rem] max-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate">{getIntegrationDestination(integration)}</p>
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="min-w-[20rem] max-w-lg"
|
||||
content={<IntegrationDetails integration={integration} />}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{" "}
|
||||
{typeof integration.isSynced !== "boolean" ? (
|
||||
<Badge variant="primary" key={integration.id}>
|
||||
Pending Sync
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="max-w-sm"
|
||||
content={
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{integration.lastUsed && (
|
||||
<div>
|
||||
<div
|
||||
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCalendarCheck}
|
||||
className="ml-1 pt-0.5 pr-1.5 text-sm"
|
||||
/>
|
||||
<div className="text-xs">Last Synced</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">
|
||||
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failureMessage && (
|
||||
<div>
|
||||
<div className="mb-2 flex self-start text-red">
|
||||
<FontAwesomeIcon icon={faXmark} className="ml-1 pt-0.5 pr-1.5 text-sm" />
|
||||
<div className="text-xs">Failure Reason</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="w-min whitespace-nowrap">
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
|
||||
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex gap-2 whitespace-nowrap">
|
||||
<Tooltip className="max-w-sm text-center" content="Manually Sync">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheck,
|
||||
faClock,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlug,
|
||||
faSearch,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
import { getIntegrationDestination } from "./IntegrationDetails";
|
||||
import { IntegrationRow } from "./IntegrationRow";
|
||||
|
||||
type Props = {
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
workspaceId: string;
|
||||
isLoading?: boolean;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onDeleteIntegration: (integration: TIntegration) => void;
|
||||
};
|
||||
|
||||
enum IntegrationsOrderBy {
|
||||
App = "app",
|
||||
Status = "status",
|
||||
SecretPath = "secretPath",
|
||||
Environment = "environment",
|
||||
Destination = "destination"
|
||||
}
|
||||
|
||||
enum IntegrationStatus {
|
||||
Synced = "synced",
|
||||
NotSynced = "not-synced",
|
||||
PendingSync = "pending-sync"
|
||||
}
|
||||
|
||||
type IntegrationFilters = {
|
||||
environmentIds: string[];
|
||||
integrations: string[];
|
||||
status: IntegrationStatus[];
|
||||
};
|
||||
|
||||
const STATUS_ICON_MAP = {
|
||||
[IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" },
|
||||
[IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" },
|
||||
[IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" }
|
||||
};
|
||||
|
||||
export const IntegrationsTable = ({
|
||||
integrations = [],
|
||||
cloudIntegrations = [],
|
||||
workspaceId,
|
||||
environments,
|
||||
onDeleteIntegration,
|
||||
isLoading
|
||||
}: Props) => {
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
|
||||
const initialFilters = useMemo(
|
||||
() => ({
|
||||
environmentIds: environments.map((env) => env.id),
|
||||
integrations: [...new Set(integrations.map(({ integration }) => integration))],
|
||||
status: Object.values(IntegrationStatus)
|
||||
}),
|
||||
[environments, integrations]
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
|
||||
|
||||
const cloudIntegrationMap = useMemo(() => {
|
||||
return new Map(
|
||||
cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration])
|
||||
);
|
||||
}, [cloudIntegrations]);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
|
||||
|
||||
useEffect(() => {
|
||||
if (integrations?.some((integration) => integration.isSynced === false))
|
||||
setOrderBy(IntegrationsOrderBy.Status);
|
||||
}, []);
|
||||
|
||||
const environmentMap = new Map(environments.map((env) => [env.id, env]));
|
||||
|
||||
const filteredIntegrations = useMemo(
|
||||
() =>
|
||||
integrations
|
||||
.filter((integration) => {
|
||||
const { secretPath, envId, isSynced } = integration;
|
||||
|
||||
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
|
||||
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
|
||||
return false;
|
||||
if (
|
||||
!filters.status.includes(IntegrationStatus.PendingSync) &&
|
||||
typeof isSynced !== "boolean"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (!filters.integrations.includes(integration.integration)) return false;
|
||||
|
||||
if (!filters.environmentIds.includes(envId)) return false;
|
||||
|
||||
return (
|
||||
integration.integration
|
||||
.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) ||
|
||||
getIntegrationDestination(integration)
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
environmentMap
|
||||
.get(envId)
|
||||
?.name.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [integrationOne, integrationTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case IntegrationsOrderBy.SecretPath:
|
||||
return integrationOne.secretPath
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.secretPath.toLowerCase());
|
||||
case IntegrationsOrderBy.Environment:
|
||||
return (environmentMap.get(integrationOne.envId)?.name ?? "-")
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
(environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase()
|
||||
);
|
||||
case IntegrationsOrderBy.Destination:
|
||||
return getIntegrationDestination(integrationOne)
|
||||
.toLowerCase()
|
||||
.localeCompare(getIntegrationDestination(integrationTwo).toLowerCase());
|
||||
case IntegrationsOrderBy.Status:
|
||||
if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end
|
||||
if (typeof integrationTwo.isSynced !== "boolean") return -1;
|
||||
|
||||
return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced);
|
||||
case IntegrationsOrderBy.App:
|
||||
default:
|
||||
return integrationOne.integration
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.integration.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[integrations, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredIntegrations.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: IntegrationsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: IntegrationsOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: IntegrationsOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered =
|
||||
filters.integrations.length !== initialFilters.integrations.length ||
|
||||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
|
||||
filters.status.length !== initialFilters.status.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search integrations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Environments"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<Tooltip content="Filter Integrations" className="mb-2">
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{Object.values(IntegrationStatus).map((status) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((s) => s !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
}}
|
||||
key={status}
|
||||
icon={
|
||||
filters.status.includes(status) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={STATUS_ICON_MAP[status].icon}
|
||||
className={STATUS_ICON_MAP[status].className}
|
||||
/>
|
||||
<span className="capitalize">{status.replace("-", " ")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Integration</DropdownMenuLabel>
|
||||
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
integrations: prev.integrations.includes(integration)
|
||||
? prev.integrations.filter((i) => i !== integration)
|
||||
: [...prev.integrations, integration]
|
||||
}));
|
||||
}}
|
||||
key={integration}
|
||||
icon={
|
||||
filters.integrations.includes(integration) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Environment</DropdownMenuLabel>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
environmentIds: prev.environmentIds.includes(env.id)
|
||||
? prev.environmentIds.filter((i) => i !== env.id)
|
||||
: [...prev.environmentIds, env.id]
|
||||
}));
|
||||
}}
|
||||
key={env.id}
|
||||
icon={
|
||||
filters.environmentIds.includes(env.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<span className="capitalize">{env.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-[25%]">
|
||||
<div className="flex items-center">
|
||||
Integration
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.App)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.App)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Path
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.SecretPath)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Environment
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Environment)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Destination
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Destination)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Status)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Status)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
|
||||
<IntegrationRow
|
||||
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => onDeleteIntegration(integration)}
|
||||
integration={integration}
|
||||
environment={environmentMap.get(integration.envId)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredIntegrations.length) && (
|
||||
<Pagination
|
||||
count={filteredIntegrations.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredIntegrations?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
integrations.length
|
||||
? "No integrations match search..."
|
||||
: "This project has no integrations configured"
|
||||
}
|
||||
icon={integrations.length ? faSearch : faPlug}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export * from "./IntegrationsTable";
|
@@ -6,14 +6,14 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
|
||||
.string()
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.string()
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole?.slug ?? group.role
|
||||
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.group?.data, roles]);
|
||||
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("group", false);
|
||||
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`org-group-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -9,27 +9,24 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import {
|
||||
// IdentityAuthMethod,
|
||||
useAddIdentityUniversalAuth
|
||||
} from "@app/hooks/api/identities";
|
||||
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
name: z.string().min(1, "Required"),
|
||||
role: z.object({ slug: z.string(), name: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
if (identity) {
|
||||
reset({
|
||||
name: identity.name,
|
||||
role: identity?.customRole?.slug ?? identity.role,
|
||||
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
|
||||
metadata: identity.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.identity?.data, roles]);
|
||||
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
await updateMutateAsync({
|
||||
identityId: identity.identityId,
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
const { id: createdId } = await createMutateAsync({
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -15,7 +15,7 @@ import {
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
@@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({
|
||||
)
|
||||
.default([]),
|
||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
|
||||
organizationRole: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({
|
||||
useEffect(() => {
|
||||
if (organizationRoles) {
|
||||
reset({
|
||||
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
|
||||
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
|
||||
: currentOrg?.defaultMembershipRole
|
||||
organizationRole: findOrgMembershipRole(
|
||||
organizationRoles,
|
||||
currentOrg?.defaultMembershipRole!
|
||||
)
|
||||
});
|
||||
}
|
||||
}, [organizationRoles]);
|
||||
|
||||
const onAddMembers = async ({
|
||||
emails,
|
||||
organizationRoleSlug,
|
||||
organizationRole,
|
||||
projects: selectedProjects,
|
||||
projectRoleSlug
|
||||
}: TAddMemberForm) => {
|
||||
@@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({
|
||||
const { data } = await addUsersMutateAsync({
|
||||
organizationId: currentOrg?.id,
|
||||
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||
organizationRoleSlug,
|
||||
organizationRoleSlug: organizationRole.slug,
|
||||
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
|
||||
});
|
||||
|
||||
@@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="organizationRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
name="organizationRole"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which organization role you want to assign to the user."
|
||||
label="Assign organization role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
className="w-full"
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{organizationRoles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.slug}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={organizationRoles}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -148,7 +148,8 @@ export const UserPage = withPermission(
|
||||
onClick={() =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role
|
||||
role: membership.role,
|
||||
roleId: membership.roleId
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
|
@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role,
|
||||
roleId: membership.roleId,
|
||||
metadata: membership.metadata
|
||||
});
|
||||
}}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -8,21 +9,21 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
role: z.string(),
|
||||
role: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const { data: roles = [] } = useGetOrgRoles(orgId);
|
||||
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const popUpData = popUp?.orgMembership?.data as {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
roleId?: string;
|
||||
metadata: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
|
||||
if (popUpData) {
|
||||
reset({
|
||||
role: popUpData.role,
|
||||
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
|
||||
metadata: popUpData.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
|
||||
});
|
||||
}
|
||||
}, [popUp?.orgMembership?.data, roles]);
|
||||
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId: popUpData.membershipId,
|
||||
role,
|
||||
role: role.slug,
|
||||
metadata
|
||||
});
|
||||
|
||||
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Update Membership">
|
||||
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Update Organization Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(e);
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={(newValue) => {
|
||||
const role = newValue as SingleValue<(typeof roles)[number]>;
|
||||
|
||||
if (!role) return;
|
||||
|
||||
const isCustomRole = isCustomOrgRole(role.slug);
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(e);
|
||||
onChange(role);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -1,6 +1,27 @@
|
||||
import { faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
@@ -12,31 +33,106 @@ type Props = {
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
enum UserGroupsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username);
|
||||
const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships(
|
||||
orgMembership.user.username
|
||||
);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const filteredGroupMemberships = useMemo(
|
||||
() =>
|
||||
groupMemberships
|
||||
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
|
||||
}),
|
||||
[groupMemberships, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{groups?.map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && !groups?.length && (
|
||||
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-full">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships.length
|
||||
? "No groups match search..."
|
||||
: "This user has not been assigned to any groups"
|
||||
}
|
||||
icon={groupMemberships.length ? faSearch : faUser}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddGroupToWorkspace,
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
role: z.string()
|
||||
group: z.object({ id: z.string(), name: z.string() }),
|
||||
role: z.object({ slug: z.string(), name: z.string() })
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
@@ -27,7 +27,9 @@ type Props = {
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
// TODO: update backend to support adding multiple roles at once
|
||||
|
||||
const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
@@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ id, role }: FormData) => {
|
||||
const onFormSubmit = async ({ group, role }: FormData) => {
|
||||
try {
|
||||
await addGroupToWorkspaceMutateAsync({
|
||||
projectId: currentWorkspace?.id || "",
|
||||
groupId: id,
|
||||
role: role || undefined
|
||||
groupId: group.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
|
||||
reset();
|
||||
@@ -82,95 +84,84 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
return filteredGroupMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="group"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
options={filteredGroupMembershipOrgs}
|
||||
placeholder="Select group..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.group?.data ? "Update" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("group", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="text-sm">
|
||||
All groups in your organization have already been added to this project.
|
||||
</div>
|
||||
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
|
||||
<Button variant="outline_bg">Create a new group</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.group?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("group", isOpen);
|
||||
reset();
|
||||
}}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
|
||||
>
|
||||
<ModalContent title="Add Group to Project">
|
||||
{filteredGroupMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="id"
|
||||
defaultValue={filteredGroupMembershipOrgs?.[0]?.id}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-600"
|
||||
placeholder="Select group..."
|
||||
>
|
||||
{filteredGroupMembershipOrgs.map(({ name, id }) => (
|
||||
<SelectItem value={id} key={`org-group-${id}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
placeholder="Select role..."
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.group?.data ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("group", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="text-sm">
|
||||
All groups in your organization have already been added to this project.
|
||||
</div>
|
||||
<Link href={`/org/${currentWorkspace?.orgId}/members`}>
|
||||
<Button variant="outline_bg">Create a new group</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
|
||||
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
@@ -2,24 +2,11 @@ import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { faCheckCircle, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
@@ -33,7 +20,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
|
||||
projectRoleSlugs: z.array(z.string().trim().min(1)).min(1)
|
||||
projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1)
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TAddMemberForm>({
|
||||
resolver: zodResolver(addMemberFormSchema),
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
|
||||
});
|
||||
|
||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||
@@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
{
|
||||
slug: currentWorkspace.slug,
|
||||
id: currentWorkspace.id,
|
||||
projectRoleSlug: projectRoleSlugs
|
||||
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -172,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
render={({ field }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
label="Select roles"
|
||||
tooltipText="Select the roles that you wish to assign to the users"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{roles && roles.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{selectedRoleSlugs.length === 1
|
||||
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
|
||||
: selectedRoleSlugs.length === 0
|
||||
? "Select at least one role"
|
||||
: `${selectedRoleSlugs.length} roles selected`}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={twMerge("ml-2 text-xs")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No roles found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80"
|
||||
>
|
||||
{roles && roles.length > 0 ? (
|
||||
roles.map((role) => {
|
||||
const isSelected = selectedRoleSlugs.includes(role.slug);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => roles.length > 1 && event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (selectedRoleSlugs.includes(String(role.slug))) {
|
||||
field.onChange(
|
||||
selectedRoleSlugs.filter(
|
||||
(roleSlug: string) => roleSlug !== String(role.slug)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...selectedRoleSlugs, role.slug]);
|
||||
}
|
||||
}}
|
||||
key={`role-slug-${role.slug}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{role.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select roles..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create policy
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
@@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th>Eligible Approvers</Th>
|
||||
<Th>Eligible Group Approvers</Th>
|
||||
<Th className="w-[18%]">Eligible Approvers</Th>
|
||||
<Th className="w-[18%]">Eligible Group Approvers</Th>
|
||||
<Th>Approval Required</Th>
|
||||
<Th>
|
||||
<DropdownMenu>
|
||||
@@ -256,9 +256,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies?.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
projectSlug={currentWorkspace.slug}
|
||||
policy={policy}
|
||||
workspaceId={workspaceId}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
|
@@ -1,18 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -21,6 +15,7 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import {
|
||||
useCreateSecretApprovalPolicy,
|
||||
@@ -46,21 +41,34 @@ type Props = {
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
environment: z.string(),
|
||||
environment: z.object({ slug: z.string(), name: z.string() }),
|
||||
name: z.string().optional(),
|
||||
secretPath: z.string().optional(),
|
||||
approvals: z.number().min(1),
|
||||
approvers: z
|
||||
.object({ type: z.nativeEnum(ApproverType), id: z.string() })
|
||||
userApprovers: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.array()
|
||||
.default([]),
|
||||
groupApprovers: z
|
||||
.object({ type: z.literal(ApproverType.Group), id: z.string() })
|
||||
.array()
|
||||
.min(1)
|
||||
.default([]),
|
||||
policyType: z.nativeEnum(PolicyType),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||
})
|
||||
.refine((data) => data.approvers, {
|
||||
path: ["approvers"],
|
||||
message: "At least one approver should be provided."
|
||||
.superRefine((data, ctx) => {
|
||||
if (!(data.groupApprovers.length || data.userApprovers.length)) {
|
||||
ctx.addIssue({
|
||||
path: ["userApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
ctx.addIssue({
|
||||
path: ["groupApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -84,8 +92,15 @@ export const AccessPolicyForm = ({
|
||||
values: editValues
|
||||
? {
|
||||
...editValues,
|
||||
environment: editValues.environment.slug,
|
||||
approvers: editValues?.approvers || [],
|
||||
environment: editValues.environment,
|
||||
userApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
|
||||
groupApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
|
||||
approvals: editValues?.approvals
|
||||
}
|
||||
: undefined
|
||||
@@ -110,18 +125,27 @@ export const AccessPolicyForm = ({
|
||||
|
||||
const approversRequired = watch("approvals") || 1;
|
||||
|
||||
const handleCreatePolicy = async (data: TFormSchema) => {
|
||||
const handleCreatePolicy = async ({
|
||||
environment,
|
||||
groupApprovers,
|
||||
userApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (data.policyType === PolicyType.ChangePolicy) {
|
||||
await createSecretApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await createAccessApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
@@ -139,7 +163,12 @@ export const AccessPolicyForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async (data: TFormSchema) => {
|
||||
const handleUpdatePolicy = async ({
|
||||
environment,
|
||||
userApprovers,
|
||||
groupApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId || !projectSlug) return;
|
||||
if (!editValues?.id) return;
|
||||
|
||||
@@ -148,12 +177,15 @@ export const AccessPolicyForm = ({
|
||||
await updateSecretApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await updateAccessApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
@@ -179,150 +211,178 @@ export const AccessPolicyForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const memberOptions = useMemo(
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: ApproverType.User
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
groups?.map(({ group }) => ({
|
||||
id: group.id,
|
||||
type: ApproverType.Group
|
||||
})),
|
||||
[groups]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
bodyClassName="overflow-visible"
|
||||
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
|
||||
>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments[0]?.slug}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
className="mt-4"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enforcement Level"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="Determines the level of enforcement for required approvers of a request"
|
||||
helperText={
|
||||
<div className="ml-1">
|
||||
{field.value === EnforcementLevel.Hard
|
||||
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.`
|
||||
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enforcement Level"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText={
|
||||
<>
|
||||
<p>
|
||||
Determines the level of enforcement for required approvers of a request:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Hard</span> enforcement requires at least{" "}
|
||||
<span className="font-bold"> {approversRequired}</span> approver(s) to
|
||||
approve the request.`
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Soft</span> enforcement At least{" "}
|
||||
<span className="font-bold">{approversRequired}</span> approver(s) must
|
||||
approve the request; however, the requester can bypass approval
|
||||
requirements in emergencies.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
@@ -331,127 +391,53 @@ export const AccessPolicyForm = ({
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvers"
|
||||
name="userApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={
|
||||
value?.filter((e) => e.type === ApproverType.User).length
|
||||
? `${value.filter((e) => e.type === ApproverType.User).length} selected`
|
||||
: "None"
|
||||
}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select members that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{members.map(({ user }) => {
|
||||
const { id: userId } = user;
|
||||
const isChecked =
|
||||
value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === userId && el.type === ApproverType.User
|
||||
).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== userId && el.type !== ApproverType.User
|
||||
)
|
||||
: [...(value || []), { id: userId, type: ApproverType.User }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${userId}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members that are allowed to approve requests..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
const member = members?.find((m) => m.user.id === option.id);
|
||||
|
||||
if (!member) return option.id;
|
||||
|
||||
return getMemberLabel(member);
|
||||
}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvers"
|
||||
name="groupApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={
|
||||
value?.filter((e) => e.type === ApproverType.Group).length
|
||||
? `${
|
||||
value?.filter((e) => e.type === ApproverType.Group).length
|
||||
} selected`
|
||||
: "None"
|
||||
}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups &&
|
||||
groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked =
|
||||
value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === id && el.type === ApproverType.Group
|
||||
).length > 0;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== id && el.type !== ApproverType.Group
|
||||
)
|
||||
: [...(value || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{group.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups that are allowed to approve requests..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -8,19 +8,19 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
|
||||
import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types";
|
||||
import { Approver } from "@app/hooks/api/accessApproval/types";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { ApproverType } from "@app/hooks/api/secretApproval/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
@@ -35,14 +35,12 @@ interface IPolicy {
|
||||
updatedAt: Date;
|
||||
policyType: PolicyType;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
policy: IPolicy;
|
||||
members?: TWorkspaceUser[];
|
||||
groups?: TGroupMembership[];
|
||||
projectSlug: string;
|
||||
workspaceId: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
@@ -51,175 +49,58 @@ export const ApprovalPolicyRow = ({
|
||||
policy,
|
||||
members = [],
|
||||
groups = [],
|
||||
projectSlug,
|
||||
workspaceId,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy();
|
||||
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy();
|
||||
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
|
||||
const labels = useMemo(() => {
|
||||
const usersInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const groupsInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const memberLabels = usersInPolicy?.length
|
||||
? members
|
||||
.filter((member) => usersInPolicy?.includes(member.user.id))
|
||||
.map((member) => getMemberLabel(member))
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
const groupLabels = groupsInPolicy?.length
|
||||
? groups
|
||||
.filter(({ group }) => groupsInPolicy?.includes(group.id))
|
||||
.map(({ group }) => group.name)
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
return {
|
||||
members: memberLabels,
|
||||
groups: groupLabels
|
||||
};
|
||||
}, [policy, members, groups]);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td>
|
||||
<DropdownMenu
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (policy.policyType === PolicyType.AccessPolicy) {
|
||||
updateAccessApprovalPolicy(
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
updateSecretApprovalPolicy(
|
||||
{
|
||||
workspaceId,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}}
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.members ?? "No users are assigned as approvers for this policy"}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={
|
||||
isLoading ||
|
||||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={selectedApprovers.length ? `${selectedApprovers.length} selected` : "None"}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select members that are allowed to approve changes
|
||||
</DropdownMenuLabel>
|
||||
{members?.map(({ user }) => {
|
||||
const userId = user.id;
|
||||
const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setSelectedApprovers((state) =>
|
||||
isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${userId}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="truncate">{labels.members ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (policy.policyType === PolicyType.AccessPolicy) {
|
||||
updateAccessApprovalPolicy(
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updateSecretApprovalPolicy(
|
||||
{
|
||||
workspaceId,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
}}
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups && groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setSelectedGroupApprovers(
|
||||
isChecked
|
||||
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group)
|
||||
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-groups-${id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{group.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="truncate">{labels.groups ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>{policy.approvals}</Td>
|
||||
<Td>
|
||||
@@ -229,12 +110,12 @@ export const ApprovalPolicyRow = ({
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer">
|
||||
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out">
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
|
||||
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="p-1 min-w-[100%]">
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -17,7 +18,7 @@ import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { useCreateSecretImport } from "@app/hooks/api";
|
||||
|
||||
const typeSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -80,7 +81,7 @@ export const CreateSecretImportForm = ({
|
||||
path: secretPath,
|
||||
isReplication,
|
||||
import: {
|
||||
environment: importedEnv,
|
||||
environment: importedEnv.slug,
|
||||
path: importedSecPath
|
||||
}
|
||||
});
|
||||
@@ -88,8 +89,9 @@ export const CreateSecretImportForm = ({
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : ""
|
||||
}`
|
||||
text: `Successfully linked. ${
|
||||
isReplication ? "Please refresh the dashboard to view changes" : ""
|
||||
}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -111,6 +113,7 @@ export const CreateSecretImportForm = ({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Add Secret Link"
|
||||
subTitle="To inherit secrets from another environment or folder"
|
||||
>
|
||||
@@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
placeholder="Select environment..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<SecretPathInput {...field} environment={selectedEnvironment} />
|
||||
<SecretPathInput {...field} environment={selectedEnvironment?.slug} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -1,14 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClone,
|
||||
faFileImport,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareCheck,
|
||||
faSquareXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -16,17 +9,13 @@ import { z } from "zod";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
@@ -35,14 +24,17 @@ import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z.record(z.string().optional().nullable())
|
||||
secrets: z
|
||||
.object({ key: z.string(), value: z.string().optional() })
|
||||
.array()
|
||||
.min(1, "Select one or more secrets to copy")
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -68,7 +60,6 @@ export const CopySecretsFromBoard = ({
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
@@ -80,7 +71,7 @@ export const CopySecretsFromBoard = ({
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0] }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
@@ -89,7 +80,7 @@ export const CopySecretsFromBoard = ({
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
environment: selectedEnvSlug.slug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
options: {
|
||||
enabled:
|
||||
@@ -101,29 +92,22 @@ export const CopySecretsFromBoard = ({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
setValue("secrets", []);
|
||||
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
setValue("secrets", secrets, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
data.secrets.forEach(({ key, value }) => {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && value) || "",
|
||||
comments: [""]
|
||||
};
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
@@ -136,7 +120,6 @@ export const CopySecretsFromBoard = ({
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
@@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
@@ -176,22 +160,14 @@ export const CopySecretsFromBoard = ({
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
onChange={onChange}
|
||||
options={environments}
|
||||
placeholder="Select environment..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={selectedEnvSlug}
|
||||
environment={selectedEnvSlug?.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="flex w-1/2 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
</div>
|
||||
<div className="flex w-full items-start gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="secrets"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
placeholder={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isSecretsLoading
|
||||
? "Loading secrets..."
|
||||
: secrets?.length
|
||||
? "Select secrets..."
|
||||
: "No secrets found..."
|
||||
}
|
||||
isLoading={isSecretsLoading}
|
||||
options={secrets}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.key}
|
||||
getOptionLabel={(option) => option.key}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
className="mt-1 h-9 w-9"
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-2 gap-4 overflow-auto ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
|
||||
))}
|
||||
|
||||
{secrets
|
||||
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
?.map(({ id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
<div className="my-6 ml-2">
|
||||
<Switch
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
@@ -285,7 +246,7 @@ export const CopySecretsFromBoard = ({
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
Copy Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
||||
Cancel
|
||||
|
@@ -722,26 +722,6 @@ export const SecretOverviewPage = () => {
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((availableEnv) => {
|
||||
const { id: envId, name } = availableEnv;
|
||||
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleEnvSelect(envId);
|
||||
}}
|
||||
key={envId}
|
||||
disabled={visibleEnvs?.length === 1}
|
||||
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{/* <DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -796,6 +776,26 @@ export const SecretOverviewPage = () => {
|
||||
<span>Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((availableEnv) => {
|
||||
const { id: envId, name } = availableEnv;
|
||||
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleEnvSelect(envId);
|
||||
}}
|
||||
key={envId}
|
||||
disabled={visibleEnvs?.length === 1}
|
||||
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -1128,7 +1128,6 @@ export const SecretOverviewPage = () => {
|
||||
>
|
||||
<CreateSecretForm
|
||||
secretPath={secretPath}
|
||||
getSecretByKey={getSecretByKey}
|
||||
onClose={() => handlePopUpClose("addSecretsInAllEnvs")}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
@@ -1,13 +1,13 @@
|
||||
import { ClipboardEvent } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Checkbox, FormControl, FormLabel, Input, Tooltip } from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
@@ -17,20 +17,14 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretV3,
|
||||
useCreateWsTag,
|
||||
useGetWsTags,
|
||||
useUpdateSecretV3
|
||||
} from "@app/hooks/api";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
key: z.string().trim().min(1, "Key is required"),
|
||||
value: z.string().optional(),
|
||||
environments: z.record(z.boolean().optional()),
|
||||
environments: z.object({ name: z.string(), slug: z.string() }).array(),
|
||||
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
|
||||
})
|
||||
.refine((data) => data.key !== undefined, {
|
||||
@@ -41,22 +35,19 @@ type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secretPath?: string;
|
||||
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||
// modal props
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: Props) => {
|
||||
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
const newSecretKey = watch("key");
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
@@ -65,22 +56,13 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
|
||||
canReadTags ? workspaceId : ""
|
||||
);
|
||||
|
||||
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: 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 promises = selectedEnv.map(async (env) => {
|
||||
const environment = env.slug;
|
||||
// create folder if not existing
|
||||
if (secretPath !== "/") {
|
||||
@@ -106,21 +88,7 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
}
|
||||
}
|
||||
|
||||
const isEdit = getSecretByKey(environment, key) !== undefined;
|
||||
if (isEdit) {
|
||||
return {
|
||||
...(await updateSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
type: SecretType.Shared,
|
||||
tagIds: tags?.map((el) => el.value)
|
||||
})),
|
||||
environment
|
||||
};
|
||||
}
|
||||
// TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination
|
||||
|
||||
return {
|
||||
...(await createSecretV3({
|
||||
@@ -278,54 +246,33 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Environments" className="mb-2" />
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto py-2">
|
||||
{environments
|
||||
.filter((environmentSlug) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: environmentSlug.slug,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
)
|
||||
)
|
||||
.map((env) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`environments.${env.slug}`}
|
||||
key={`secret-input-${env.slug}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`secret-input-${env.slug}`}
|
||||
className="!justify-start"
|
||||
>
|
||||
<span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
|
||||
<span title={env.name} className="truncate">
|
||||
{env.name}
|
||||
</span>
|
||||
<span>
|
||||
{getSecretByKey(env.slug, newSecretKey) && (
|
||||
<Tooltip
|
||||
className="max-w-[150px]"
|
||||
content="Secret already exists, and it will be overwritten"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl label="Environments" isError={Boolean(error)} errorText={error?.message}>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
options={environments.filter((environment) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: environment.slug,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
)
|
||||
)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environments to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
name="environments"
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
|
@@ -19,7 +19,6 @@ import { SecretTagsTable } from "./SecretTagsTable";
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
export const SecretTagsSection = (): JSX.Element => {
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"CreateSecretTag",
|
||||
"deleteTagConfirmation"
|
||||
@@ -65,7 +64,7 @@ export const SecretTagsSection = (): JSX.Element => {
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create tag
|
||||
Create Tag
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
@@ -1,10 +1,20 @@
|
||||
import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faTag,
|
||||
faTrashCan
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -15,7 +25,9 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetWsTags } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
@@ -31,59 +43,124 @@ type Props = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum TagsOrderBy {
|
||||
Slug = "slug"
|
||||
}
|
||||
|
||||
export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetWsTags(currentWorkspace?.id ?? "");
|
||||
const { data: tags = [], isLoading } = useGetWsTags(currentWorkspace?.id ?? "");
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(TagsOrderBy.Slug, { initPerPage: 10 });
|
||||
|
||||
const filteredTags = useMemo(
|
||||
() =>
|
||||
tags
|
||||
.filter((tag) => tag.slug.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const [tagOne, tagTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return tagOne.slug.toLowerCase().localeCompare(tagTwo.slug.toLowerCase());
|
||||
}),
|
||||
[tags, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredTags.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.map(({ id, slug }) => (
|
||||
<Tr key={id}>
|
||||
<Td>{slug}</Td>
|
||||
<Td className="flex items-center justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Tags}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteTagConfirmation", {
|
||||
name: slug,
|
||||
id
|
||||
})
|
||||
}
|
||||
colorSchema="danger"
|
||||
ariaLabel="update"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search tags..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Td colSpan={3}>
|
||||
<EmptyState title="No secret tags found" icon={faTags} />
|
||||
</Td>
|
||||
<Th className="w-full">
|
||||
<div className="flex items-center">
|
||||
Slug
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
|
||||
{!isLoading &&
|
||||
filteredTags.slice(offset, perPage * page).map(({ id, slug }) => (
|
||||
<Tr key={id}>
|
||||
<Td>{slug}</Td>
|
||||
<Td className="flex items-center justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Tags}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteTagConfirmation", {
|
||||
name: slug,
|
||||
id
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
colorSchema="danger"
|
||||
ariaLabel="update"
|
||||
variant="plain"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredTags.length) && (
|
||||
<Pagination
|
||||
count={filteredTags.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredTags?.length && (
|
||||
<EmptyState
|
||||
title={tags.length ? "No tags match search..." : "No tags found for project"}
|
||||
icon={tags.length ? faSearch : faTag}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user