Merge branch 'main' into secret-approval-filterable-selects

This commit is contained in:
Scott Wilson
2024-12-02 09:44:04 -08:00
committed by GitHub
27 changed files with 1169 additions and 696 deletions

View File

@ -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

View File

@ -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 │
├───────────────┼──────────────┼─────────────┤

View File

@ -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:]
}

View File

@ -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)
// }
// }

View File

@ -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
<Info>
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.
</Info>
### 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).
<Warning>
The allowed audience field of the Kubernetes authentication settings should
match the audience specified for the Secrets Store CSI driver during
installation.
</Warning>
### 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"
```
<Note>
The SecretProviderClass should be provisioned in the same namespace as the pod
you intend to mount secrets to.
</Note>
#### Supported Parameters
<Accordion title="infisicalUrl">
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`.
</Accordion>
<Accordion title="caCertificate">
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.
</Accordion>
<Accordion title="authMethod">
The auth method to use for authenticating the Infisical CSI provider with
Infisical. For now, the only supported method is `kubernetes`.
</Accordion>
<Accordion title="identityId">
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.
</Accordion>
<Accordion title="projectId">
The project ID of the Infisical project to pull secrets from.
</Accordion>
<Accordion title="envSlug">
The slug of the project environment to pull secrets from.
</Accordion>
<Accordion title="secrets">
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.
</Accordion>
<Accordion title="audience">
The custom audience value configured for the CSI driver. This defaults to
`infisical`.
</Accordion>
### 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
<AccordionGroup>
<Accordion title="Is it possible to sync Infisical secrets as environment variables?">
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).
</Accordion>
</AccordionGroup>
<AccordionGroup>
<Accordion title="Do I have to list out every Infisical single secret that I want to sync?">
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.
</Accordion>
</AccordionGroup>

View File

@ -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"
]

View File

@ -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 (
<div
@ -142,14 +154,59 @@ export default function NavHeader({
>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
{index + 1 === secretPathSegments?.length ? (
<span className="text-sm font-semibold text-bunker-300">{folderName}</span>
<div className="flex items-center space-x-2">
<span
className={twMerge(
"text-sm font-semibold transition-all",
isHoveringCopyButton ? "text-bunker-200" : "text-bunker-300"
)}
>
{folderName}
</span>
<Tooltip
className="relative right-2"
position="bottom"
content="Copy secret path"
>
<IconButton
variant="plain"
ariaLabel="copy"
onMouseEnter={() => 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"
>
<FontAwesomeIcon
icon={!isCopied ? faCopy : faCheck}
size="sm"
className="cursor-pointer"
/>
</IconButton>
</Tooltip>
</div>
) : (
<Link
passHref
legacyBehavior
href={{ pathname: "/project/[id]/secrets/[env]", query }}
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
<a
className={twMerge(
"text-sm font-semibold transition-all hover:text-primary",
isHoveringCopyButton ? "text-primary" : "text-primary/80"
)}
>
{folderName}
</a>
</Link>

View File

@ -32,7 +32,13 @@ export const FilterableSelect = <T,>({
})
}}
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 = <T,>({
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"
}}

View File

@ -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>

View File

@ -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);

View File

@ -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 (
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
@ -448,97 +377,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
)}
{!router.asPath.includes("org") &&
(!router.asPath.includes("personal") && currentWorkspace ? (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?.id}
value={currentWorkspace?.id}
className="w-full bg-mineshaft-600 py-2.5 font-medium [&>*:first-child]:truncate"
onValueChange={(value) => {
localStorage.setItem("projectData.id", value);
// 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/${value}/secrets/overview`);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
{workspacesWithFaveProp
.filter((ws) => ws.orgId === currentOrg?.id)
.map(({ id, name, isFavorite }) => (
<div
className={twMerge(
"mb-1 grid grid-cols-7 rounded-md hover:bg-mineshaft-500",
id === currentWorkspace?.id && "bg-mineshaft-500"
)}
key={id}
>
<div className="col-span-6">
<SelectItem
key={`ws-layout-list-${id}`}
value={id}
className="transition-none data-[highlighted]:bg-mineshaft-500"
>
{name}
</SelectItem>
</div>
<div className="col-span-1 flex items-center">
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-mineshaft-300 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(id);
}}
/>
)}
</div>
</div>
))}
</div>
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
<div className="w-full">
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.Workspace}
>
{(isAllowed) => (
<Button
className="w-full bg-mineshaft-700 py-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
isDisabled={!isAllowed}
onClick={() => {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
)}
</OrgPermissionCan>
</div>
</Select>
</div>
<ProjectSelect />
) : (
<Link href={`/org/${currentOrg?.id}/overview`}>
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
@ -816,15 +655,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div>
</nav>
</aside>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
/>
<CreateOrgModal
isOpen={popUp?.createOrg?.isOpen}
onClose={() => handlePopUpToggle("createOrg", false)}

View File

@ -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<TWorkspaceWithFaveProp>) => {
return (
<components.Menu {...props}>
{children}
<hr className="mb-2 h-px border-0 bg-mineshaft-500" />
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
className="w-full bg-mineshaft-700 pt-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => props.clearValue()}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
)}
</OrgPermissionCan>
</components.Menu>
);
};
const ProjectOption = ({
isSelected,
children,
data,
...props
}: OptionProps<TWorkspaceWithFaveProp>) => {
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 (
<components.Option
isSelected={isSelected}
data={data}
{...props}
className={twMerge(props.className, isSelected && "bg-mineshaft-500")}
>
<div className="flex w-full items-center">
{isSelected && (
<FontAwesomeIcon className="mr-2 text-mineshaft-300" icon={faEye} size="sm" />
)}
<p className="truncate">{children}</p>
{data.isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-auto text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={async (e) => {
e.stopPropagation();
await removeProjectFromFavorites(data.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-auto text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={async (e) => {
e.stopPropagation();
await addProjectToFavorites(data.id);
}}
/>
)}
</div>
</components.Option>
);
};
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 (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">Project</p>
<FilterableSelect
className="text-sm"
value={value}
filterOption={(option, inputValue) =>
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
}}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You have exceeded the number of projects allowed on the free plan."
/>
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
/>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./ProjectSelect";

View File

@ -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}

View File

@ -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>
)}
/>

View File

@ -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>
)}
/>

View File

@ -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>
)}
/>

View File

@ -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}

View File

@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
});
}}

View File

@ -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>
)}
/>

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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>
)}
/>

View File

@ -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>
)}
/>

View File

@ -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

View File

@ -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>

View File

@ -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>
);
};