mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-02 19:25:51 +00:00
Merge branch 'main' into secret-approval-filterable-selects
This commit is contained in:
@@ -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
|
If this issue continues, get support at https://infisical.com/slack
|
||||||
|
|||||||
@@ -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 │
|
│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │
|
||||||
├───────────────┼──────────────┼─────────────┤
|
├───────────────┼──────────────┼─────────────┤
|
||||||
|
|||||||
+36
-3
@@ -1,6 +1,7 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -41,11 +42,12 @@ var creds = Credentials{
|
|||||||
func ExecuteCliCommand(command string, args ...string) (string, error) {
|
func ExecuteCliCommand(command string, args ...string) (string, error) {
|
||||||
cmd := exec.Command(command, args...)
|
cmd := exec.Command(command, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(fmt.Sprint(err) + ": " + string(output))
|
fmt.Println(fmt.Sprint(err) + ": " + FilterRequestID(strings.TrimSpace(string(output))))
|
||||||
return strings.TrimSpace(string(output)), err
|
return FilterRequestID(strings.TrimSpace(string(output))), err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(output)), nil
|
return FilterRequestID(strings.TrimSpace(string(output))), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetupCli() {
|
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:]
|
||||||
|
}
|
||||||
|
|||||||
+22
-22
@@ -3,7 +3,6 @@ package tests
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Infisical/infisical-merge/packages/util"
|
|
||||||
"github.com/bradleyjkemp/cupaloy/v2"
|
"github.com/bradleyjkemp/cupaloy/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,28 +95,29 @@ func TestUserAuth_SecretsGetAll(t *testing.T) {
|
|||||||
// testUserAuth_SecretsGetAllWithoutConnection(t)
|
// testUserAuth_SecretsGetAllWithoutConnection(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) {
|
// disabled for the time being
|
||||||
originalConfigFile, err := util.GetConfigFile()
|
// func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) {
|
||||||
if err != nil {
|
// originalConfigFile, err := util.GetConfigFile()
|
||||||
t.Fatalf("error getting config file")
|
// if err != nil {
|
||||||
}
|
// t.Fatalf("error getting config file")
|
||||||
newConfigFile := originalConfigFile
|
// }
|
||||||
|
// newConfigFile := originalConfigFile
|
||||||
|
|
||||||
// set it to a URL that will always be unreachable
|
// // set it to a URL that will always be unreachable
|
||||||
newConfigFile.LoggedInUserDomain = "http://localhost:4999"
|
// newConfigFile.LoggedInUserDomain = "http://localhost:4999"
|
||||||
util.WriteConfigFile(&newConfigFile)
|
// util.WriteConfigFile(&newConfigFile)
|
||||||
|
|
||||||
// restore config file
|
// // restore config file
|
||||||
defer util.WriteConfigFile(&originalConfigFile)
|
// defer util.WriteConfigFile(&originalConfigFile)
|
||||||
|
|
||||||
output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent")
|
// output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent")
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
t.Fatalf("error running CLI command: %v", err)
|
// t.Fatalf("error running CLI command: %v", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Use cupaloy to snapshot test the output
|
// // Use cupaloy to snapshot test the output
|
||||||
err = cupaloy.Snapshot(output)
|
// err = cupaloy.Snapshot(output)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
t.Fatalf("snapshot failed: %v", err)
|
// t.Fatalf("snapshot failed: %v", err)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -345,6 +345,7 @@
|
|||||||
"group": "Container orchestrators",
|
"group": "Container orchestrators",
|
||||||
"pages": [
|
"pages": [
|
||||||
"integrations/platforms/kubernetes",
|
"integrations/platforms/kubernetes",
|
||||||
|
"integrations/platforms/kubernetes-csi",
|
||||||
"integrations/platforms/docker-swarm-with-agent",
|
"integrations/platforms/docker-swarm-with-agent",
|
||||||
"integrations/platforms/ecs-with-agent"
|
"integrations/platforms/ecs-with-agent"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import { ParsedUrlQuery } from "querystring";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useOrganization, useWorkspace } from "@app/context";
|
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 = {
|
type Props = {
|
||||||
pageName: string;
|
pageName: string;
|
||||||
@@ -50,6 +56,10 @@ export default function NavHeader({
|
|||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
|
|
||||||
|
const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false);
|
||||||
|
const [isHoveringCopyButton, setIsHoveringCopyButton] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const secretPathSegments = secretPath.split("/").filter(Boolean);
|
const secretPathSegments = secretPath.split("/").filter(Boolean);
|
||||||
@@ -132,8 +142,10 @@ export default function NavHeader({
|
|||||||
)}
|
)}
|
||||||
{isFolderMode &&
|
{isFolderMode &&
|
||||||
secretPathSegments?.map((folderName, index) => {
|
secretPathSegments?.map((folderName, index) => {
|
||||||
const query = { ...router.query };
|
const query: ParsedUrlQuery & { secretPath: string } = {
|
||||||
query.secretPath = `/${secretPathSegments.slice(0, index + 1).join("/")}`;
|
...router.query,
|
||||||
|
secretPath: `/${secretPathSegments.slice(0, index + 1).join("/")}`
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -142,14 +154,59 @@ export default function NavHeader({
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||||
{index + 1 === secretPathSegments?.length ? (
|
{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
|
<Link
|
||||||
passHref
|
passHref
|
||||||
legacyBehavior
|
legacyBehavior
|
||||||
href={{ pathname: "/project/[id]/secrets/[env]", query }}
|
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}
|
{folderName}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ export const FilterableSelect = <T,>({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
tabSelectsValue={tabSelectsValue}
|
tabSelectsValue={tabSelectsValue}
|
||||||
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
|
components={{
|
||||||
|
DropdownIndicator,
|
||||||
|
ClearIndicator,
|
||||||
|
MultiValueRemove,
|
||||||
|
Option,
|
||||||
|
...props.components
|
||||||
|
}}
|
||||||
classNames={{
|
classNames={{
|
||||||
container: ({ isDisabled }) =>
|
container: ({ isDisabled }) =>
|
||||||
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
|
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",
|
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
|
||||||
indicatorSeparator: () => "bg-bunker-400",
|
indicatorSeparator: () => "bg-bunker-400",
|
||||||
dropdownIndicator: () => "text-bunker-200 p-1",
|
dropdownIndicator: () => "text-bunker-200 p-1",
|
||||||
|
menuList: () => "flex flex-col gap-1",
|
||||||
menu: () =>
|
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",
|
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
|
||||||
option: ({ isFocused, isSelected }) =>
|
option: ({ isFocused, isSelected }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
|
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
|
||||||
isSelected && "text-mineshaft-200",
|
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"
|
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const Pagination = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{startAdornment}
|
{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">
|
<div className="text-xs">
|
||||||
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
|
||||||
|
|
||||||
enum OrgMembershipRole {
|
enum OrgMembershipRole {
|
||||||
Admin = "admin",
|
Admin = "admin",
|
||||||
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
|
|||||||
|
|
||||||
export const isCustomProjectRole = (slug: string) =>
|
export const isCustomProjectRole = (slug: string) =>
|
||||||
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
!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);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
|
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
|
||||||
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import {
|
import {
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
@@ -22,15 +21,11 @@ import {
|
|||||||
faInfo,
|
faInfo,
|
||||||
faMobile,
|
faMobile,
|
||||||
faPlus,
|
faPlus,
|
||||||
faQuestion,
|
faQuestion
|
||||||
faStar as faSolidStar
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
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 { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage";
|
||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||||
import {
|
import {
|
||||||
@@ -39,20 +34,9 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem
|
||||||
Select,
|
|
||||||
SelectItem,
|
|
||||||
UpgradePlanModal
|
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { NewProjectModal } from "@app/components/v2/projects/NewProjectModal";
|
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
|
||||||
import {
|
|
||||||
OrgPermissionActions,
|
|
||||||
OrgPermissionSubjects,
|
|
||||||
useOrganization,
|
|
||||||
useSubscription,
|
|
||||||
useUser,
|
|
||||||
useWorkspace
|
|
||||||
} from "@app/context";
|
|
||||||
import { usePopUp, useToggle } from "@app/hooks";
|
import { usePopUp, useToggle } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
useGetAccessRequestsCount,
|
useGetAccessRequestsCount,
|
||||||
@@ -62,11 +46,9 @@ import {
|
|||||||
useSelectOrganization
|
useSelectOrganization
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
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 { AuthMethod } from "@app/hooks/api/users/types";
|
||||||
import { InsecureConnectionBanner } from "@app/layouts/AppLayout/components/InsecureConnectionBanner";
|
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 { navigateUserToOrg } from "@app/views/Login/Login.utils";
|
||||||
import { Mfa } from "@app/views/Login/Mfa";
|
import { Mfa } from "@app/views/Login/Mfa";
|
||||||
import { CreateOrgModal } from "@app/views/Org/components";
|
import { CreateOrgModal } from "@app/views/Org/components";
|
||||||
@@ -108,23 +90,10 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
const { workspaces, currentWorkspace } = useWorkspace();
|
const { workspaces, currentWorkspace } = useWorkspace();
|
||||||
const { orgs, currentOrg } = useOrganization();
|
const { orgs, currentOrg } = useOrganization();
|
||||||
|
|
||||||
const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!);
|
|
||||||
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
|
|
||||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||||
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
|
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
|
||||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
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 { user } = useUser();
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
@@ -137,17 +106,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
|
return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
|
||||||
}, [secretApprovalReqCount, accessApprovalRequestCount]);
|
}, [secretApprovalReqCount, accessApprovalRequestCount]);
|
||||||
|
|
||||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
|
||||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
|
||||||
: true;
|
|
||||||
|
|
||||||
const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION;
|
const infisicalPlatformVersion = process.env.NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION;
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
|
||||||
"addNewWs",
|
|
||||||
"upgradePlan",
|
|
||||||
"createOrg"
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -230,38 +191,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
putUserInOrg();
|
putUserInOrg();
|
||||||
}, [router.query.id]);
|
}, [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) {
|
if (shouldShowMfa) {
|
||||||
return (
|
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">
|
<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("org") &&
|
||||||
(!router.asPath.includes("personal") && currentWorkspace ? (
|
(!router.asPath.includes("personal") && currentWorkspace ? (
|
||||||
<div className="mt-5 mb-4 w-full p-3">
|
<ProjectSelect />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<Link href={`/org/${currentOrg?.id}/overview`}>
|
<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">
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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
|
<CreateOrgModal
|
||||||
isOpen={popUp?.createOrg?.isOpen}
|
isOpen={popUp?.createOrg?.isOpen}
|
||||||
onClose={() => handlePopUpToggle("createOrg", false)}
|
onClose={() => handlePopUpToggle("createOrg", false)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ProjectSelect";
|
||||||
@@ -876,7 +876,7 @@ const OrganizationPage = () => {
|
|||||||
<Pagination
|
<Pagination
|
||||||
className={
|
className={
|
||||||
projectsViewMode === ProjectsViewMode.GRID
|
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"
|
: "rounded-b-md border border-mineshaft-600"
|
||||||
}
|
}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
|
|||||||
+21
-23
@@ -6,14 +6,14 @@ import { z } from "zod";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
FormControl,
|
FormControl,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent
|
||||||
Select,
|
|
||||||
SelectItem
|
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
|
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(5, "Slug must be at least 5 characters long")
|
.min(5, "Slug must be at least 5 characters long")
|
||||||
.max(36, "Slug must be 36 characters or fewer"),
|
.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>;
|
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||||
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
|||||||
reset({
|
reset({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
role: group?.customRole?.slug ?? group.role
|
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset({
|
reset({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
role: roles[0].slug
|
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [popUp?.group?.data, roles]);
|
}, [popUp?.group?.data, roles]);
|
||||||
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
|||||||
id: group.groupId,
|
id: group.groupId,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
role: role || undefined
|
role: role.slug || undefined
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createMutateAsync({
|
await createMutateAsync({
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
organizationId: currentOrg.id,
|
organizationId: currentOrg.id,
|
||||||
role: role || undefined
|
role: role.slug || undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handlePopUpToggle("group", false);
|
handlePopUpToggle("group", false);
|
||||||
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
|||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
|
<ModalContent
|
||||||
|
bodyClassName="overflow-visible"
|
||||||
|
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="role"
|
name="role"
|
||||||
defaultValue=""
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
<FormControl
|
||||||
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Select
|
<FilterableSelect
|
||||||
defaultValue={field.value}
|
options={roles}
|
||||||
{...field}
|
placeholder="Select role..."
|
||||||
onValueChange={(e) => onChange(e)}
|
onChange={onChange}
|
||||||
className="w-full"
|
value={value}
|
||||||
>
|
getOptionValue={(option) => option.slug}
|
||||||
{(roles || []).map(({ name, slug }) => (
|
getOptionLabel={(option) => option.name}
|
||||||
<SelectItem value={slug} key={`org-group-role-${slug}`}>
|
/>
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+23
-28
@@ -9,27 +9,24 @@ import { z } from "zod";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent
|
||||||
Select,
|
|
||||||
SelectItem
|
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
|
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||||
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||||
import {
|
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
|
||||||
// IdentityAuthMethod,
|
|
||||||
useAddIdentityUniversalAuth
|
|
||||||
} from "@app/hooks/api/identities";
|
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const schema = z
|
const schema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string().min(1, "Required"),
|
||||||
role: z.string(),
|
role: z.object({ slug: z.string(), name: z.string() }),
|
||||||
metadata: z
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
key: z.string().trim().min(1),
|
key: z.string().trim().min(1),
|
||||||
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
if (identity) {
|
if (identity) {
|
||||||
reset({
|
reset({
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
role: identity?.customRole?.slug ?? identity.role,
|
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
|
||||||
metadata: identity.metadata
|
metadata: identity.metadata
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset({
|
reset({
|
||||||
name: "",
|
name: "",
|
||||||
role: roles[0].slug
|
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [popUp?.identity?.data, roles]);
|
}, [popUp?.identity?.data, roles]);
|
||||||
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
await updateMutateAsync({
|
await updateMutateAsync({
|
||||||
identityId: identity.identityId,
|
identityId: identity.identityId,
|
||||||
name,
|
name,
|
||||||
role: role || undefined,
|
role: role.slug || undefined,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
|
|
||||||
const { id: createdId } = await createMutateAsync({
|
const { id: createdId } = await createMutateAsync({
|
||||||
name,
|
name,
|
||||||
role: role || undefined,
|
role: role.slug || undefined,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
|
<ModalContent
|
||||||
|
bodyClassName="overflow-visible"
|
||||||
|
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
|
||||||
|
>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="role"
|
name="role"
|
||||||
defaultValue=""
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
<FormControl
|
||||||
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Select
|
<FilterableSelect
|
||||||
defaultValue={field.value}
|
placeholder="Select role..."
|
||||||
{...field}
|
options={roles}
|
||||||
onValueChange={(e) => onChange(e)}
|
onChange={onChange}
|
||||||
className="w-full"
|
value={value}
|
||||||
>
|
getOptionValue={(option) => option.slug}
|
||||||
{(roles || []).map(({ name, slug }) => (
|
getOptionLabel={(option) => option.name}
|
||||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
/>
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+18
-22
@@ -15,7 +15,7 @@ import {
|
|||||||
TextArea
|
TextArea
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||||
import {
|
import {
|
||||||
useAddUsersToOrg,
|
useAddUsersToOrg,
|
||||||
useFetchServerStatus,
|
useFetchServerStatus,
|
||||||
@@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({
|
|||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
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>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
@@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organizationRoles) {
|
if (organizationRoles) {
|
||||||
reset({
|
reset({
|
||||||
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
|
organizationRole: findOrgMembershipRole(
|
||||||
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
|
organizationRoles,
|
||||||
: currentOrg?.defaultMembershipRole
|
currentOrg?.defaultMembershipRole!
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [organizationRoles]);
|
}, [organizationRoles]);
|
||||||
|
|
||||||
const onAddMembers = async ({
|
const onAddMembers = async ({
|
||||||
emails,
|
emails,
|
||||||
organizationRoleSlug,
|
organizationRole,
|
||||||
projects: selectedProjects,
|
projects: selectedProjects,
|
||||||
projectRoleSlug
|
projectRoleSlug
|
||||||
}: TAddMemberForm) => {
|
}: TAddMemberForm) => {
|
||||||
@@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({
|
|||||||
const { data } = await addUsersMutateAsync({
|
const { data } = await addUsersMutateAsync({
|
||||||
organizationId: currentOrg?.id,
|
organizationId: currentOrg?.id,
|
||||||
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||||
organizationRoleSlug,
|
organizationRoleSlug: organizationRole.slug,
|
||||||
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
|
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({
|
|||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="organizationRoleSlug"
|
name="organizationRole"
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
tooltipText="Select which organization role you want to assign to the user."
|
tooltipText="Select which organization role you want to assign to the user."
|
||||||
label="Assign organization role"
|
label="Assign organization role"
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
>
|
>
|
||||||
<div>
|
<FilterableSelect
|
||||||
<Select
|
placeholder="Select role..."
|
||||||
className="w-full"
|
options={organizationRoles}
|
||||||
{...field}
|
getOptionValue={(option) => option.slug}
|
||||||
onValueChange={(val) => field.onChange(val)}
|
getOptionLabel={(option) => option.name}
|
||||||
>
|
value={value}
|
||||||
{organizationRoles?.map((role) => (
|
onChange={onChange}
|
||||||
<SelectItem key={role.id} value={role.slug}>
|
/>
|
||||||
{role.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ export const UserPage = withPermission(
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handlePopUpOpen("orgMembership", {
|
handlePopUpOpen("orgMembership", {
|
||||||
membershipId: membership.id,
|
membershipId: membership.id,
|
||||||
role: membership.role
|
role: membership.role,
|
||||||
|
roleId: membership.roleId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!isAllowed}
|
disabled={!isAllowed}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
|||||||
handlePopUpOpen("orgMembership", {
|
handlePopUpOpen("orgMembership", {
|
||||||
membershipId: membership.id,
|
membershipId: membership.id,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
|
roleId: membership.roleId,
|
||||||
metadata: membership.metadata
|
metadata: membership.metadata
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { SingleValue } from "react-select";
|
||||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -8,21 +9,21 @@ import { z } from "zod";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent
|
||||||
Select,
|
|
||||||
SelectItem
|
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useOrganization, useSubscription } from "@app/context";
|
import { useOrganization, useSubscription } from "@app/context";
|
||||||
|
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
|
||||||
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
role: z.string(),
|
role: z.object({ name: z.string(), slug: z.string() }),
|
||||||
metadata: z
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
key: z.string().trim().min(1),
|
key: z.string().trim().min(1),
|
||||||
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
|
|
||||||
const { data: roles } = useGetOrgRoles(orgId);
|
const { data: roles = [] } = useGetOrgRoles(orgId);
|
||||||
|
|
||||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
const popUpData = popUp?.orgMembership?.data as {
|
const popUpData = popUp?.orgMembership?.data as {
|
||||||
membershipId: string;
|
membershipId: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
roleId?: string;
|
||||||
metadata: { key: string; value: string }[];
|
metadata: { key: string; value: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
|
|
||||||
if (popUpData) {
|
if (popUpData) {
|
||||||
reset({
|
reset({
|
||||||
role: popUpData.role,
|
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
|
||||||
metadata: popUpData.metadata
|
metadata: popUpData.metadata
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset({
|
reset({
|
||||||
role: roles[0].slug
|
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [popUp?.orgMembership?.data, roles]);
|
}, [popUp?.orgMembership?.data, roles]);
|
||||||
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
await updateOrgMembership({
|
await updateOrgMembership({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
membershipId: popUpData.membershipId,
|
membershipId: popUpData.membershipId,
|
||||||
role,
|
role: role.slug,
|
||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent title="Update Membership">
|
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="role"
|
name="role"
|
||||||
defaultValue=""
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Update Organization Role"
|
label="Update Organization Role"
|
||||||
errorText={error?.message}
|
errorText={error?.message}
|
||||||
isError={Boolean(error)}
|
isError={Boolean(error)}
|
||||||
>
|
>
|
||||||
<Select
|
<FilterableSelect
|
||||||
defaultValue={field.value}
|
placeholder="Select role..."
|
||||||
{...field}
|
options={roles}
|
||||||
onValueChange={(e) => {
|
onChange={(newValue) => {
|
||||||
const isCustomRole = !["admin", "member", "no-access"].includes(e);
|
const role = newValue as SingleValue<(typeof roles)[number]>;
|
||||||
|
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
const isCustomRole = isCustomOrgRole(role.slug);
|
||||||
|
|
||||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||||
handlePopUpOpen("upgradePlan", {
|
handlePopUpOpen("upgradePlan", {
|
||||||
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(e);
|
onChange(role);
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
value={value}
|
||||||
>
|
getOptionValue={(option) => option.slug}
|
||||||
{(roles || []).map(({ name, slug }) => (
|
getOptionLabel={(option) => option.name}
|
||||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
/>
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+121
-25
@@ -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 { OrgUser } from "@app/hooks/api/types";
|
||||||
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
@@ -12,31 +33,106 @@ type Props = {
|
|||||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
|
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum UserGroupsOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
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 (
|
return (
|
||||||
<TableContainer>
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Name</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th className="w-5" />
|
placeholder="Search groups..."
|
||||||
</Tr>
|
/>
|
||||||
</THead>
|
<TableContainer className="mt-4">
|
||||||
<TBody>
|
<Table>
|
||||||
{groups?.map((group) => (
|
<THead>
|
||||||
<UserGroupsRow
|
<Tr>
|
||||||
key={`user-group-${group.id}`}
|
<Th className="w-full">
|
||||||
group={group}
|
<div className="flex items-center">
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
Name
|
||||||
/>
|
<IconButton
|
||||||
))}
|
variant="plain"
|
||||||
</TBody>
|
className="ml-2"
|
||||||
</Table>
|
ariaLabel="sort"
|
||||||
{!isLoading && !groups?.length && (
|
onClick={toggleOrderDirection}
|
||||||
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
|
>
|
||||||
)}
|
<FontAwesomeIcon
|
||||||
</TableContainer>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+83
-92
@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
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 { useOrganization, useWorkspace } from "@app/context";
|
||||||
import {
|
import {
|
||||||
useAddGroupToWorkspace,
|
useAddGroupToWorkspace,
|
||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.string(),
|
group: z.object({ id: z.string(), name: z.string() }),
|
||||||
role: z.string()
|
role: z.object({ slug: z.string(), name: z.string() })
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormData = z.infer<typeof schema>;
|
export type FormData = z.infer<typeof schema>;
|
||||||
@@ -27,7 +27,9 @@ type Props = {
|
|||||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
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 { currentOrg } = useOrganization();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
@@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
resolver: zodResolver(schema)
|
resolver: zodResolver(schema)
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ id, role }: FormData) => {
|
const onFormSubmit = async ({ group, role }: FormData) => {
|
||||||
try {
|
try {
|
||||||
await addGroupToWorkspaceMutateAsync({
|
await addGroupToWorkspaceMutateAsync({
|
||||||
projectId: currentWorkspace?.id || "",
|
projectId: currentWorkspace?.id || "",
|
||||||
groupId: id,
|
groupId: group.id,
|
||||||
role: role || undefined
|
role: role.slug || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
reset();
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.group?.isOpen}
|
isOpen={popUp?.group?.isOpen}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
|
||||||
handlePopUpToggle("group", isOpen);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ModalContent title="Add Group to Project">
|
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
|
||||||
{filteredGroupMembershipOrgs.length ? (
|
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<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>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-84
@@ -2,24 +2,11 @@ import { useMemo } from "react";
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import {
|
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
FilterableSelect,
|
|
||||||
FormControl,
|
|
||||||
Modal,
|
|
||||||
ModalContent
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { useOrganization, useWorkspace } from "@app/context";
|
import { useOrganization, useWorkspace } from "@app/context";
|
||||||
import {
|
import {
|
||||||
useAddUsersToOrg,
|
useAddUsersToOrg,
|
||||||
@@ -33,7 +20,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
|||||||
|
|
||||||
const addMemberFormSchema = z.object({
|
const addMemberFormSchema = z.object({
|
||||||
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
|
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>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
@@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
formState: { isSubmitting, errors }
|
formState: { isSubmitting, errors }
|
||||||
} = useForm<TAddMemberForm>({
|
} = useForm<TAddMemberForm>({
|
||||||
resolver: zodResolver(addMemberFormSchema),
|
resolver: zodResolver(addMemberFormSchema),
|
||||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
|
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||||
@@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
{
|
{
|
||||||
slug: currentWorkspace.slug,
|
slug: currentWorkspace.slug,
|
||||||
id: currentWorkspace.id,
|
id: currentWorkspace.id,
|
||||||
projectRoleSlug: projectRoleSlugs
|
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -172,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="projectRoleSlugs"
|
name="projectRoleSlugs"
|
||||||
render={({ field }) => (
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Select roles"
|
label="Select roles"
|
||||||
tooltipText="Select the roles that you wish to assign to the users"
|
tooltipText="Select the roles that you wish to assign to the users"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<FilterableSelect
|
||||||
<DropdownMenuTrigger asChild>
|
options={roles}
|
||||||
{roles && roles.length > 0 ? (
|
placeholder="Select roles..."
|
||||||
<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">
|
value={value}
|
||||||
{/* eslint-disable-next-line no-nested-ternary */}
|
onChange={onChange}
|
||||||
{selectedRoleSlugs.length === 1
|
isMulti
|
||||||
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
|
getOptionValue={(option) => option.slug}
|
||||||
: selectedRoleSlugs.length === 0
|
getOptionLabel={(option) => option.name}
|
||||||
? "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>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
FormControl,
|
FormControl,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -17,7 +18,7 @@ import { useSubscription, useWorkspace } from "@app/context";
|
|||||||
import { useCreateSecretImport } from "@app/hooks/api";
|
import { useCreateSecretImport } from "@app/hooks/api";
|
||||||
|
|
||||||
const typeSchema = z.object({
|
const typeSchema = z.object({
|
||||||
environment: z.string().trim(),
|
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||||
secretPath: z
|
secretPath: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -80,7 +81,7 @@ export const CreateSecretImportForm = ({
|
|||||||
path: secretPath,
|
path: secretPath,
|
||||||
isReplication,
|
isReplication,
|
||||||
import: {
|
import: {
|
||||||
environment: importedEnv,
|
environment: importedEnv.slug,
|
||||||
path: importedSecPath
|
path: importedSecPath
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,8 +89,9 @@ export const CreateSecretImportForm = ({
|
|||||||
reset();
|
reset();
|
||||||
createNotification({
|
createNotification({
|
||||||
type: "success",
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -111,6 +113,7 @@ export const CreateSecretImportForm = ({
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
|
bodyClassName="overflow-visible"
|
||||||
title="Add Secret Link"
|
title="Add Secret Link"
|
||||||
subTitle="To inherit secrets from another environment or folder"
|
subTitle="To inherit secrets from another environment or folder"
|
||||||
>
|
>
|
||||||
@@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="environment"
|
name="environment"
|
||||||
defaultValue={environments?.[0]?.slug}
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
|
||||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||||
<Select
|
<FilterableSelect
|
||||||
defaultValue={field.value}
|
options={environments}
|
||||||
{...field}
|
getOptionLabel={(option) => option.name}
|
||||||
onValueChange={(e) => onChange(e)}
|
getOptionValue={(option) => option.slug}
|
||||||
className="w-full"
|
placeholder="Select environment..."
|
||||||
>
|
value={value}
|
||||||
{environments.map(({ name, slug }) => (
|
onChange={onChange}
|
||||||
<SelectItem value={slug} key={slug}>
|
/>
|
||||||
{name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({
|
|||||||
defaultValue="/"
|
defaultValue="/"
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||||
<SecretPathInput {...field} environment={selectedEnvironment} />
|
<SecretPathInput {...field} environment={selectedEnvironment?.slug} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+72
-111
@@ -1,14 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import {
|
import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
faClone,
|
|
||||||
faFileImport,
|
|
||||||
faKey,
|
|
||||||
faSearch,
|
|
||||||
faSquareCheck,
|
|
||||||
faSquareXmark
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -16,17 +9,13 @@ import { z } from "zod";
|
|||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
FilterableSelect,
|
||||||
EmptyState,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalTrigger,
|
ModalTrigger,
|
||||||
Select,
|
Switch,
|
||||||
SelectItem,
|
|
||||||
Skeleton,
|
|
||||||
Tooltip
|
Tooltip
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||||
@@ -35,14 +24,17 @@ import { useDebounce } from "@app/hooks";
|
|||||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
environment: z.string().trim(),
|
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||||
secretPath: z
|
secretPath: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.transform((val) =>
|
.transform((val) =>
|
||||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : 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>;
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
@@ -68,7 +60,6 @@ export const CopySecretsFromBoard = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onParsedEnv
|
onParsedEnv
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [searchFilter, setSearchFilter] = useState("");
|
|
||||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -80,7 +71,7 @@ export const CopySecretsFromBoard = ({
|
|||||||
formState: { isDirty }
|
formState: { isDirty }
|
||||||
} = useForm<TFormSchema>({
|
} = useForm<TFormSchema>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
defaultValues: { secretPath: "/", environment: environments?.[0] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const envCopySecPath = watch("secretPath");
|
const envCopySecPath = watch("secretPath");
|
||||||
@@ -89,7 +80,7 @@ export const CopySecretsFromBoard = ({
|
|||||||
|
|
||||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
environment: selectedEnvSlug,
|
environment: selectedEnvSlug.slug,
|
||||||
secretPath: debouncedEnvCopySecretPath,
|
secretPath: debouncedEnvCopySecretPath,
|
||||||
options: {
|
options: {
|
||||||
enabled:
|
enabled:
|
||||||
@@ -101,29 +92,22 @@ export const CopySecretsFromBoard = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("secrets", {});
|
setValue("secrets", []);
|
||||||
setSearchFilter("");
|
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||||
}, [debouncedEnvCopySecretPath]);
|
|
||||||
|
|
||||||
const handleSecSelectAll = () => {
|
const handleSecSelectAll = () => {
|
||||||
if (secrets) {
|
if (secrets) {
|
||||||
setValue(
|
setValue("secrets", secrets, { shouldDirty: true });
|
||||||
"secrets",
|
|
||||||
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (data: TFormSchema) => {
|
const handleFormSubmit = async (data: TFormSchema) => {
|
||||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||||
Object.keys(data.secrets || {}).forEach((key) => {
|
data.secrets.forEach(({ key, value }) => {
|
||||||
if (data.secrets[key]) {
|
secretsToBePulled[key] = {
|
||||||
secretsToBePulled[key] = {
|
value: (shouldIncludeValues && value) || "",
|
||||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
comments: [""]
|
||||||
comments: [""]
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
onParsedEnv(secretsToBePulled);
|
onParsedEnv(secretsToBePulled);
|
||||||
onToggle(false);
|
onToggle(false);
|
||||||
@@ -136,7 +120,6 @@ export const CopySecretsFromBoard = ({
|
|||||||
onOpenChange={(state) => {
|
onOpenChange={(state) => {
|
||||||
onToggle(state);
|
onToggle(state);
|
||||||
reset();
|
reset();
|
||||||
setSearchFilter("");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalTrigger asChild>
|
<ModalTrigger asChild>
|
||||||
@@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
|
|||||||
</div>
|
</div>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
|
bodyClassName="overflow-visible"
|
||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
title="Copy Secret From An Environment"
|
title="Copy Secret From An Environment"
|
||||||
subTitle="Copy/paste secrets from other environments into this context"
|
subTitle="Copy/paste secrets from other environments into this context"
|
||||||
@@ -176,22 +160,14 @@ export const CopySecretsFromBoard = ({
|
|||||||
name="environment"
|
name="environment"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<FormControl label="Environment" isRequired className="w-1/3">
|
<FormControl label="Environment" isRequired className="w-1/3">
|
||||||
<Select
|
<FilterableSelect
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(val) => onChange(val)}
|
onChange={onChange}
|
||||||
className="w-full border border-mineshaft-500"
|
options={environments}
|
||||||
defaultValue={environments?.[0]?.slug}
|
placeholder="Select environment..."
|
||||||
position="popper"
|
getOptionLabel={(option) => option.name}
|
||||||
>
|
getOptionValue={(option) => option.slug}
|
||||||
{environments.map((sourceEnvironment) => (
|
/>
|
||||||
<SelectItem
|
|
||||||
value={sourceEnvironment.slug}
|
|
||||||
key={`source-environment-${sourceEnvironment.slug}`}
|
|
||||||
>
|
|
||||||
{sourceEnvironment.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({
|
|||||||
<SecretPathInput
|
<SecretPathInput
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Provide a path, default is /"
|
placeholder="Provide a path, default is /"
|
||||||
environment={selectedEnvSlug}
|
environment={selectedEnvSlug?.slug}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
@@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
|
|||||||
<div className="border-t border-mineshaft-600 pt-4">
|
<div className="border-t border-mineshaft-600 pt-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>Secrets</div>
|
<div>Secrets</div>
|
||||||
<div className="flex w-1/2 items-center space-x-2">
|
</div>
|
||||||
<Input
|
<div className="flex w-full items-start gap-3">
|
||||||
placeholder="Search for secret"
|
<Controller
|
||||||
value={searchFilter}
|
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"
|
size="xs"
|
||||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
onClick={handleSecSelectAll}
|
||||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
>
|
||||||
/>
|
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||||
<Tooltip content="Select All">
|
</IconButton>
|
||||||
<IconButton
|
</Tooltip>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{!isSecretsLoading && !secrets?.length && (
|
<div className="my-6 ml-2">
|
||||||
<EmptyState title="No secrets found" icon={faKey} />
|
<Switch
|
||||||
)}
|
|
||||||
<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
|
|
||||||
id="populate-include-value"
|
id="populate-include-value"
|
||||||
isChecked={shouldIncludeValues}
|
isChecked={shouldIncludeValues}
|
||||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||||
>
|
>
|
||||||
Include secret values
|
Include secret values
|
||||||
</Checkbox>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -285,7 +246,7 @@ export const CopySecretsFromBoard = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
isDisabled={!isDirty}
|
isDisabled={!isDirty}
|
||||||
>
|
>
|
||||||
Paste Secrets
|
Copy Secrets
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
+1
-2
@@ -19,7 +19,6 @@ import { SecretTagsTable } from "./SecretTagsTable";
|
|||||||
type DeleteModalData = { name: string; id: string };
|
type DeleteModalData = { name: string; id: string };
|
||||||
|
|
||||||
export const SecretTagsSection = (): JSX.Element => {
|
export const SecretTagsSection = (): JSX.Element => {
|
||||||
|
|
||||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||||
"CreateSecretTag",
|
"CreateSecretTag",
|
||||||
"deleteTagConfirmation"
|
"deleteTagConfirmation"
|
||||||
@@ -65,7 +64,7 @@ export const SecretTagsSection = (): JSX.Element => {
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Create tag
|
Create Tag
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|||||||
+126
-49
@@ -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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -15,7 +25,9 @@ import {
|
|||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetWsTags } from "@app/hooks/api";
|
import { useGetWsTags } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -31,59 +43,124 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum TagsOrderBy {
|
||||||
|
Slug = "slug"
|
||||||
|
}
|
||||||
|
|
||||||
export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
|
export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
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 (
|
return (
|
||||||
<TableContainer className="mt-4">
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Slug</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th aria-label="button" />
|
placeholder="Search tags..."
|
||||||
</Tr>
|
/>
|
||||||
</THead>
|
<TableContainer className="mt-4">
|
||||||
<TBody>
|
<Table>
|
||||||
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
|
<THead>
|
||||||
{!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 && (
|
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={3}>
|
<Th className="w-full">
|
||||||
<EmptyState title="No secret tags found" icon={faTags} />
|
<div className="flex items-center">
|
||||||
</Td>
|
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>
|
</Tr>
|
||||||
)}
|
</THead>
|
||||||
</TBody>
|
<TBody>
|
||||||
</Table>
|
{isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
|
||||||
</TableContainer>
|
{!isLoading &&
|
||||||
|
filteredTags.slice(offset, perPage * page).map(({ id, slug }) => (
|
||||||
|
<Tr key={id}>
|
||||||
|
<Td>{slug}</Td>
|
||||||
|
<Td className="flex items-center justify-end">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.Tags}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("deleteTagConfirmation", {
|
||||||
|
name: slug,
|
||||||
|
id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="update"
|
||||||
|
variant="plain"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrashCan} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredTags.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredTags.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !filteredTags?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={tags.length ? "No tags match search..." : "No tags found for project"}
|
||||||
|
icon={tags.length ? faSearch : faTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user