mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 07:26:45 +00:00
feat(secret-import): implemented ui for secret import
This commit is contained in:
Generated
+109
@@ -5,6 +5,9 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.0.8",
|
||||||
|
"@dnd-kit/modifiers": "^6.0.1",
|
||||||
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@emotion/css": "^11.10.0",
|
"@emotion/css": "^11.10.0",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
"@fontsource/inter": "^4.5.15",
|
"@fontsource/inter": "^4.5.15",
|
||||||
@@ -2468,6 +2471,68 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz",
|
||||||
|
"integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.0.6",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.0.7",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emotion/babel-plugin": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.11.0",
|
"version": "11.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
|
||||||
@@ -24491,6 +24556,50 @@
|
|||||||
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@dnd-kit/accessibility": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@dnd-kit/core": {
|
||||||
|
"version": "6.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz",
|
||||||
|
"integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==",
|
||||||
|
"requires": {
|
||||||
|
"@dnd-kit/accessibility": "^3.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@dnd-kit/modifiers": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==",
|
||||||
|
"requires": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.1",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@dnd-kit/sortable": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
|
||||||
|
"requires": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@emotion/babel-plugin": {
|
"@emotion/babel-plugin": {
|
||||||
"version": "11.11.0",
|
"version": "11.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.0.8",
|
||||||
|
"@dnd-kit/modifiers": "^6.0.1",
|
||||||
|
"@dnd-kit/sortable": "^7.0.2",
|
||||||
"@emotion/css": "^11.10.0",
|
"@emotion/css": "^11.10.0",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
"@fontsource/inter": "^4.5.15",
|
"@fontsource/inter": "^4.5.15",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from "./integrations";
|
|||||||
export * from "./keys";
|
export * from "./keys";
|
||||||
export * from "./organization";
|
export * from "./organization";
|
||||||
export * from "./secretFolders";
|
export * from "./secretFolders";
|
||||||
|
export * from "./secretImports";
|
||||||
export * from "./secrets";
|
export * from "./secrets";
|
||||||
export * from "./secretSnapshots";
|
export * from "./secretSnapshots";
|
||||||
export * from "./serviceAccounts";
|
export * from "./serviceAccounts";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation";
|
||||||
|
export { useGetImportedSecrets, useGetSecretImports } from "./queries";
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { secretImportKeys } from "./queries";
|
||||||
|
import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types";
|
||||||
|
|
||||||
|
export const useCreateSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TCreateSecretImportDTO>({
|
||||||
|
mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/secret-imports", {
|
||||||
|
secretImport,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TUpdateSecretImportDTO>({
|
||||||
|
mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => {
|
||||||
|
const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, {
|
||||||
|
secretImports,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TDeleteSecretImportDTO>({
|
||||||
|
mutationFn: async ({ id, secretImportEnv, secretImportPath }) => {
|
||||||
|
const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, {
|
||||||
|
data: {
|
||||||
|
secretImportPath,
|
||||||
|
secretImportEnv
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptAssymmetric,
|
||||||
|
decryptSymmetric
|
||||||
|
} from "@app/components/utilities/cryptography/crypto";
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { TGetImportedSecrets, TImportedSecrets, TSecretImports } from "./types";
|
||||||
|
|
||||||
|
export const secretImportKeys = {
|
||||||
|
getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||||
|
{ workspaceId, env, folderId },
|
||||||
|
"secrets-imports"
|
||||||
|
],
|
||||||
|
getSecretImportSecrets: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||||
|
{ workspaceId, env, folderId },
|
||||||
|
"secrets-import-sec"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSecretImport = async (workspaceId: string, environment: string, folderId?: string) => {
|
||||||
|
const { data } = await apiRequest.get<{ secretImport: TSecretImports }>(
|
||||||
|
"/api/v1/secret-imports",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
folderId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.secretImport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetSecretImports = (workspaceId: string, env: string, folderId?: string) =>
|
||||||
|
useQuery({
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(env),
|
||||||
|
queryKey: secretImportKeys.getProjectSecretImports(workspaceId, env, folderId),
|
||||||
|
queryFn: () => fetchSecretImport(workspaceId, env, folderId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchImportedSecrets = async (
|
||||||
|
workspaceId: string,
|
||||||
|
environment: string,
|
||||||
|
folderId?: string
|
||||||
|
) => {
|
||||||
|
const { data } = await apiRequest.get<{ secrets: TImportedSecrets }>(
|
||||||
|
"/api/v1/secret-imports/secrets",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
folderId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.secrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetImportedSecrets = ({
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
folderId,
|
||||||
|
decryptFileKey
|
||||||
|
}: TGetImportedSecrets) =>
|
||||||
|
useQuery({
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(environment) && Boolean(decryptFileKey),
|
||||||
|
queryKey: secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId),
|
||||||
|
queryFn: () => fetchImportedSecrets(workspaceId, environment, folderId),
|
||||||
|
select: useCallback(
|
||||||
|
(data: TImportedSecrets) => {
|
||||||
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
|
const latestKey = decryptFileKey;
|
||||||
|
const key = decryptAssymmetric({
|
||||||
|
ciphertext: latestKey.encryptedKey,
|
||||||
|
nonce: latestKey.nonce,
|
||||||
|
publicKey: latestKey.sender.publicKey,
|
||||||
|
privateKey: PRIVATE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.map((el) => ({
|
||||||
|
environment: el.environment,
|
||||||
|
secretPath: el.secretPath,
|
||||||
|
folderId: el.folderId,
|
||||||
|
secrets: el.secrets.map((encSecret) => {
|
||||||
|
const secretKey = decryptSymmetric({
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
iv: encSecret.secretKeyIV,
|
||||||
|
tag: encSecret.secretKeyTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretValue = decryptSymmetric({
|
||||||
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
|
iv: encSecret.secretValueIV,
|
||||||
|
tag: encSecret.secretValueTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretComment = decryptSymmetric({
|
||||||
|
ciphertext: encSecret.secretCommentCiphertext,
|
||||||
|
iv: encSecret.secretCommentIV,
|
||||||
|
tag: encSecret.secretCommentTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: encSecret._id,
|
||||||
|
env: encSecret.environment,
|
||||||
|
key: secretKey,
|
||||||
|
value: secretValue,
|
||||||
|
tags: encSecret.tags,
|
||||||
|
comment: secretComment,
|
||||||
|
createdAt: encSecret.createdAt,
|
||||||
|
updatedAt: encSecret.updatedAt
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[decryptFileKey]
|
||||||
|
)
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { EncryptedSecret } from "../secrets/types";
|
||||||
|
import { UserWsKeyPair } from "../types";
|
||||||
|
|
||||||
|
export type TSecretImports = {
|
||||||
|
_id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId: string;
|
||||||
|
imports: Array<{ environment: string; secretPath: string }>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TImportedSecrets = {
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
folderId: string;
|
||||||
|
secrets: EncryptedSecret[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type TGetImportedSecrets = {
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId?: string;
|
||||||
|
decryptFileKey: UserWsKeyPair;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateSecretImportDTO = {
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId?: string;
|
||||||
|
secretImport: {
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateSecretImportDTO = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId?: string;
|
||||||
|
secretImports: Array<{
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteSecretImportDTO = {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
folderId?: string;
|
||||||
|
secretImportPath: string;
|
||||||
|
secretImportEnv: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { secretKeys } from "./queries";
|
||||||
|
import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types";
|
||||||
|
|
||||||
|
export const useCreateSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TCreateSecretImportDTO>({
|
||||||
|
mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/secret-imports", {
|
||||||
|
secretImport,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TUpdateSecretImportDTO>({
|
||||||
|
mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => {
|
||||||
|
const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, {
|
||||||
|
secretImports,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteSecretImport = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TDeleteSecretImportDTO>({
|
||||||
|
mutationFn: async ({ id, secretImportEnv, secretImportPath }) => {
|
||||||
|
const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, {
|
||||||
|
data: {
|
||||||
|
secretImportPath,
|
||||||
|
secretImportEnv
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
secretKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -24,6 +24,10 @@ export const secretKeys = {
|
|||||||
{ workspaceId, env, folderId },
|
{ workspaceId, env, folderId },
|
||||||
"secrets"
|
"secrets"
|
||||||
],
|
],
|
||||||
|
getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||||
|
{ workspaceId, env, folderId },
|
||||||
|
"secrets-imports"
|
||||||
|
],
|
||||||
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"]
|
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type DecryptedSecret = {
|
|||||||
valueOverride?: string;
|
valueOverride?: string;
|
||||||
idOverride?: string;
|
idOverride?: string;
|
||||||
overrideAction?: string;
|
overrideAction?: string;
|
||||||
|
folderId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EncryptedSecretVersion = {
|
export type EncryptedSecretVersion = {
|
||||||
@@ -98,6 +99,7 @@ export type GetProjectSecretsDTO = {
|
|||||||
folderId?: string;
|
folderId?: string;
|
||||||
secretPath?: string;
|
secretPath?: string;
|
||||||
isPaused?: boolean;
|
isPaused?: boolean;
|
||||||
|
include_imports?: boolean;
|
||||||
onSuccess?: (data: DecryptedSecret[]) => void;
|
onSuccess?: (data: DecryptedSecret[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||||
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
import {
|
import {
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faCheck,
|
faCheck,
|
||||||
@@ -10,6 +22,7 @@ import {
|
|||||||
faDownload,
|
faDownload,
|
||||||
faEye,
|
faEye,
|
||||||
faEyeSlash,
|
faEyeSlash,
|
||||||
|
faFileImport,
|
||||||
faFolderPlus,
|
faFolderPlus,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faPlus
|
faPlus
|
||||||
@@ -41,10 +54,14 @@ import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks";
|
|||||||
import {
|
import {
|
||||||
useBatchSecretsOp,
|
useBatchSecretsOp,
|
||||||
useCreateFolder,
|
useCreateFolder,
|
||||||
|
useCreateSecretImport,
|
||||||
useCreateWsTag,
|
useCreateWsTag,
|
||||||
useDeleteFolder,
|
useDeleteFolder,
|
||||||
|
useDeleteSecretImport,
|
||||||
|
useGetImportedSecrets,
|
||||||
useGetProjectFolders,
|
useGetProjectFolders,
|
||||||
useGetProjectSecrets,
|
useGetProjectSecrets,
|
||||||
|
useGetSecretImports,
|
||||||
useGetSecretVersion,
|
useGetSecretVersion,
|
||||||
useGetSnapshotSecrets,
|
useGetSnapshotSecrets,
|
||||||
useGetUserAction,
|
useGetUserAction,
|
||||||
@@ -55,7 +72,8 @@ import {
|
|||||||
useGetWsTags,
|
useGetWsTags,
|
||||||
usePerformSecretRollback,
|
usePerformSecretRollback,
|
||||||
useRegisterUserAction,
|
useRegisterUserAction,
|
||||||
useUpdateFolder
|
useUpdateFolder,
|
||||||
|
useUpdateSecretImport
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||||
@@ -71,6 +89,8 @@ import {
|
|||||||
import { PitDrawer } from "./components/PitDrawer";
|
import { PitDrawer } from "./components/PitDrawer";
|
||||||
import { SecretDetailDrawer } from "./components/SecretDetailDrawer";
|
import { SecretDetailDrawer } from "./components/SecretDetailDrawer";
|
||||||
import { SecretDropzone } from "./components/SecretDropzone";
|
import { SecretDropzone } from "./components/SecretDropzone";
|
||||||
|
import { SecretImportForm } from "./components/SecretImportForm";
|
||||||
|
import { SecretImportSection } from "./components/SecretImportSection";
|
||||||
import { SecretInputRow } from "./components/SecretInputRow";
|
import { SecretInputRow } from "./components/SecretInputRow";
|
||||||
import { SecretTableHeader } from "./components/SecretTableHeader";
|
import { SecretTableHeader } from "./components/SecretTableHeader";
|
||||||
import {
|
import {
|
||||||
@@ -84,7 +104,7 @@ import {
|
|||||||
} from "./DashboardPage.utils";
|
} from "./DashboardPage.utils";
|
||||||
|
|
||||||
const USER_ACTION_PUSH = "first_time_secrets_pushed";
|
const USER_ACTION_PUSH = "first_time_secrets_pushed";
|
||||||
|
type TDeleteSecretImport = { environment: string; secretPath: string };
|
||||||
/*
|
/*
|
||||||
* Some imp aspects to consider. Here there are multiple stats changing
|
* Some imp aspects to consider. Here there are multiple stats changing
|
||||||
* Thus ideally we need to use a context. But instead we rely on react hook form
|
* Thus ideally we need to use a context. But instead we rely on react hook form
|
||||||
@@ -113,7 +133,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
"compareSecrets",
|
"compareSecrets",
|
||||||
"folderForm",
|
"folderForm",
|
||||||
"deleteFolder",
|
"deleteFolder",
|
||||||
"upgradePlan"
|
"upgradePlan",
|
||||||
|
"addSecretImport",
|
||||||
|
"deleteSecretImport"
|
||||||
] as const);
|
] as const);
|
||||||
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
|
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
|
||||||
const [searchFilter, setSearchFilter] = useState("");
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
@@ -129,6 +151,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
const { currentWorkspace, isLoading } = useWorkspace();
|
const { currentWorkspace, isLoading } = useWorkspace();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const workspaceId = currentWorkspace?._id as string;
|
const workspaceId = currentWorkspace?._id as string;
|
||||||
|
const selectedEnvSlug = selectedEnv?.slug || "";
|
||||||
|
|
||||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,7 +185,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
|
|
||||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
env: selectedEnv?.slug || "",
|
env: selectedEnvSlug,
|
||||||
decryptFileKey: latestFileKey!,
|
decryptFileKey: latestFileKey!,
|
||||||
isPaused: Boolean(snapshotId),
|
isPaused: Boolean(snapshotId),
|
||||||
folderId
|
folderId
|
||||||
@@ -169,7 +193,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
|
|
||||||
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
|
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
|
||||||
workspaceId: workspaceId || "",
|
workspaceId: workspaceId || "",
|
||||||
environment: selectedEnv?.slug || "",
|
environment: selectedEnvSlug,
|
||||||
parentFolderId: folderId,
|
parentFolderId: folderId,
|
||||||
isPaused: isRollbackMode,
|
isPaused: isRollbackMode,
|
||||||
sortDir
|
sortDir
|
||||||
@@ -182,7 +206,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
isFetchingNextPage
|
isFetchingNextPage
|
||||||
} = useGetWorkspaceSecretSnapshots({
|
} = useGetWorkspaceSecretSnapshots({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
environment: selectedEnv?.slug || "",
|
environment: selectedEnvSlug,
|
||||||
folder: folderId,
|
folder: folderId,
|
||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
@@ -193,17 +217,18 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
isFetching: isSnapshotChanging
|
isFetching: isSnapshotChanging
|
||||||
} = useGetSnapshotSecrets({
|
} = useGetSnapshotSecrets({
|
||||||
snapshotId: snapshotId || "",
|
snapshotId: snapshotId || "",
|
||||||
env: selectedEnv?.slug || "",
|
env: selectedEnvSlug,
|
||||||
decryptFileKey: latestFileKey!
|
decryptFileKey: latestFileKey!
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
|
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
selectedEnv?.slug || "",
|
selectedEnvSlug,
|
||||||
folderId
|
folderId
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: wsTags } = useGetWsTags(workspaceId);
|
const { data: wsTags } = useGetWsTags(workspaceId);
|
||||||
|
|
||||||
// mutation calls
|
// mutation calls
|
||||||
const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
|
const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
|
||||||
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
|
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
|
||||||
@@ -213,6 +238,50 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
|
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
|
||||||
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
|
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
|
||||||
|
|
||||||
|
const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports(
|
||||||
|
workspaceId,
|
||||||
|
selectedEnvSlug,
|
||||||
|
folderId
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: importedSecrets } = useGetImportedSecrets({
|
||||||
|
workspaceId,
|
||||||
|
decryptFileKey: latestFileKey!,
|
||||||
|
environment: selectedEnvSlug,
|
||||||
|
folderId
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is for dnd-kit. As react-query state mutation async
|
||||||
|
// This will act as a placeholder to avoid a glitching animation on dropping items
|
||||||
|
const [items, setItems] = useState<
|
||||||
|
Array<{ environment: string; secretPath: string; id: string }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isSecretImportCfgFetching ||
|
||||||
|
// case in which u go to a folder and come back to fill in with cache data
|
||||||
|
(items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching)
|
||||||
|
) {
|
||||||
|
setItems(
|
||||||
|
secretImportCfg?.imports?.map((el) => ({
|
||||||
|
...el,
|
||||||
|
id: `${el.environment}-${el.secretPath}`
|
||||||
|
})) || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [isSecretImportCfgFetching]);
|
||||||
|
|
||||||
|
const { mutateAsync: createSecretImport } = useCreateSecretImport();
|
||||||
|
const { mutate: updateSecretImportSync } = useUpdateSecretImport();
|
||||||
|
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, {}),
|
||||||
|
useSensor(TouchSensor, {}),
|
||||||
|
useSensor(KeyboardSensor, {})
|
||||||
|
);
|
||||||
|
|
||||||
const method = useForm<FormData>({
|
const method = useForm<FormData>({
|
||||||
// why any: well yup inferred ts expects other keys to defined as undefined
|
// why any: well yup inferred ts expects other keys to defined as undefined
|
||||||
defaultValues: secrets as any,
|
defaultValues: secrets as any,
|
||||||
@@ -319,14 +388,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
await performSecretRollback({
|
await performSecretRollback({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
version: snapshotSecret.version,
|
version: snapshotSecret.version,
|
||||||
environment: selectedEnv?.slug || "",
|
environment: selectedEnvSlug,
|
||||||
folderId
|
folderId
|
||||||
});
|
});
|
||||||
setValue("isSnapshotMode", false);
|
setValue("isSnapshotMode", false);
|
||||||
setSnaphotId(null);
|
setSnaphotId(null);
|
||||||
queryClient.invalidateQueries(
|
queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug));
|
||||||
secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || "")
|
|
||||||
);
|
|
||||||
createNotification({
|
createNotification({
|
||||||
text: "Successfully rollback secrets",
|
text: "Successfully rollback secrets",
|
||||||
type: "success"
|
type: "success"
|
||||||
@@ -522,6 +589,79 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSecretImportCreate = async (env: string, secretPath: string) => {
|
||||||
|
try {
|
||||||
|
await createSecretImport({
|
||||||
|
workspaceId,
|
||||||
|
environment: selectedEnv?.slug || "",
|
||||||
|
folderId,
|
||||||
|
secretImport: {
|
||||||
|
environment: env,
|
||||||
|
secretPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully create secret link"
|
||||||
|
});
|
||||||
|
handlePopUpClose("addSecretImport");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create secret link",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecretImportDelete = async () => {
|
||||||
|
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
|
||||||
|
?.data as TDeleteSecretImport;
|
||||||
|
try {
|
||||||
|
if (secretImportCfg?._id) {
|
||||||
|
await deleteSecretImport({
|
||||||
|
workspaceId,
|
||||||
|
environment: selectedEnvSlug,
|
||||||
|
folderId,
|
||||||
|
id: secretImportCfg?._id,
|
||||||
|
secretImportEnv: importEnv,
|
||||||
|
secretImportPath: impSecPath
|
||||||
|
});
|
||||||
|
handlePopUpClose("deleteSecretImport");
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully removed secret link"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to remove secret link",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (evt: DragEndEvent) => {
|
||||||
|
const { active, over } = evt;
|
||||||
|
if (over?.id && active.id !== over.id) {
|
||||||
|
const oldIndex = items.findIndex(({ id }) => id === active.id);
|
||||||
|
const newIndex = items.findIndex(({ id }) => id === over.id);
|
||||||
|
const newImportOrder = arrayMove(items, oldIndex, newIndex);
|
||||||
|
setItems(newImportOrder);
|
||||||
|
updateSecretImportSync({
|
||||||
|
workspaceId,
|
||||||
|
environment: selectedEnvSlug,
|
||||||
|
folderId,
|
||||||
|
id: secretImportCfg?._id || "",
|
||||||
|
secretImports: newImportOrder.map((el) => ({
|
||||||
|
environment: el.environment,
|
||||||
|
secretPath: el.secretPath
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// when secrets is not loading and secrets list is empty
|
// when secrets is not loading and secrets list is empty
|
||||||
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
|
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
|
||||||
|
|
||||||
@@ -664,7 +804,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="block lg:hidden">
|
<div className="block lg:hidden">
|
||||||
<Tooltip content="Point-in-time Recovery">
|
<Tooltip content="Point-in-time Recovery">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -684,6 +823,17 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Button
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
||||||
|
onClick={() => handlePopUpOpen("addSecretImport")}
|
||||||
|
isDisabled={isReadOnly || isRollbackMode}
|
||||||
|
variant="outline_bg"
|
||||||
|
className="h-10"
|
||||||
|
>
|
||||||
|
Secret Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||||
@@ -695,7 +845,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
Add Folder
|
Add Folder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
@@ -751,49 +900,67 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
ref={secretContainer}
|
ref={secretContainer}
|
||||||
>
|
>
|
||||||
{!isEmptyPage && (
|
{!isEmptyPage && (
|
||||||
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
|
<DndContext
|
||||||
<table className="secret-table relative">
|
onDragEnd={handleDragEnd}
|
||||||
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
sensors={sensors}
|
||||||
<tbody className="max-h-96 overflow-y-auto">
|
collisionDetection={closestCenter}
|
||||||
<FolderSection
|
modifiers={[restrictToVerticalAxis]}
|
||||||
onFolderOpen={handleFolderOpen}
|
>
|
||||||
onFolderUpdate={(id, name) => handlePopUpOpen("folderForm", { id, name })}
|
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
|
||||||
onFolderDelete={(id, name) => handlePopUpOpen("deleteFolder", { id, name })}
|
<table className="secret-table relative">
|
||||||
folders={folderList}
|
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
||||||
search={searchFilter}
|
<tbody className="max-h-96 overflow-y-auto">
|
||||||
/>
|
<SecretImportSection
|
||||||
{fields.map(({ id, _id }, index) => (
|
onSecretImportDelete={(impSecEnv, impSecPath) =>
|
||||||
<SecretInputRow
|
handlePopUpOpen("deleteSecretImport", {
|
||||||
key={id}
|
environment: impSecEnv,
|
||||||
isReadOnly={isReadOnly}
|
secretPath: impSecPath
|
||||||
isRollbackMode={isRollbackMode}
|
})
|
||||||
isAddOnly={isAddOnly}
|
}
|
||||||
index={index}
|
secrets={secrets?.secrets}
|
||||||
searchTerm={searchFilter}
|
importedSecrets={importedSecrets}
|
||||||
onSecretDelete={onSecretDelete}
|
items={items}
|
||||||
onRowExpand={() => onDrawerOpen({ id: _id as string, index })}
|
|
||||||
isSecretValueHidden={isSecretValueHidden}
|
|
||||||
wsTags={wsTags}
|
|
||||||
onCreateTagOpen={() => handlePopUpOpen("addTag")}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<FolderSection
|
||||||
{!isReadOnly && !isRollbackMode && (
|
onFolderOpen={handleFolderOpen}
|
||||||
<tr>
|
onFolderUpdate={(id, name) => handlePopUpOpen("folderForm", { id, name })}
|
||||||
<td colSpan={3} className="hover:bg-mineshaft-700">
|
onFolderDelete={(id, name) => handlePopUpOpen("deleteFolder", { id, name })}
|
||||||
<button
|
folders={folderList}
|
||||||
type="button"
|
search={searchFilter}
|
||||||
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
|
/>
|
||||||
onClick={onAppendSecret}
|
{fields.map(({ id, _id }, index) => (
|
||||||
>
|
<SecretInputRow
|
||||||
<FontAwesomeIcon icon={faPlus} />
|
key={id}
|
||||||
<span className="ml-2 w-20">Add Secret</span>
|
isReadOnly={isReadOnly}
|
||||||
</button>
|
isRollbackMode={isRollbackMode}
|
||||||
</td>
|
isAddOnly={isAddOnly}
|
||||||
</tr>
|
index={index}
|
||||||
)}
|
searchTerm={searchFilter}
|
||||||
</tbody>
|
onSecretDelete={onSecretDelete}
|
||||||
</table>
|
onRowExpand={() => onDrawerOpen({ id: _id as string, index })}
|
||||||
</TableContainer>
|
isSecretValueHidden={isSecretValueHidden}
|
||||||
|
wsTags={wsTags}
|
||||||
|
onCreateTagOpen={() => handlePopUpOpen("addTag")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!isReadOnly && !isRollbackMode && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="hover:bg-mineshaft-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
|
||||||
|
onClick={onAppendSecret}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} />
|
||||||
|
<span className="ml-2 w-20">Add Secret</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</TableContainer>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
<PitDrawer
|
<PitDrawer
|
||||||
isDrawerOpen={popUp?.secretSnapshots?.isOpen}
|
isDrawerOpen={popUp?.secretSnapshots?.isOpen}
|
||||||
@@ -881,6 +1048,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
/>
|
/>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.addSecretImport?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Add Secret Link"
|
||||||
|
subTitle="To inherit secrets from another environment or folder"
|
||||||
|
>
|
||||||
|
<SecretImportForm
|
||||||
|
environments={currentWorkspace?.environments}
|
||||||
|
onCreate={handleSecretImportCreate}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteFolder.isOpen}
|
isOpen={popUp.deleteFolder.isOpen}
|
||||||
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
|
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
|
||||||
@@ -888,6 +1069,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||||
onDeleteApproved={handleFolderDelete}
|
onDeleteApproved={handleFolderDelete}
|
||||||
/>
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteSecretImport.isOpen}
|
||||||
|
deleteKey="unlink"
|
||||||
|
title="Do you want to remove this secret import?"
|
||||||
|
subTitle={`This will unlink secrets from environment ${
|
||||||
|
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
|
||||||
|
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
|
||||||
|
onDeleteApproved={handleSecretImportDelete}
|
||||||
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.compareSecrets?.isOpen}
|
isOpen={popUp?.compareSecrets?.isOpen}
|
||||||
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
|
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const FolderSection = ({
|
|||||||
ariaLabel="delete"
|
ariaLabel="delete"
|
||||||
onClick={() => handleFolderDelete(id, name)}
|
onClick={() => handleFolderDelete(id, name)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onCreate: (environment: string, secretPath: string) => Promise<void>;
|
||||||
|
environments?: Array<{ slug: string; name: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = yup.object({
|
||||||
|
environment: yup.string().required().label("Environment").trim(),
|
||||||
|
secretPath: yup
|
||||||
|
.string()
|
||||||
|
.required()
|
||||||
|
.label("Secret Path")
|
||||||
|
.trim()
|
||||||
|
.transform((val) =>
|
||||||
|
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormData = yup.InferType<typeof formSchema>;
|
||||||
|
|
||||||
|
export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TFormData>({
|
||||||
|
resolver: yupResolver(formSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async ({ environment, secretPath }: TFormData) => {
|
||||||
|
await onCreate(environment, secretPath);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
defaultValue={environments?.[0]?.slug}
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{environments.map(({ name, slug }) => (
|
||||||
|
<SelectItem value={slug} key={slug}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secretPath"
|
||||||
|
defaultValue="/"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="plain" colorSchema="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SecretImportForm } from "./SecretImportForm";
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { faFileImport, faFolder, faUpDown, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { IconButton, TableContainer, Tooltip } from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks/useToggle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDelete: (environment: string, secretPath: string) => void;
|
||||||
|
importedEnv: string;
|
||||||
|
importedSecPath: string;
|
||||||
|
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// to show the environment and folder icon
|
||||||
|
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||||
|
<div className="inline-flex items-center space-x-2">
|
||||||
|
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||||
|
{secretPath && (
|
||||||
|
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||||
|
<FontAwesomeIcon icon={faFolder} size="lg" className="text-primary-700" />
|
||||||
|
<span>{secretPath}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SecretImportItem = ({
|
||||||
|
importedEnv,
|
||||||
|
importedSecPath,
|
||||||
|
onDelete,
|
||||||
|
importedSecrets = []
|
||||||
|
}: Props) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useToggle();
|
||||||
|
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||||
|
id: `${importedEnv}-${importedSecPath}`
|
||||||
|
});
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
setIsExpanded.off();
|
||||||
|
}
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||||
|
transition
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||||
|
onClick={() => setIsExpanded.toggle()}
|
||||||
|
>
|
||||||
|
<td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className="text-primary-700" />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
colSpan={2}
|
||||||
|
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||||
|
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||||
|
>
|
||||||
|
<div className="flex-grow p-2">
|
||||||
|
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||||
|
</div>
|
||||||
|
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip content="Change Order" className="capitalize">
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="expand"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUpDown} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip content="Delete" className="capitalize">
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="delete"
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
onDelete(importedEnv, importedSecPath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{isExpanded && !isDragging && (
|
||||||
|
<td colSpan={3}>
|
||||||
|
<div className="rounded-md bg-bunker-700 p-4 pb-6">
|
||||||
|
<div className="mb-2 text-lg font-medium">Secrets Imported</div>
|
||||||
|
<TableContainer>
|
||||||
|
<table className="secret-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||||
|
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||||
|
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{importedSecrets.map(({ key, value, overriden }, index) => (
|
||||||
|
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||||
|
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||||
|
{key}
|
||||||
|
</td>
|
||||||
|
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||||
|
{value}
|
||||||
|
</td>
|
||||||
|
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||||
|
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||||
|
|
||||||
|
import { SecretImportItem } from "./SecretImportItem";
|
||||||
|
|
||||||
|
type TImportedSecrets = Array<{
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
folderId: string;
|
||||||
|
secrets: DecryptedSecret[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||||
|
|
||||||
|
export const computeImportedSecretRows = (
|
||||||
|
importedSecEnv: string,
|
||||||
|
importedSecPath: string,
|
||||||
|
importSecrets: TImportedSecrets = [],
|
||||||
|
secrets: DecryptedSecret[] = [],
|
||||||
|
environments: { name: string; slug: string }[] = []
|
||||||
|
) => {
|
||||||
|
const importedSecIndex = importSecrets.findIndex(
|
||||||
|
({ secretPath, environment }) =>
|
||||||
|
secretPath === importedSecPath && importedSecEnv === environment
|
||||||
|
);
|
||||||
|
if (importedSecIndex === -1) return [];
|
||||||
|
|
||||||
|
const importedSec = importSecrets[importedSecIndex];
|
||||||
|
|
||||||
|
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||||
|
const envSlug2Name: Record<string, string> = {};
|
||||||
|
environments.forEach((el) => {
|
||||||
|
envSlug2Name[el.slug] = el.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||||
|
importSecrets[i].secrets.forEach((el) => {
|
||||||
|
overridenSec[el.key] = {
|
||||||
|
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||||
|
secretPath: importSecrets[i].secretPath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets.forEach((el) => {
|
||||||
|
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
return importedSec.secrets.map(({ key, value }) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
overriden: overridenSec?.[key]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secrets?: DecryptedSecret[];
|
||||||
|
importedSecrets?: TImportedSecrets;
|
||||||
|
onSecretImportDelete: (env: string, secPath: string) => void;
|
||||||
|
items: { id: string; environment: string; secretPath: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretImportSection = ({
|
||||||
|
secrets = [],
|
||||||
|
importedSecrets = [],
|
||||||
|
onSecretImportDelete,
|
||||||
|
items = []
|
||||||
|
}: Props) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const environments = currentWorkspace?.environments || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => (
|
||||||
|
<SecretImportItem
|
||||||
|
key={id}
|
||||||
|
importedEnv={importSecEnv}
|
||||||
|
importedSecrets={computeImportedSecretRows(
|
||||||
|
importSecEnv,
|
||||||
|
impSecPath,
|
||||||
|
importedSecrets,
|
||||||
|
secrets,
|
||||||
|
environments
|
||||||
|
)}
|
||||||
|
onDelete={onSecretImportDelete}
|
||||||
|
importedSecPath={impSecPath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SecretImportSection } from "./SecretImportSection";
|
||||||
@@ -407,7 +407,7 @@ export const SecretInputRow = memo(
|
|||||||
<div className="opacity-0 group-hover:opacity-100">
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
<Tooltip content="Delete">
|
<Tooltip content="Delete">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="md"
|
size="lg"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
ariaLabel="delete"
|
ariaLabel="delete"
|
||||||
|
|||||||
Reference in New Issue
Block a user