diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment index ff7925d75..b447d947e 100644 --- a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -1,4 +1,4 @@ -error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"statusCode":404,"message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","error":"NotFound"}] +error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"error":"NotFound","message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","statusCode":404}] If this issue continues, get support at https://infisical.com/slack diff --git a/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection index 71a189a65..2ca9d13ad 100644 --- a/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection +++ b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection @@ -1,4 +1,4 @@ -Warning: Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug +Warning: Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug ┌───────────────┬──────────────┬─────────────┐ │ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ ├───────────────┼──────────────┼─────────────┤ diff --git a/cli/test/helper.go b/cli/test/helper.go index 819f4c4c9..21bd261df 100644 --- a/cli/test/helper.go +++ b/cli/test/helper.go @@ -1,6 +1,7 @@ package tests import ( + "encoding/json" "fmt" "log" "os" @@ -41,11 +42,12 @@ var creds = Credentials{ func ExecuteCliCommand(command string, args ...string) (string, error) { cmd := exec.Command(command, args...) output, err := cmd.CombinedOutput() + if err != nil { - fmt.Println(fmt.Sprint(err) + ": " + string(output)) - return strings.TrimSpace(string(output)), err + fmt.Println(fmt.Sprint(err) + ": " + FilterRequestID(strings.TrimSpace(string(output)))) + return FilterRequestID(strings.TrimSpace(string(output))), err } - return strings.TrimSpace(string(output)), nil + return FilterRequestID(strings.TrimSpace(string(output))), nil } func SetupCli() { @@ -67,3 +69,34 @@ func SetupCli() { } } + +func FilterRequestID(input string) string { + // Find the JSON part of the error message + start := strings.Index(input, "{") + end := strings.LastIndex(input, "}") + 1 + + if start == -1 || end == -1 { + return input + } + + jsonPart := input[:start] // Pre-JSON content + + // Parse the JSON object + var errorObj map[string]interface{} + if err := json.Unmarshal([]byte(input[start:end]), &errorObj); err != nil { + return input + } + + // Remove requestId field + delete(errorObj, "requestId") + delete(errorObj, "reqId") + + // Convert back to JSON + filtered, err := json.Marshal(errorObj) + if err != nil { + return input + } + + // Reconstruct the full string + return jsonPart + string(filtered) + input[end:] +} diff --git a/cli/test/secrets_test.go b/cli/test/secrets_test.go index f11392f52..f7f0f13ff 100644 --- a/cli/test/secrets_test.go +++ b/cli/test/secrets_test.go @@ -3,7 +3,6 @@ package tests import ( "testing" - "github.com/Infisical/infisical-merge/packages/util" "github.com/bradleyjkemp/cupaloy/v2" ) @@ -96,28 +95,29 @@ func TestUserAuth_SecretsGetAll(t *testing.T) { // testUserAuth_SecretsGetAllWithoutConnection(t) } -func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { - originalConfigFile, err := util.GetConfigFile() - if err != nil { - t.Fatalf("error getting config file") - } - newConfigFile := originalConfigFile +// disabled for the time being +// func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { +// originalConfigFile, err := util.GetConfigFile() +// if err != nil { +// t.Fatalf("error getting config file") +// } +// newConfigFile := originalConfigFile - // set it to a URL that will always be unreachable - newConfigFile.LoggedInUserDomain = "http://localhost:4999" - util.WriteConfigFile(&newConfigFile) +// // set it to a URL that will always be unreachable +// newConfigFile.LoggedInUserDomain = "http://localhost:4999" +// util.WriteConfigFile(&newConfigFile) - // restore config file - defer util.WriteConfigFile(&originalConfigFile) +// // restore config file +// defer util.WriteConfigFile(&originalConfigFile) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") - if err != nil { - t.Fatalf("error running CLI command: %v", err) - } +// output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") +// if err != nil { +// t.Fatalf("error running CLI command: %v", err) +// } - // Use cupaloy to snapshot test the output - err = cupaloy.Snapshot(output) - if err != nil { - t.Fatalf("snapshot failed: %v", err) - } -} +// // Use cupaloy to snapshot test the output +// err = cupaloy.Snapshot(output) +// if err != nil { +// t.Fatalf("snapshot failed: %v", err) +// } +// } diff --git a/docs/integrations/platforms/kubernetes-csi.mdx b/docs/integrations/platforms/kubernetes-csi.mdx new file mode 100644 index 000000000..88df9585c --- /dev/null +++ b/docs/integrations/platforms/kubernetes-csi.mdx @@ -0,0 +1,281 @@ +--- +title: "Kubernetes CSI" +description: "How to use Infisical to inject secrets directly into Kubernetes pods." +--- + +## Overview + +The Infisical CSI provider allows you to use Infisical with the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io) to inject secrets directly into your Kubernetes pods through a volume mount. +In contrast to the [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes), the Infisical CSI provider will allow you to sync Infisical secrets directly to pods as files, removing the need for Kubernetes secret resources. + +```mermaid +flowchart LR + subgraph Secrets Management + SS(Infisical) --> CSP(Infisical CSI Provider) + CSP --> CSD(Secrets Store CSI Driver) + end + + subgraph Application + CSD --> V(Volume) + V <--> P(Pod) + end + +``` + +## Features + +The following features are supported by the Infisical CSI Provider: + +- Integration with Secrets Store CSI Driver for direct pod mounting +- Authentication using Kubernetes service accounts via machine identities +- Auto-syncing secrets when enabled via CSI Driver +- Configurable secret paths and file mounting locations +- Installation via Helm + +## Prerequisites + +The Infisical CSI provider is only supported for Kubernetes clusters with version >= 1.20. + +## Limitations + +Currently, the Infisical CSI provider only supports static secrets. + +## Deploy to Kubernetes cluster + +### Install Secrets Store CSI Driver + +In order to use the Infisical CSI provider, you will first have to install the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io/getting-started/installation) to your cluster. It is important that you define +the audience value for token requests as demonstrated below. The Infisical CSI provider will **NOT WORK** if this is not set. + +```bash +helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts +``` + +```bash +helm install csi secrets-store-csi-driver/secrets-store-csi-driver \ +--namespace=kube-system \ +--set "tokenRequests[0].audience=infisical" \ +--set enableSecretRotation=true \ +--set rotationPollInterval=2m \ +--set "syncSecret.enabled=true" \ +``` + +The flags configure the following: + +- `tokenRequests[0].audience=infisical`: Sets the audience value for service account token authentication (required) +- `enableSecretRotation=true`: Enables automatic secret updates from Infisical +- `rotationPollInterval=2m`: Checks for secret updates every 2 minutes +- `syncSecret.enabled=true`: Enables syncing secrets to Kubernetes secrets + + + If you do not wish to use the auto-syncing feature of the secrets store CSI + driver, you can omit the `enableSecretRotation` and the `rotationPollInterval` + flags. Do note that by default, secrets from Infisical are only fetched and + mounted during pod creation. If there are any changes made to the secrets in + Infisical, they will not propagate to the pods unless auto-syncing is enabled + for the CSI driver. + + +### Install Infisical CSI Provider + +You would then have to install the Infisical CSI provider to your cluster. + +**Install the latest Infisical Helm repository** + +```bash +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + +helm repo update +``` + +**Install the Helm Chart** + +```bash +helm install infisical-csi-provider infisical-helm-charts/infisical-csi-provider +``` + +For a list of all supported arguments for the helm installation, you can run the following: + +```bash +helm show values infisical-helm-charts/infisical-csi-provider +``` + +### Authentication + +In order for the Infisical CSI provider to pull secrets from your Infisical project, you will have to configure +a machine identity with [Kubernetes authentication](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) configured with your cluster. +You can refer to the documentation for setting it up [here](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth#guide). + + + The allowed audience field of the Kubernetes authentication settings should + match the audience specified for the Secrets Store CSI driver during + installation. + + +### Creating Secret Provider Class + +With the Secrets Store CSI driver and the Infisical CSI provider installed, create a Kubernetes [SecretProviderClass](https://secrets-store-csi-driver.sigs.k8s.io/concepts.html#secretproviderclass) resource to establish +the connection between the CSI driver and the Infisical CSI provider for secret retrieval. You can create as many Secret Provider Classes as needed for your cluster. + +```yaml +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: my-infisical-app-csi-provider +spec: + provider: infisical + parameters: + infisicalUrl: "https://app.infisical.com" + authMethod: "kubernetes" + identityId: "ad2f8c67-cbe2-417a-b5eb-1339776ec0b3" + projectId: "09eda1f8-85a3-47a9-8a6f-e27f133b2a36" + envSlug: "prod" + secrets: | + - secretPath: "/" + fileName: "dbPassword" + secretKey: "DB_PASSWORD" + - secretPath: "/app" + fileName: "appSecret" + secretKey: "APP_SECRET" +``` + + + The SecretProviderClass should be provisioned in the same namespace as the pod + you intend to mount secrets to. + + +#### Supported Parameters + + + The base URL of your Infisical instance. If you're using Infisical Cloud US, + this should be set to `https://app.infisical.com`. If you're using Infisical + Cloud EU, then this should be set to `https://eu.infisical.com`. + + + + The CA certificate of the Infisical instance in order to establish SSL/TLS + when the instance uses a private or self-signed certificate. Unless necessary, + this should be omitted. + + + + The auth method to use for authenticating the Infisical CSI provider with + Infisical. For now, the only supported method is `kubernetes`. + + + + The ID of the machine identity to use for authenticating the Infisical CSI + provider with your Infisical organization. This should be the machine identity + configured with Kubernetes authentication. + + + + The project ID of the Infisical project to pull secrets from. + + + + The slug of the project environment to pull secrets from. + + + + An array that defines which secrets to retrieve and how to mount them. Each + entry requires three properties: `secretPath` and `secretKey` work together to + identify the source secret to fetch, while `fileName` specifies the path where + the secret's value will be mounted within the pod's filesystem. + + + + The custom audience value configured for the CSI driver. This defaults to + `infisical`. + + +### Using Secret Provider Class + +A pod can use the Secret Provider Class by mounting it as a CSI volume: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-secrets-store + labels: + app: nginx +spec: + containers: + - name: nginx + image: nginx + volumeMounts: + - name: secrets-store-inline + mountPath: "/mnt/secrets-store" + readOnly: true + volumes: + - name: secrets-store-inline + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: "my-infisical-app-csi-provider" +``` + +When the pod is created, the secrets are mounted as individual files in the /mnt/secrets-store directory. + +### Verifying Secret Mounts + +To verify your secrets are mounted correctly: + +```bash +# Check pod status +kubectl get pod nginx-secrets-store + +# View mounted secrets +kubectl exec -it nginx-secrets-store -- ls -l /mnt/secrets-store +``` + +### Troubleshooting + +To troubleshoot issues with the Infisical CSI provider, refer to the logs of the Infisical CSI provider running on the same node as your pod. + +```bash +kubectl logs infisical-csi-provider-7x44t +``` + +You can also refer to the logs of the secrets store CSI driver. Modify the command below with the appropriate pod and namespace of your secrets store CSI driver installation. + +```bash +kubectl logs csi-secrets-store-csi-driver-7h4jp -n=kube-system +``` + +**Common issues include:** + +- Mismatch in the audience value of the CSI driver with the machine identity's Kubernetes auth configuration +- SecretProviderClass in the wrong namespace +- Invalid machine identity configuration +- Incorrect secret paths or keys + +## Best Practices + +For additional guidance on setting this up for your production cluster, you can refer to the Secrets Store CSI driver documentation [here](https://secrets-store-csi-driver.sigs.k8s.io/topics/best-practices). + +## Frequently Asked Questions + + + + Yes, but it requires an indirect approach: + + 1. First enable syncing to Kubernetes secrets by setting `syncSecret.enabled=true` in the CSI driver installation + 2. Configure the Secret Provider Class to sync specific secrets to Kubernetes secrets + 3. Use the resulting Kubernetes secrets in your pod's environment variables + + This means secrets are first synced to Kubernetes secrets before they can be used as environment variables. You can find detailed examples in the [Secrets Store CSI driver documentation](https://secrets-store-csi-driver.sigs.k8s.io/topics/set-as-env-var). + + + + + + + Yes, you will need to explicitly list each secret you want to sync in the + Secret Provider Class configuration. This is a common requirement across all + CSI providers as the Secrets Store CSI Driver architecture requires specific + mapping of secrets to their mounted file locations. + + diff --git a/docs/mint.json b/docs/mint.json index 59aa59054..7df2e1062 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -345,6 +345,7 @@ "group": "Container orchestrators", "pages": [ "integrations/platforms/kubernetes", + "integrations/platforms/kubernetes-csi", "integrations/platforms/docker-swarm-with-agent", "integrations/platforms/ecs-with-agent" ] diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 715d8c51d..973feeb73 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -1,11 +1,17 @@ +import { ParsedUrlQuery } from "querystring"; + +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { faAngleRight, faLock } from "@fortawesome/free-solid-svg-icons"; +import { faAngleRight, faCheck, faCopy, faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { useOrganization, useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; -import { Select, SelectItem, Tooltip } from "../v2"; +import { createNotification } from "../notifications"; +import { IconButton, Select, SelectItem, Tooltip } from "../v2"; type Props = { pageName: string; @@ -50,6 +56,10 @@ export default function NavHeader({ }: Props): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); + + const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false); + const [isHoveringCopyButton, setIsHoveringCopyButton] = useState(false); + const router = useRouter(); const secretPathSegments = secretPath.split("/").filter(Boolean); @@ -132,8 +142,10 @@ export default function NavHeader({ )} {isFolderMode && secretPathSegments?.map((folderName, index) => { - const query = { ...router.query }; - query.secretPath = `/${secretPathSegments.slice(0, index + 1).join("/")}`; + const query: ParsedUrlQuery & { secretPath: string } = { + ...router.query, + secretPath: `/${secretPathSegments.slice(0, index + 1).join("/")}` + }; return (
{index + 1 === secretPathSegments?.length ? ( - {folderName} +
+ + {folderName} + + + setIsHoveringCopyButton(true)} + onMouseLeave={() => setIsHoveringCopyButton(false)} + onClick={() => { + if (isCopied) return; + + navigator.clipboard.writeText(query.secretPath); + + createNotification({ + text: "Copied secret path to clipboard", + type: "info" + }); + + toggleIsCopied(2000); + }} + className="hover:bg-bunker-100/10" + > + + + +
) : ( - + {folderName} diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index f17083248..dd8ba330d 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -32,7 +32,13 @@ export const FilterableSelect = ({ }) }} tabSelectsValue={tabSelectsValue} - components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }} + components={{ + DropdownIndicator, + ClearIndicator, + MultiValueRemove, + Option, + ...props.components + }} classNames={{ container: ({ isDisabled }) => twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"), @@ -58,14 +64,15 @@ export const FilterableSelect = ({ 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: () => - "my-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 text-xs px-3 py-2" + "hover:cursor-pointer rounded text-xs px-3 py-2" ), noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md" }} diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 51eed6396..2d0e8b1a9 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -54,7 +54,7 @@ export const Pagination = ({ )} > {startAdornment} -
+
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts index de6291a13..4e26e1b15 100644 --- a/frontend/src/helpers/roles.ts +++ b/frontend/src/helpers/roles.ts @@ -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); diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 2d12b3eda..8c4f8e1c8 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faStar } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowLeft, @@ -22,15 +21,11 @@ import { faInfo, faMobile, faPlus, - faQuestion, - faStar as faSolidStar + faQuestion } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; -import { twMerge } from "tailwind-merge"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { @@ -39,20 +34,9 @@ import { DropdownMenuContent, DropdownMenuItem, Menu, - MenuItem, - Select, - SelectItem, - UpgradePlanModal + MenuItem } from "@app/components/v2"; -import { NewProjectModal } from "@app/components/v2/projects/NewProjectModal"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - useOrganization, - useSubscription, - useUser, - useWorkspace -} from "@app/context"; +import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useGetAccessRequestsCount, @@ -62,11 +46,9 @@ import { useSelectOrganization } from "@app/hooks/api"; import { MfaMethod } from "@app/hooks/api/auth/types"; -import { Workspace } from "@app/hooks/api/types"; -import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; -import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { AuthMethod } from "@app/hooks/api/users/types"; import { InsecureConnectionBanner } from "@app/layouts/AppLayout/components/InsecureConnectionBanner"; +import { ProjectSelect } from "@app/layouts/AppLayout/components/ProjectSelect"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { Mfa } from "@app/views/Login/Mfa"; import { CreateOrgModal } from "@app/views/Org/components"; @@ -108,23 +90,10 @@ export const AppLayout = ({ children }: LayoutProps) => { const { workspaces, currentWorkspace } = useWorkspace(); const { orgs, currentOrg } = useOrganization(); - const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); - const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const [shouldShowMfa, toggleShowMfa] = useToggle(false); const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); - const workspacesWithFaveProp = useMemo( - () => - workspaces - .map((w): Workspace & { isFavorite: boolean } => ({ - ...w, - isFavorite: Boolean(projectFavorites?.includes(w.id)) - })) - .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)), - [workspaces, projectFavorites] - ); - const { user } = useUser(); const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; @@ -137,17 +106,9 @@ export const AppLayout = ({ children }: LayoutProps) => { return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); }, [secretApprovalReqCount, accessApprovalRequestCount]); - const isAddingProjectsAllowed = subscription?.workspaceLimit - ? subscription.workspacesUsed < subscription.workspaceLimit - : true; - const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION; - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ - "addNewWs", - "upgradePlan", - "createOrg" - ] as const); + const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); const { t } = useTranslation(); @@ -230,38 +191,6 @@ export const AppLayout = ({ children }: LayoutProps) => { putUserInOrg(); }, [router.query.id]); - const addProjectToFavorites = async (projectId: string) => { - try { - if (currentOrg?.id) { - await updateUserProjectFavorites({ - orgId: currentOrg?.id, - projectFavorites: [...(projectFavorites || []), projectId] - }); - } - } catch (err) { - createNotification({ - text: "Failed to add project to favorites.", - type: "error" - }); - } - }; - - const removeProjectFromFavorites = async (projectId: string) => { - try { - if (currentOrg?.id) { - await updateUserProjectFavorites({ - orgId: currentOrg?.id, - projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] - }); - } - } catch (err) { - createNotification({ - text: "Failed to remove project from favorites.", - type: "error" - }); - } - }; - if (shouldShowMfa) { return (
@@ -448,97 +377,7 @@ export const AppLayout = ({ children }: LayoutProps) => { )} {!router.asPath.includes("org") && (!router.asPath.includes("personal") && currentWorkspace ? ( -
-

- Project -

- -
+ ) : (
@@ -816,15 +655,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
- handlePopUpToggle("addNewWs", isOpen)} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." - /> handlePopUpToggle("createOrg", false)} diff --git a/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx new file mode 100644 index 000000000..2768abfed --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/ProjectSelect/ProjectSelect.tsx @@ -0,0 +1,212 @@ +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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FilterableSelect, UpgradePlanModal } from "@app/components/v2"; +import { NewProjectModal } from "@app/components/v2/projects"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; +import { Workspace } from "@app/hooks/api/workspace/types"; + +type TWorkspaceWithFaveProp = Workspace & { isFavorite: boolean }; + +const ProjectsMenu = ({ children, ...props }: MenuProps) => { + return ( + + {children} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; + +const ProjectOption = ({ + isSelected, + children, + data, + ...props +}: OptionProps) => { + const { currentOrg } = useOrganization(); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + + const removeProjectFromFavorites = async (projectId: string) => { + try { + await updateUserProjectFavorites({ + orgId: currentOrg!.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } catch (err) { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; + + const addProjectToFavorites = async (projectId: string) => { + try { + await updateUserProjectFavorites({ + orgId: currentOrg!.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } catch (err) { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; + return ( + +
+ {isSelected && ( + + )} +

{children}

+ {data.isFavorite ? ( + { + e.stopPropagation(); + await removeProjectFromFavorites(data.id); + }} + /> + ) : ( + { + e.stopPropagation(); + await addProjectToFavorites(data.id); + }} + /> + )} +
+
+ ); +}; + +export const ProjectSelect = () => { + const { workspaces, currentWorkspace } = useWorkspace(); + const { currentOrg } = useOrganization(); + const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + + const { subscription } = useSubscription(); + + const isAddingProjectsAllowed = subscription?.workspaceLimit + ? subscription.workspacesUsed < subscription.workspaceLimit + : true; + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addNewWs", + "upgradePlan" + ] as const); + + const { options, value } = useMemo(() => { + const projectOptions = workspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)); + + const currentOption = projectOptions.find((option) => option.id === currentWorkspace?.id); + + if (!currentOption) { + return { + options: projectOptions, + value: null + }; + } + + return { + options: [ + currentOption, + ...projectOptions.filter((option) => option.id !== currentOption.id) + ], + value: currentOption + }; + }, [workspaces, projectFavorites, currentWorkspace]); + + return ( +
+

Project

+ + option.data.name.toLowerCase().includes(inputValue.toLowerCase()) + } + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + onChange={(newValue) => { + // hacky use of null as indication to create project + if (!newValue) { + if (isAddingProjectsAllowed) { + handlePopUpOpen("addNewWs"); + } else { + handlePopUpOpen("upgradePlan"); + } + return; + } + + const project = newValue as TWorkspaceWithFaveProp; + localStorage.setItem("projectData.id", project.id); + // todo(akhi): this is not using react query because react query in overview is throwing error when envs are not exact same count + // to reproduce change this back to router.push and switch between two projects with different env count + // look into this on dashboard revamp + window.location.assign(`/project/${project.id}/secrets/overview`); + }} + options={options} + components={{ + Option: ProjectOption, + Menu: ProjectsMenu + }} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You have exceeded the number of projects allowed on the free plan." + /> + + handlePopUpToggle("addNewWs", isOpen)} + /> +
+ ); +}; diff --git a/frontend/src/layouts/AppLayout/components/ProjectSelect/index.ts b/frontend/src/layouts/AppLayout/components/ProjectSelect/index.ts new file mode 100644 index 000000000..d0be7c203 --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/ProjectSelect/index.ts @@ -0,0 +1 @@ +export * from "./ProjectSelect"; diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 45fc7f3d2..9e39fd389 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -876,7 +876,7 @@ const OrganizationPage = () => { ; @@ -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(); }} > - +
( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 4b71aaea3..d483d0ea7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -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(); }} > - + { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index 74aa5d7c2..38faf53f1 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -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; @@ -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 = ({ ( + name="organizationRole" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
- -
+ option.slug} + getOptionLabel={(option) => option.name} + value={value} + onChange={onChange} + />
)} /> diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index ad0f66d6e..2b3817bcd 100644 --- a/frontend/src/views/Org/UserPage/UserPage.tsx +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -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} diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx index d439c7ecd..6939eca17 100644 --- a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => handlePopUpOpen("orgMembership", { membershipId: membership.id, role: membership.role, + roleId: membership.roleId, metadata: membership.metadata }); }} diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx index 57c8cebb2..289553ba8 100644 --- a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -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(); }} > - + ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + value={value} + getOptionValue={(option) => option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 15299da26..af136d7ff 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -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 ( - - - - - - - - - {groups?.map((group) => ( - - ))} - -
Name -
- {!isLoading && !groups?.length && ( - - )} -
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search groups..." + /> + + + + + + + + + {filteredGroupMemberships.slice(offset, perPage * page).map((group) => ( + + ))} + +
+
+ Name + + + +
+
+
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} +
+
); }; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 3ece05497..ef1c89e58 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -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; @@ -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 ? ( + + ( + + option.id} + getOptionLabel={(option) => option.name} + options={filteredGroupMembershipOrgs} + placeholder="Select group..." + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + options={roles} + placeholder="Select role..." + /> + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ ); +}; + +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("group", isOpen); - reset(); - }} + onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)} > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} + +
); diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index fd0b13172..ed8271973 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -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; @@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { formState: { isSubmitting, errors } } = useForm({ 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) => { ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - - - {roles && roles.length > 0 ? ( -
- {/* 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`} - -
- ) : ( -
- No roles found -
- )} -
- - {roles && roles.length > 0 ? ( - roles.map((role) => { - const isSelected = selectedRoleSlugs.includes(role.slug); - - return ( - 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 ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {role.name} - - ); - }) - ) : ( -
- )} - - + option.slug} + getOptionLabel={(option) => option.name} + /> )} /> diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx index 7f9f2a4fe..69d3b6d40 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx @@ -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 ( @@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({ ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.name} + getOptionValue={(option) => option.slug} + placeholder="Select environment..." + value={value} + onChange={onChange} + /> )} /> @@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({ defaultValue="/" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx index fb5355298..ac6efe5cd 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx @@ -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; @@ -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({ 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 = {}; - 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(""); }} > @@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
( - + onChange={onChange} + options={environments} + placeholder="Select environment..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.slug} + /> )} /> @@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({ )} @@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
Secrets
-
- +
+ ( + + option.key} + getOptionLabel={(option) => option.key} + /> + + )} + /> + + } - onChange={(evt) => setSearchFilter(evt.target.value)} - /> - - - - - - - reset()} - > - - - -
+ onClick={handleSecSelectAll} + > + + +
- {!isSecretsLoading && !secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} - - {secrets - ?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase())) - ?.map(({ id, key, value: secVal }) => ( - ( - onChange(isChecked ? secVal : "")} - > - {key} - - )} - /> - ))} -
-
- + setShouldIncludeValues(isChecked as boolean)} > Include secret values - +
)} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index cc68b0700..b6793ea1d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -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 ( - - - - - - - - - {isLoading && } - {!isLoading && - data && - data.map(({ id, slug }) => ( - - - - - ))} - {!isLoading && data && data?.length === 0 && ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search tags..." + /> + +
Slug -
{slug} - - {(isAllowed) => ( - - handlePopUpOpen("deleteTagConfirmation", { - name: slug, - id - }) - } - colorSchema="danger" - ariaLabel="update" - isDisabled={!isAllowed} - > - - - )} - -
+ - + + - )} - -
- - +
+ Slug + + + +
+
-
+ + + {isLoading && } + {!isLoading && + filteredTags.slice(offset, perPage * page).map(({ id, slug }) => ( + + {slug} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteTagConfirmation", { + name: slug, + id + }) + } + size="xs" + colorSchema="danger" + ariaLabel="update" + variant="plain" + isDisabled={!isAllowed} + > + + + )} + + + + ))} + + + {Boolean(filteredTags.length) && ( + + )} + {!isLoading && !filteredTags?.length && ( + + )} + +
); };