Fix merge conflicts

This commit is contained in:
Tuan Dang
2025-04-10 12:39:09 -07:00
443 changed files with 17531 additions and 2266 deletions
+341 -3
View File
@@ -23,6 +23,7 @@
"@hcaptcha/react-hcaptcha": "^1.11.0",
"@headlessui/react": "^1.7.19",
"@hookform/resolvers": "^3.9.1",
"@lexical/react": "^0.29.0",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@octokit/rest": "^21.0.2",
"@peculiar/x509": "^1.12.3",
@@ -66,6 +67,7 @@
"jspdf": "^2.5.2",
"jsrp": "^0.2.4",
"jwt-decode": "^4.0.0",
"lexical": "^0.29.0",
"ms": "^2.1.3",
"nprogress": "^0.2.0",
"picomatch": "^4.0.2",
@@ -1570,6 +1572,260 @@
}
}
},
"node_modules/@lexical/clipboard": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.29.0.tgz",
"integrity": "sha512-llxZosYCwH13p2GfPfhAinukdvAZYxWuwf5md107X80hsE8TQJj25unjqTwRKQ+w/wD+hpmBMziU8+K/WTitWQ==",
"license": "MIT",
"dependencies": {
"@lexical/html": "0.29.0",
"@lexical/list": "0.29.0",
"@lexical/selection": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/code": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.29.0.tgz",
"integrity": "sha512-yKGzoKpyIO39Xf7OKLPpoCE5V8mTDCM3l3CDHZR3X1gM/VZQzf4jAiO3b06y9YkQ2fM8kqwchYu87wGvs8/iIQ==",
"license": "MIT",
"dependencies": {
"@lexical/utils": "0.29.0",
"lexical": "0.29.0",
"prismjs": "^1.30.0"
}
},
"node_modules/@lexical/devtools-core": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.29.0.tgz",
"integrity": "sha512-uUq0m9ql/7mthp7Ho1vnG7Id6imQ5kD5mxUhX2lmgHretS+yAHGsGsGiPIVHdPWeVmUb2n4IVDJ+cJbUsUjQJw==",
"license": "MIT",
"dependencies": {
"@lexical/html": "0.29.0",
"@lexical/link": "0.29.0",
"@lexical/mark": "0.29.0",
"@lexical/table": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
},
"peerDependencies": {
"react": ">=17.x",
"react-dom": ">=17.x"
}
},
"node_modules/@lexical/dragon": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.29.0.tgz",
"integrity": "sha512-Zaky2jd/Pp1blAZqPeGNdyhxnVL4lwVjbWPxhfS1gbW4Q5CBQ3aD3B0T4ljiKfmRNJm004LJ9q7KjhlRbREvZA==",
"license": "MIT",
"dependencies": {
"lexical": "0.29.0"
}
},
"node_modules/@lexical/hashtag": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.29.0.tgz",
"integrity": "sha512-fa7s0Yi2RKz/GvgT5XU9fborx6VPU3VtvvEPaIXgyd6zXZRiOhD9rGypwB3oj4fMK1ndx2dX0m7SwhMJo48D8w==",
"license": "MIT",
"dependencies": {
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/history": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.29.0.tgz",
"integrity": "sha512-OrCwZycp/yaq63mw511NutkwAB+W6WSchG1xTxlLh6nbc8jnbvKhCf4CGbnrvlhD7hTuzxJ8FI9/2M/2zv/mNQ==",
"license": "MIT",
"dependencies": {
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/html": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.29.0.tgz",
"integrity": "sha512-+jV6ijppOpxpUGeXkGssXJbsAmFALfeLrgbM0xuZbxZ7RgYZ+5Atn00WjSno7+JV5EOuRkYmCNtS1tiHtXMY1g==",
"license": "MIT",
"dependencies": {
"@lexical/selection": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/link": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.29.0.tgz",
"integrity": "sha512-wGbKRF0x/6ZQHuCfr8m8qD1J0R1kFmWINBG2A1hUXPDf7UY5qm/nS2oKNDGpjiDMGwkVZ7n7WfzeBGO+KRe/Lg==",
"license": "MIT",
"dependencies": {
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/list": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.29.0.tgz",
"integrity": "sha512-sWiof+i2ff8rL7KxJ3dxHLwyJfX423e1EVLmAdQEOPhyZJiNbeLTSNhNGsZ8FjFoBwvTTEDwuQZm3iT3hliKOg==",
"license": "MIT",
"dependencies": {
"@lexical/selection": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/mark": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.29.0.tgz",
"integrity": "sha512-UB3x6pyUdpZHRqF4tiajLnC1+Umvt7x8Rkkdi29aNNvzIWniVwGkBOlmvFus7x+4dOV1D1fydwiP4m38nGgLDw==",
"license": "MIT",
"dependencies": {
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/markdown": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.29.0.tgz",
"integrity": "sha512-4Od8WoDoviv9DxJZVgrIORTIAzyoGOpztbGbIBXguGmwvy7NnHQDh9fZYIYRrdI1Awp1VVGdJ3ku/7KTgSOoRw==",
"license": "MIT",
"dependencies": {
"@lexical/code": "0.29.0",
"@lexical/link": "0.29.0",
"@lexical/list": "0.29.0",
"@lexical/rich-text": "0.29.0",
"@lexical/text": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/offset": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.29.0.tgz",
"integrity": "sha512-VyD2Ff3rBJpo++Fxvi3MNYmDELa+9nA0EgXqGRNb3MvRehRjHbaDbymtLMMHIwvbkF5lnra+ubStcTRQmoQxXw==",
"license": "MIT",
"dependencies": {
"lexical": "0.29.0"
}
},
"node_modules/@lexical/overflow": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.29.0.tgz",
"integrity": "sha512-IzH3M652Ej2gB2sK65N3yTgyiQAa3I3tqKbSnBRiXu/+isxHoCy/qRr9/kL63uy7zhGvgV+EYsoffQCawIFt8Q==",
"license": "MIT",
"dependencies": {
"lexical": "0.29.0"
}
},
"node_modules/@lexical/plain-text": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.29.0.tgz",
"integrity": "sha512-F5C3meDb2HmO0NmKJBVRkjmX9PNln6O1jXU/APJuSFBdvfcIWSY58ncHR4zy2M5LF1Q5PQMWyIay9p+SqOtY5A==",
"license": "MIT",
"dependencies": {
"@lexical/clipboard": "0.29.0",
"@lexical/selection": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/react": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.29.0.tgz",
"integrity": "sha512-YMlnljW/jxmwSzsRv5UPatfOoMZXqxFmRIEltTUIQfrOFdqn+ssUtCpjE6xRD1oxD6KpSIekakzLs+y/8+7CuQ==",
"license": "MIT",
"dependencies": {
"@lexical/devtools-core": "0.29.0",
"@lexical/dragon": "0.29.0",
"@lexical/hashtag": "0.29.0",
"@lexical/history": "0.29.0",
"@lexical/link": "0.29.0",
"@lexical/list": "0.29.0",
"@lexical/mark": "0.29.0",
"@lexical/markdown": "0.29.0",
"@lexical/overflow": "0.29.0",
"@lexical/plain-text": "0.29.0",
"@lexical/rich-text": "0.29.0",
"@lexical/table": "0.29.0",
"@lexical/text": "0.29.0",
"@lexical/utils": "0.29.0",
"@lexical/yjs": "0.29.0",
"lexical": "0.29.0",
"react-error-boundary": "^3.1.4"
},
"peerDependencies": {
"react": ">=17.x",
"react-dom": ">=17.x"
}
},
"node_modules/@lexical/rich-text": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.29.0.tgz",
"integrity": "sha512-fSKgXGxJUOWo7dwSTUYFVBNNk4pPN8norsZfdmKM1kGDS1/GKuVzlzHLKZ7rQb8RLD5a43p4ifEL+28P+q0Qqg==",
"license": "MIT",
"dependencies": {
"@lexical/clipboard": "0.29.0",
"@lexical/selection": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/selection": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.29.0.tgz",
"integrity": "sha512-lX9CRrXgKte65cozTHFXwUJ2fvZD92OEtos+YU+U40GJjf3NdheGeKDxDfOpF4AXrYRSszY7E0CzmIvuEs0p4A==",
"license": "MIT",
"dependencies": {
"lexical": "0.29.0"
}
},
"node_modules/@lexical/table": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.29.0.tgz",
"integrity": "sha512-Jdj32kBDeJh/0dGaZB14JggnEIS956/cN7grnLr7cmhhVzDicvLMBENSXQVEJAQVcSIU4G9EvxC7GJZ9VgqDnA==",
"license": "MIT",
"dependencies": {
"@lexical/clipboard": "0.29.0",
"@lexical/utils": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/text": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.29.0.tgz",
"integrity": "sha512-QnNGr6ickTLk76o3PdxJjPwt//dpuh8idVfR73WdCIoAwkhiEPUxxTZERoMsudXj6O/lJ+/HhI61wVjLckYr3A==",
"license": "MIT",
"dependencies": {
"lexical": "0.29.0"
}
},
"node_modules/@lexical/utils": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.29.0.tgz",
"integrity": "sha512-y2hhWQDjcXdplsAaQMuZx6ht9u1I4BV5NynA+WKoQ3h8vKxzeDnpCxVOK/zxU1R5dhM/nilnFu7uhvrSeEn+TQ==",
"license": "MIT",
"dependencies": {
"@lexical/list": "0.29.0",
"@lexical/selection": "0.29.0",
"@lexical/table": "0.29.0",
"lexical": "0.29.0"
}
},
"node_modules/@lexical/yjs": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.29.0.tgz",
"integrity": "sha512-6IXWWlGkVJEzWP/+LcuKYJ9jmcFp8k7TT/jmz4V5gBD9Ut3swOGsIA/sQCtB9y7jad10csaDVmFdFzGNWKVH9A==",
"license": "MIT",
"dependencies": {
"@lexical/offset": "0.29.0",
"@lexical/selection": "0.29.0",
"lexical": "0.29.0"
},
"peerDependencies": {
"yjs": ">=13.5.22"
}
},
"node_modules/@lottiefiles/dotlottie-react": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.12.0.tgz",
@@ -8871,6 +9127,17 @@
"node": ">=10"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz",
@@ -9100,6 +9367,34 @@
"node": ">= 0.8.0"
}
},
"node_modules/lexical": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/lexical/-/lexical-0.29.0.tgz",
"integrity": "sha512-eoBHUEn0LmExKeK6x2cFKU0FPaMk2Bc5HgiCzTiv5ymKtwWw7LeKcxaNPmLxRRdQpcWV1IMKjayAbw7Lt/Gu7w==",
"license": "MIT"
},
"node_modules/lib0": {
"version": "0.2.102",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz",
"integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -10857,6 +11152,15 @@
}
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -11142,6 +11446,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-error-boundary": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=10",
"npm": ">=6"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -13587,9 +13907,9 @@
}
},
"node_modules/vite": {
"version": "5.4.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
"integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==",
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14131,6 +14451,24 @@
"node": ">=8"
}
},
"node_modules/yjs": {
"version": "13.6.24",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz",
"integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -27,6 +27,7 @@
"@hcaptcha/react-hcaptcha": "^1.11.0",
"@headlessui/react": "^1.7.19",
"@hookform/resolvers": "^3.9.1",
"@lexical/react": "^0.29.0",
"@lottiefiles/dotlottie-react": "^0.12.0",
"@octokit/rest": "^21.0.2",
"@peculiar/x509": "^1.12.3",
@@ -70,6 +71,7 @@
"jspdf": "^2.5.2",
"jsrp": "^0.2.4",
"jwt-decode": "^4.0.0",
"lexical": "^0.29.0",
"ms": "^2.1.3",
"nprogress": "^0.2.0",
"picomatch": "^4.0.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faAnglesUp,
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpRightAndDownLeftFromCenter,
faWindowRestore
} from "@fortawesome/free-solid-svg-icons";
@@ -10,6 +12,7 @@ import {
Background,
BackgroundVariant,
ConnectionLineType,
ControlButton,
Controls,
Node,
NodeMouseHandler,
@@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge";
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components";
import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks";
import { FolderNode, RoleNode } from "./nodes";
@@ -35,13 +40,30 @@ export type AccessTreeProps = {
const EdgeTypes = { base: BasePermissionEdge };
const NodeTypes = { role: RoleNode, folder: FolderNode };
const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode };
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
const accessTreeData = useAccessTree(permissions);
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData;
const [selectedPath, setSelectedPath] = useState<string>("/");
const accessTreeData = useAccessTree(permissions, selectedPath);
const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData;
const [initialRender, setInitialRender] = useState(true);
const { fitView, getViewport, setCenter } = useReactFlow();
useEffect(() => {
setSelectedPath("/");
}, [environment]);
const { getViewport, setCenter, fitView } = useReactFlow();
const goToRootNode = useCallback(() => {
const roleNode = nodes.find((node) => node.type === "role");
if (roleNode) {
setCenter(
roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0),
roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0),
{ duration: 800, zoom: 1 }
);
}
}, [nodes, setCenter]);
const onNodeClick: NodeMouseHandler<Node> = useCallback(
(_, node) => {
@@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
);
useEffect(() => {
setTimeout(() => {
fitView({
padding: 0.2,
duration: 1000,
maxZoom: 1
});
}, 1);
}, [fitView, nodes, edges, getViewport()]);
setInitialRender(true);
}, [selectedPath, environment]);
useEffect(() => {
let timer: NodeJS.Timeout;
if (initialRender) {
timer = setTimeout(() => {
goToRootNode();
setInitialRender(false);
}, 500);
}
return () => clearTimeout(timer);
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
@@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
edgesReconnectable={false}
nodesConnectable={false}
connectionLineType={ConnectionLineType.SmoothStep}
minZoom={0.001}
proOptions={{
hideAttribution: false // we need pro license if we want to hide
}}
@@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
{viewMode !== ViewMode.Undocked && (
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
)}
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
className="ml-1 w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
@@ -170,7 +205,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<IconButton
className="rounded"
className="w-10 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
@@ -179,7 +214,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter
}
/>
@@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
{viewMode === ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
</Panel>
)}
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
<Controls
position="bottom-left"
showInteractive={false}
onFitView={() => fitView({ duration: 800 })}
>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to root folder">
<FontAwesomeIcon icon={faAnglesUp} />
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
</div>
</div>
@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
className="mr-1 rounded"
colorSchema="secondary"
onClick={handlePermissionSimulation}
rightIcon={
<FontAwesomeIcon
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
icon={faChevronDown}
/>
}
>
Permission Simulation
</Button>
@@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types";
@@ -15,8 +16,24 @@ import {
getSubjectActionRuleMap,
positionElements
} from "../utils";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => {
const INITIAL_FOLDERS_PER_LEVEL = 10;
const FOLDERS_INCREMENT = 10;
type LevelFolderMap = Record<
string,
{
folders: TSecretFolderWithPath[];
visibleCount: number;
hasMore: boolean;
}
>;
export const useAccessTree = (
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
searchPath: string
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]);
@@ -27,19 +44,124 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
currentWorkspace.id
);
const [levelFolderMap, setLevelFolderMap] = useState<LevelFolderMap>({});
const [totalFolderCount, setTotalFolderCount] = useState(0);
const showMoreFolders = (parentId: string) => {
setLevelFolderMap((prevMap) => {
const level = prevMap[parentId];
if (!level) return prevMap;
const newVisibleCount = Math.min(
level.visibleCount + FOLDERS_INCREMENT,
level.folders.length
);
return {
...prevMap,
[parentId]: {
...level,
visibleCount: newVisibleCount,
hasMore: newVisibleCount < level.folders.length
}
};
});
};
const levelsWithMoreFolders = Object.entries(levelFolderMap)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, level]) => level.hasMore)
.map(([parentId]) => parentId);
const getLevelCounts = (parentId: string) => {
const level = levelFolderMap[parentId];
if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false };
return {
visibleCount: level.visibleCount,
totalCount: level.folders.length,
hasMore: level.hasMore
};
};
useEffect(() => {
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
const { folders, name } = environmentsFolders[environment];
const { folders } = environmentsFolders[environment];
setTotalFolderCount(folders.length);
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
const filteredFolders = folders.filter((folder) => {
if (folder.path.startsWith(searchPath)) {
return true;
}
if (
searchPath.startsWith(folder.path) &&
(folder.path === "/" ||
searchPath === folder.path ||
searchPath.indexOf("/", folder.path.length) === folder.path.length)
) {
return true;
}
return false;
});
filteredFolders.forEach((folder) => {
const parentId = folder.parentId || "";
if (!groupedFolders[parentId]) {
groupedFolders[parentId] = [];
}
groupedFolders[parentId].push(folder);
});
const newLevelFolderMap: LevelFolderMap = {};
Object.entries(groupedFolders).forEach(([parentId, folderList]) => {
const key = parentId;
newLevelFolderMap[key] = {
folders: folderList,
visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length),
hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL
};
});
setLevelFolderMap(newLevelFolderMap);
}, [permissions, environmentsFolders, environment, subject, secretName, searchPath]);
useEffect(() => {
if (
!environmentsFolders ||
!permissions ||
!environmentsFolders[environment] ||
Object.keys(levelFolderMap).length === 0
)
return;
const { slug } = environmentsFolders[environment];
const roleNode = createRoleNode({
subject,
environment: name
environment: slug,
environments: environmentsFolders,
onSubjectChange: setSubject,
onEnvironmentChange: setEnvironment
});
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) =>
const visibleFolders: TSecretFolderWithPath[] = [];
Object.entries(levelFolderMap).forEach(([key, levelData]) => {
if (key !== "__rootFolderId") {
visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount));
}
});
// eslint-disable-next-line no-underscore-dangle
const rootFolder = levelFolderMap.__rootFolderId?.folders[0];
const folderNodes = visibleFolders.map((folder) =>
createFolderNode({
folder,
permissions,
@@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
})
);
const folderEdges = folderNodes.map(({ data: folder }) => {
const actions = Object.values(folder.actions);
const folderEdges: Edge[] = [];
if (rootFolder) {
const rootFolderNode = folderNodes.find(
(node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path
);
if (rootFolderNode) {
const rootActions = Object.values(rootFolderNode.data.actions);
let rootAccess: PermissionAccess;
if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) {
rootAccess = PermissionAccess.Full;
} else if (
Object.values(rootActions).some((action) => action === PermissionAccess.Partial)
) {
rootAccess = PermissionAccess.Partial;
} else {
rootAccess = PermissionAccess.None;
}
folderEdges.push(
createBaseEdge({
source: roleNode.id,
target: rootFolderNode.id,
access: rootAccess
})
);
}
}
folderNodes.forEach(({ data: folder }) => {
if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) {
return;
}
const actions = Object.values(folder.actions);
let access: PermissionAccess;
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
access = PermissionAccess.Full;
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
@@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
access = PermissionAccess.None;
}
return createBaseEdge({
source: folder.parentId ?? roleNode.id,
target: folder.id,
access
});
folderEdges.push(
createBaseEdge({
source: folder.parentId ?? roleNode.id,
target: folder.id,
access
})
);
});
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
const addMoreButtons: Node[] = [];
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
if (parentId === "__rootFolderId") return;
const key = parentId === "null" ? null : parentId;
if (key && levelData.hasMore) {
const showMoreButtonNode = createShowMoreNode({
parentId: key,
onClick: () => showMoreFolders(key),
remaining: levelData.folders.length - levelData.visibleCount,
subject
});
addMoreButtons.push(showMoreButtonNode);
folderEdges.push(
createBaseEdge({
source: key,
target: showMoreButtonNode.id,
access: PermissionAccess.Partial
})
);
}
});
const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]);
setNodes(init.nodes);
setEdges(init.edges);
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]);
}, [
levelFolderMap,
permissions,
environmentsFolders,
environment,
subject,
secretName,
setNodes,
setEdges
]);
return {
nodes,
@@ -86,6 +281,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
secretName,
setSecretName,
viewMode,
setViewMode
setViewMode,
levelFolderMap,
showMoreFolders,
levelsWithMoreFolders,
getLevelCounts,
totalFolderCount
};
};
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from "react";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
type AccessTreeSecretPathInputProps = {
placeholder: string;
environment: string;
value: string;
onChange: (path: string) => void;
};
export const AccessTreeSecretPathInput = ({
placeholder,
environment,
value,
onChange
}: AccessTreeSecretPathInputProps) => {
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
const timeout: NodeJS.Timeout = setTimeout(() => {
setIsFocused(false);
}, 200);
return () => clearTimeout(timeout);
};
useEffect(() => {
if (!isFocused) {
setIsExpanded(false);
}
}, [isFocused]);
const focusInput = () => {
const inputElement = inputRef.current?.querySelector("input");
if (inputElement) {
inputElement.focus();
}
};
const toggleSearch = () => {
setIsExpanded(!isExpanded);
if (!isExpanded) {
const timeout: NodeJS.Timeout = setTimeout(focusInput, 300);
return () => clearTimeout(timeout);
}
return () => {};
};
return (
<div ref={wrapperRef} className="relative">
<div
className={twMerge(
"flex items-center overflow-hidden rounded transition-all duration-300 ease-in-out",
isFocused ? "bg-mineshaft-800 shadow-md" : "bg-mineshaft-700",
isExpanded ? "w-64" : "h-10 w-10"
)}
>
{isExpanded ? (
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
) : (
<Tooltip position="bottom" content="Search paths">
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
</Tooltip>
)}
<div
ref={inputRef}
className={twMerge(
"flex-1 transition-opacity duration-300",
isExpanded ? "opacity-100" : "hidden"
)}
onFocus={handleFocus}
onBlur={handleBlur}
role="search"
>
<div className="custom-input-wrapper">
<SecretPathInput
placeholder={placeholder}
environment={environment}
value={value}
onChange={onChange}
/>
</div>
</div>
</div>
</div>
);
};
@@ -1,10 +1,42 @@
import { Dispatch, SetStateAction } from "react";
import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
import { createRoleNode } from "../utils";
const getSubjectIcon = (subject: ProjectPermissionSub) => {
switch (subject) {
case ProjectPermissionSub.Secrets:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
}
};
const formatLabel = (text: string) => {
return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
};
export const RoleNode = ({
data: { subject, environment }
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
}: NodeProps & {
data: ReturnType<typeof createRoleNode>["data"] & {
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
environments: TProjectEnvironmentsFolders;
};
}) => {
return (
<>
<Handle
@@ -12,11 +44,60 @@ export const RoleNode = ({
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
<span className="capitalize">{subject.replace("-", " ")} Access</span>
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
<p className="truncate capitalize">{environment}</p>
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
<div className="flex w-full min-w-[240px] flex-col gap-4">
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
<Select
value={subject}
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Subject"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
value={sub}
key={sub}
>
<div className="flex items-center gap-3">
{getSubjectIcon(sub)}
<span className="font-medium">{formatLabel(sub)}</span>
</div>
</SelectItem>
);
})}
</Select>
</div>
<div className="flex w-full flex-col gap-1.5">
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
<Select
value={environment}
onValueChange={onEnvironmentChange}
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
position="popper"
dropdownContainerClassName="max-w-none"
aria-label="Environment"
>
{Object.values(environments).map((env) => (
<SelectItem
key={env.slug}
value={env.slug}
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
>
<div className="ml-3 font-medium">{env.name}</div>
</SelectItem>
))}
</Select>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
import { Button, Tooltip } from "@app/components/v2";
import { createShowMoreNode } from "../utils/createShowMoreNode";
export const ShowMoreButtonNode = ({
data: { onClick, remaining }
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
return (
<div className="flex h-full w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-2">
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex items-center justify-center">
<Tooltip position="right" content={tooltipText}>
<Button
colorSchema="secondary"
variant="plain"
size="xs"
onClick={onClick}
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
>
Show More
</Button>
</Tooltip>
</div>
</div>
);
};
@@ -7,7 +7,8 @@ export enum PermissionAccess {
export enum PermissionNode {
Role = "role",
Folder = "folder",
Environment = "environment"
Environment = "environment",
ShowMoreButton = "showMoreButton"
}
export enum PermissionEdge {
@@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types";
export const createBaseEdge = ({
source,
target,
access
access,
hideEdge = false
}: {
source: string;
target: string;
access: PermissionAccess;
hideEdge?: boolean;
}) => {
const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
return {
@@ -17,10 +19,12 @@ export const createBaseEdge = ({
source,
target,
type: PermissionEdge.Base,
markerEnd: {
type: MarkerType.ArrowClosed,
color
},
style: { stroke: color }
markerEnd: hideEdge
? undefined
: {
type: MarkerType.ArrowClosed,
color
},
style: { stroke: hideEdge ? "transparent" : color }
};
};
@@ -1,17 +1,31 @@
import { Dispatch, SetStateAction } from "react";
import { ProjectPermissionSub } from "@app/context";
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
import { PermissionNode } from "../types";
export const createRoleNode = ({
subject,
environment
environment,
environments,
onSubjectChange,
onEnvironmentChange
}: {
subject: string;
environment: string;
environments: TProjectEnvironmentsFolders;
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
onEnvironmentChange: (value: string) => void;
}) => ({
id: `role-${subject}-${environment}`,
position: { x: 0, y: 0 },
data: {
subject,
environment
environment,
environments,
onSubjectChange,
onEnvironmentChange
},
type: PermissionNode.Role,
height: 48,
@@ -0,0 +1,45 @@
import { ProjectPermissionSub } from "@app/context";
import { PermissionNode } from "../types";
export const createShowMoreNode = ({
parentId,
onClick,
remaining,
subject
}: {
parentId: string | null;
onClick: () => void;
remaining: number;
subject: ProjectPermissionSub;
}) => {
let height: number;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
const id = `show-more-${parentId || "root"}`;
return {
id,
type: PermissionNode.ShowMoreButton,
position: { x: 0, y: 0 },
data: {
parentId,
onClick,
remaining
},
width: 150,
height,
style: {
background: "transparent",
border: "none"
}
};
};
@@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => {
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
const showMoreParentIds = new Set(
showMoreNodes.map((node) => node.data.parentId).filter(Boolean)
);
const nodeMap: Record<string, Node> = {};
const childrenMap: Record<string, string[]> = {};
edges.forEach((edge) => {
if (!childrenMap[edge.source]) {
childrenMap[edge.source] = [];
}
childrenMap[edge.source].push(edge.target);
});
const dagre = new Dagre.graphlib.Graph({ directed: true })
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: "TB" });
.setGraph({
rankdir: "TB",
nodesep: 50,
ranksep: 70
});
nodes.forEach((node) => {
dagre.setNode(node.id, {
width: node.width || 150,
height: node.height || 40
});
});
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
nodes.forEach((node) => dagre.setNode(node.id, node));
Dagre.layout(dagre, {});
return {
nodes: nodes.map((node) => {
const { x, y } = dagre.node(node.id);
const positionedNodes = nodes.map((node) => {
const { x, y } = dagre.node(node.id);
if (node.type === "role") {
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
y: y - 150
}
};
}),
}
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
},
style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style
};
});
positionedNodes.forEach((node) => {
nodeMap[node.id] = node;
});
Array.from(showMoreParentIds).forEach((parentId) => {
const showMoreNodeIndex = positionedNodes.findIndex(
(node) => node.type === "showMoreButton" && node.data.parentId === parentId
);
if (showMoreNodeIndex !== -1) {
const siblings = positionedNodes.filter(
(node) => node.data?.parentId === parentId && node.type !== "showMoreButton"
);
if (siblings.length > 0) {
const rightmostSibling = siblings.reduce(
(rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost),
siblings[0]
);
positionedNodes[showMoreNodeIndex] = {
...positionedNodes[showMoreNodeIndex],
position: {
x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30,
y: rightmostSibling.position.y
}
};
}
}
});
return {
nodes: positionedNodes,
edges
};
};
@@ -0,0 +1,79 @@
import { useState } from "react";
import { SecretRotationV2Form } from "@app/components/secret-rotations-v2/forms";
import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
import { SecretRotationV2Select } from "@app/components/secret-rotations-v2/SecretRotationV2Select";
import { Modal, ModalContent } from "@app/components/v2";
import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
type SharedProps = {
secretPath: string;
environment?: string;
environments?: WorkspaceEnv[];
};
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
} & SharedProps;
type ContentProps = {
onComplete: (secretRotation: TSecretRotationV2) => void;
selectedRotation: SecretRotation | null;
setSelectedRotation: (selectedRotation: SecretRotation | null) => void;
} & SharedProps;
const Content = ({ setSelectedRotation, selectedRotation, ...props }: ContentProps) => {
if (selectedRotation) {
return (
<SecretRotationV2Form
onCancel={() => setSelectedRotation(null)}
type={selectedRotation}
{...props}
/>
);
}
return <SecretRotationV2Select onSelect={setSelectedRotation} />;
};
export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: Props) => {
const [selectedRotation, setSelectedRotation] = useState<SecretRotation | null>(null);
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) setSelectedRotation(null);
onOpenChange(open);
}}
>
<ModalContent
title={
selectedRotation ? (
<SecretRotationV2ModalHeader isConfigured={false} type={selectedRotation} />
) : (
"Add Secret Rotation"
)
}
onPointerDownOutside={(e) => e.preventDefault()}
className={selectedRotation ? "max-w-2xl" : "max-w-3xl"}
subTitle={
selectedRotation ? undefined : "Select a provider to create a secret rotation for."
}
bodyClassName="overflow-visible"
>
<Content
onComplete={() => {
setSelectedRotation(null);
onOpenChange(false);
}}
selectedRotation={selectedRotation}
setSelectedRotation={setSelectedRotation}
{...props}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, Switch } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { useDeleteSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: () => void;
};
export const DeleteSecretRotationV2Modal = ({
isOpen,
onOpenChange,
secretRotation,
onComplete
}: Props) => {
const deleteSecretRotation = useDeleteSecretRotationV2();
const [revokeGeneratedCredentials, setRevokeGeneratedCredentials] = useState(false);
const [deleteSecrets, setDeleteSecrets] = useState(false);
useEffect(() => {
if (!isOpen) {
setRevokeGeneratedCredentials(false);
setDeleteSecrets(false);
}
}, [isOpen]);
if (!secretRotation) return null;
const { id: rotationId, name, type, projectId, folder } = secretRotation;
const handleDeleteSecretRotation = async () => {
const rotationType = SECRET_ROTATION_MAP[type].name;
try {
await deleteSecretRotation.mutateAsync({
rotationId,
type,
revokeGeneratedCredentials,
deleteSecrets,
projectId,
secretPath: folder.path
});
createNotification({
text: `Successfully deleted ${rotationType} Rotation`,
type: "success"
});
if (onComplete) onComplete();
onOpenChange(false);
} catch {
createNotification({
text: `Failed to delete ${rotationType} Rotation`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteSecretRotation}
>
<Switch
containerClassName="mt-4"
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-red/50"
thumbClassName="bg-mineshaft-800"
isChecked={revokeGeneratedCredentials}
onCheckedChange={setRevokeGeneratedCredentials}
id="revoke-credentials"
>
Revoke Credentials
</Switch>
<p className="mt-1 font-inter text-sm text-mineshaft-400">
Generated credentials will {revokeGeneratedCredentials ? "" : "not"} be revoked on deletion
{revokeGeneratedCredentials ? "" : " and remain active"}.
</p>
<Switch
containerClassName="mt-4"
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-red/50"
thumbClassName="bg-mineshaft-800"
isChecked={deleteSecrets}
onCheckedChange={setDeleteSecrets}
id="delete-secrets"
>
Delete Secrets
</Switch>
<p className="mt-1 font-inter text-sm text-mineshaft-400">
Rotation secrets will {deleteSecrets ? "" : "not"} be removed from your project on deletion.
</p>
</DeleteActionModal>
);
};
@@ -0,0 +1,34 @@
import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader";
import { Modal, ModalContent } from "@app/components/v2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretRotationV2Form } from "./forms";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
secretRotation?: TSecretRotationV2;
};
export const EditSecretRotationV2Modal = ({ secretRotation, onOpenChange, ...props }: Props) => {
if (!secretRotation) return null;
return (
<Modal {...props} onOpenChange={onOpenChange}>
<ModalContent
title={<SecretRotationV2ModalHeader isConfigured type={secretRotation.type} />}
className="max-w-2xl"
bodyClassName="overflow-visible"
>
<SecretRotationV2Form
onComplete={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
secretRotation={secretRotation}
type={secretRotation.type}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,88 @@
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { useRotateSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretRotation: TSecretRotationV2;
onComplete: () => void;
};
const Content = ({ secretRotation, onComplete }: ContentProps) => {
const rotateSecrets = useRotateSecretRotationV2();
const { id: rotationId, type, projectId, folder } = secretRotation;
const rotationType = SECRET_ROTATION_MAP[type].name;
const handleRotateSecrets = async () => {
try {
await rotateSecrets.mutateAsync({
rotationId,
type,
projectId,
secretPath: folder.path
});
createNotification({
text: `Successfully rotated ${rotationType} secrets`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to rotate ${rotationType} secrets`,
type: "error"
});
}
};
return (
<div>
<p className="mb-8 text-sm text-mineshaft-200">
Are you sure you want to rotate the secrets for this {rotationType} Rotation?
</p>
<div className="mt-8 flex w-full items-center justify-between gap-2">
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
<Button
onClick={handleRotateSecrets}
isDisabled={rotateSecrets.isPending}
isLoading={rotateSecrets.isPending}
colorSchema="secondary"
>
Rotate Secrets
</Button>
</div>
</div>
);
};
export const RotateSecretRotationV2Modal = ({ isOpen, onOpenChange, secretRotation }: Props) => {
if (!secretRotation) return null;
const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Rotate Secrets"
subTitle={`Rotate the secrets for this ${rotationType}.`}
>
<Content secretRotation={secretRotation} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,49 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
type Props = {
type: SecretRotation;
isConfigured: boolean;
};
export const SecretRotationV2ModalHeader = ({ type, isConfigured }: Props) => {
const destinationDetails = SECRET_ROTATION_MAP[type];
return (
<div className="flex w-full items-start gap-2">
<img
alt={`${destinationDetails.name} logo`}
src={`/images/integrations/${destinationDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{destinationDetails.name} Rotation
<a
target="_blank"
href={`https://infisical.com/docs/platform/secret-rotations/${type}`}
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConfigured
? `Edit ${destinationDetails.name} Rotation`
: `Rotate ${destinationDetails.name}`}
</p>
</div>
</div>
);
};
@@ -0,0 +1,98 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import { SecretRotation, useSecretRotationV2Options } from "@app/hooks/api/secretRotationsV2";
type Props = {
onSelect: (type: SecretRotation) => void;
};
export const SecretRotationV2Select = ({ onSelect }: Props) => {
const { isPending, data: secretRotationOptions } = useSecretRotationV2Options();
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-2">
{secretRotationOptions?.map(({ type }) => {
const { image, name } = SECRET_ROTATION_MAP[type];
let size: number;
switch (type) {
case SecretRotation.MsSqlCredentials:
size = 50;
break;
default:
size = 45;
}
return (
<button
type="button"
key={type}
onClick={() => onSelect(type)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${image}`}
width={size}
className="mt-auto"
alt={`${name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{name}
</div>
</button>
);
})}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more services.</p>
<p>
{`If you don't see the third-party
service you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4 hover:bg-mineshaft-900/50">
<FontAwesomeIcon className="mt-auto text-3xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-xs font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,109 @@
import { faBan, faRotate, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistanceToNow } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Tooltip } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge/Badge";
import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
type Props = {
secretRotation: TSecretRotationV2;
className?: string;
};
export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props) => {
const { isAutoRotationEnabled, rotationStatus, nextRotationAt, lastRotationMessage } =
secretRotation;
if (rotationStatus === SecretRotationStatus.Failed) {
let errorMessage = lastRotationMessage;
if (lastRotationMessage) {
try {
errorMessage = JSON.stringify(JSON.parse(lastRotationMessage), null, 2);
} catch {
errorMessage = lastRotationMessage;
}
}
return (
<Tooltip
position="left"
className="max-w-sm select-text"
content={
<div className="flex flex-col gap-2 whitespace-normal py-1">
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
<div className="text-xs">Failure Reason</div>
</div>
<div className="break-words rounded bg-mineshaft-600 p-2 text-xs">{errorMessage}</div>
</div>
{nextRotationAt && (
<span className="text-xs text-mineshaft-300">
Next rotation attempt on {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
{format(nextRotationAt, "h:mm aa")}.
</span>
)}
</div>
}
>
<div>
<Badge
variant="danger"
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
>
<FontAwesomeIcon icon={faXmark} />
Rotation Failed
</Badge>
</div>
</Tooltip>
);
}
if (!isAutoRotationEnabled) {
return (
<Badge
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300",
className
)}
>
<FontAwesomeIcon icon={faBan} />
Auto-Rotation Disabled
</Badge>
);
}
const daysToRotation =
(new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24);
return (
<Tooltip
className="max-w-lg"
content={
<>
<span>
Rotates on {format(nextRotationAt, "MM/dd/yyyy")} at {format(nextRotationAt, "h:mm aa")}
</span>{" "}
<span className="text-mineshaft-300">(Local Time)</span>
</>
}
>
<div>
<Badge
variant={daysToRotation >= 7 ? "success" : "primary"}
className={twMerge(
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize",
className
)}
>
<FontAwesomeIcon icon={faRotate} />
{daysToRotation < 0
? "Rotating"
: `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`}
</Badge>
</div>
</Tooltip>
);
};
@@ -0,0 +1,105 @@
import { ReactNode } from "react";
import { faRotate } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { Modal, ModalContent, Spinner } from "@app/components/v2";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import {
SecretRotation,
TSecretRotationV2,
useViewSecretRotationV2GeneratedCredentials
} from "@app/hooks/api/secretRotationsV2";
import { ViewSqlRotationGeneratedCredentials } from "./shared";
type Props = {
secretRotation?: TSecretRotationV2;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
secretRotation: TSecretRotationV2;
};
const Content = ({ secretRotation }: ContentProps) => {
const { id: rotationId, type, nextRotationAt } = secretRotation;
const { data: generatedCredentialsResponse, isPending } =
useViewSecretRotationV2GeneratedCredentials({
rotationId,
type
});
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading generated credentials...</p>
</div>
);
}
if (!generatedCredentialsResponse) {
return (
<div className="flex w-full justify-center">
<p className="text-sm text-red">No generated credentials found for this rotation.</p>
</div>
);
}
let Component: ReactNode;
switch (generatedCredentialsResponse.type) {
case SecretRotation.PostgresCredentials:
case SecretRotation.MsSqlCredentials:
Component = (
<ViewSqlRotationGeneratedCredentials
generatedCredentialsResponse={generatedCredentialsResponse}
/>
);
break;
default:
throw new Error("Unhandled View Generated Credential Rotation Type");
}
return (
<div className="flex flex-col gap-y-4">
{Component}
{nextRotationAt && (
<div className="flex items-center gap-x-1.5 text-sm text-mineshaft-200">
<FontAwesomeIcon icon={faRotate} className="text-mineshaft-400" />
<span>
Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "}
{format(nextRotationAt, "h:mm aa")}{" "}
<span className="text-mineshaft-300">(Local Time)</span>
</span>
</div>
)}
</div>
);
};
export const ViewSecretRotationV2GeneratedCredentialsModal = ({
isOpen,
onOpenChange,
secretRotation
}: Props) => {
if (!secretRotation) return null;
const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
title="Generated Credentials"
subTitle={`View the current and retired ${rotationType}.`}
>
<Content secretRotation={secretRotation} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export * from "./ViewSecretRotationV2GeneratedCredentials";
@@ -0,0 +1,55 @@
import { useReducer } from "react";
import { faCheck, faCopy, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type Props = {
children?: string;
label: string;
isSensitive?: boolean;
};
export const CredentialDisplay = ({ children, label, isSensitive }: Props) => {
const [showCredential, toggleShowCredential] = useReducer((prev) => !prev, !isSensitive);
const [, isCopyingCredential, setCopyCredential] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
return (
<GenericFieldLabel label={label}>
{children ? (
<div className="flex w-full items-center gap-1 text-mineshaft-100">
{showCredential ? children : "****************************"}
<Tooltip content={`Copy ${label}`}>
<IconButton
onClick={() => {
setCopyCredential(children);
navigator.clipboard.writeText(children);
}}
ariaLabel="Copy credential"
variant="plain"
size="xs"
>
<FontAwesomeIcon icon={isCopyingCredential ? faCheck : faCopy} />
</IconButton>
</Tooltip>
{isSensitive && (
<Tooltip content={`Show ${label}`}>
<IconButton
variant="plain"
size="xs"
onClick={toggleShowCredential}
ariaLabel={`${showCredential ? "Hide" : "Show"} credential`}
>
<FontAwesomeIcon icon={faEyeSlash} />
</IconButton>
</Tooltip>
)}
</div>
) : null}
</GenericFieldLabel>
);
};
@@ -0,0 +1,57 @@
import { faCheck, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay";
import { TViewSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2";
type Props = {
generatedCredentialsResponse: TViewSecretRotationGeneratedCredentialsResponse;
};
export const ViewSqlRotationGeneratedCredentials = ({
generatedCredentialsResponse: { generatedCredentials, activeIndex }
}: Props) => {
const inactiveIndex = activeIndex === 0 ? 1 : 0;
const activeCredentials = generatedCredentials[activeIndex];
const inactiveCredentials = generatedCredentials[inactiveIndex];
return (
<>
<div className="flex flex-col gap-2">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-100">
<FontAwesomeIcon icon={faCheck} className="mr-1.5 text-green" />
Current Credentials
</span>
</div>
<p className="text-sm text-mineshaft-300">
The active credential set currently mapped to the rotation secrets.
</p>
<div className="flex flex-col gap-x-8 gap-y-2 rounded border border-mineshaft-600 bg-mineshaft-700 p-2">
<CredentialDisplay label="Username">{activeCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{activeCredentials?.password}
</CredentialDisplay>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-100">
<FontAwesomeIcon icon={faClockRotateLeft} className="mr-1.5 text-yellow" />
Retired Credentials
</span>
</div>
<p className="text-sm text-mineshaft-300">
The retired credential set that will be revoked during the next rotation cycle.
</p>
<div className="flex flex-col gap-x-8 gap-y-2 rounded border border-mineshaft-600 bg-mineshaft-700 p-2">
<CredentialDisplay label="Username">{inactiveCredentials?.username}</CredentialDisplay>
<CredentialDisplay isSensitive label="Password">
{inactiveCredentials?.password}
</CredentialDisplay>
</div>
</div>
</>
);
};
@@ -0,0 +1 @@
export * from "./ViewSqlRotationGeneratedCredentials";
@@ -0,0 +1,122 @@
import { Controller, useFormContext } from "react-hook-form";
import { format, setHours, setMinutes } from "date-fns";
import { FilterableSelect, FormControl, Input, Switch } from "@app/components/v2";
import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { TSecretRotationV2Form } from "./schemas";
import { SecretRotationV2ConnectionField } from "./SecretRotationV2ConnectionField";
type Props = {
isUpdate: boolean;
environments?: WorkspaceEnv[];
};
export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => {
const { control } = useFormContext<TSecretRotationV2Form>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure the connection rotation strategy for this Secret Rotation.
</p>
{!isUpdate && environments && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl label="Environment" isError={Boolean(error)} errorText={error?.message}>
<FilterableSelect
value={value}
onChange={onChange}
options={environments}
placeholder="Select an environment..."
getOptionLabel={(option) => option?.name}
getOptionValue={(option) => option?.id}
/>
</FormControl>
)}
/>
)}
<SecretRotationV2ConnectionField isUpdate={isUpdate} />
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Rotation Interval (In Days)"
>
<Input
value={value}
type="number"
onChange={onChange}
min={1}
placeholder="my-secret-rotation"
/>
</FormControl>
)}
control={control}
name="rotationInterval"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
label="Rotate At (Local Time)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="time"
value={format(getRotateAtLocal(value), "HH:mm")}
onChange={(e) => {
const time = e.target.value;
if (time) {
const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
const newSelectedDate = setHours(setMinutes(new Date(), minutes), hours);
onChange({
hours: newSelectedDate.getUTCHours(),
minutes: newSelectedDate.getUTCMinutes()
});
}
}}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</FormControl>
);
}}
control={control}
name="rotateAtUtc"
/>
<Controller
control={control}
name="isAutoRotationEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<FormControl
helperText={
value
? "Secrets will automatically be rotated when the rotation interval specified above as elapsed."
: "Secrets will not be rotated automatically. You can still rotate secrets manually."
}
isError={Boolean(error)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="auto-rotation-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p className="w-[9.6rem]">Auto-Rotation {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
);
}}
/>
</>
);
};
@@ -0,0 +1,102 @@
import { Controller, useFormContext } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { SECRET_ROTATION_CONNECTION_MAP } from "@app/helpers/secretRotationsV2";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { TSecretRotationV2Form } from "./schemas";
type Props = {
onChange?: VoidFunction;
isUpdate: boolean;
};
export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate }: Props) => {
const { permission } = useOrgPermission();
const { control, watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const app = SECRET_ROTATION_CONNECTION_MAP[rotationType];
const { data: availableConnections, isPending } = useListAvailableAppConnections(app);
const connectionName = APP_CONNECTION_MAP[app].name;
const canCreateConnection = permission.can(
OrgPermissionAppConnectionActions.Create,
OrgPermissionSubjects.AppConnections
);
const appName = APP_CONNECTION_MAP[app].name;
return (
<>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)}
errorText={error?.message}
label={`${connectionName} Connection`}
helperText={
isUpdate ? (
"Cannot be updated"
) : (
<p>
Check out{" "}
<a
href={`https://infisical.com/docs/integrations/app-connections/${app}`}
target="_blank"
className="underline"
rel="noopener noreferrer"
>
our docs
</a>{" "}
to ensure your connection has the required permissions for secret rotation.
</p>
)
}
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
if (callback) callback();
}}
isLoading={isPending}
options={availableConnections}
isDisabled={isUpdate}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="connection"
/>
{!isUpdate && availableConnections?.length === 0 && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? (
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p>
)}
</>
);
};
@@ -0,0 +1,51 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { TSecretRotationV2Form } from "./schemas";
export const SecretRotationV2DetailsFields = () => {
const { control } = useFormContext<TSecretRotationV2Form>();
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Provide a name and description for this Secret Rotation.
</p>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Must be slug-friendly"
isError={Boolean(error)}
errorText={error?.message}
label="Name"
>
<Input autoFocus value={value} onChange={onChange} placeholder="my-secret-rotation" />
</FormControl>
)}
control={control}
name="name"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
isOptional
errorText={error?.message}
label="Description"
>
<TextArea
value={value ?? ""}
onChange={onChange}
placeholder="Describe the purpose of this rotation..."
className="!resize-none"
rows={4}
/>
</FormControl>
)}
control={control}
name="description"
/>
</>
);
};
@@ -0,0 +1,231 @@
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { SecretRotationV2ConfigurationFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields";
import { SecretRotationV2DetailsFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields";
import { SecretRotationV2ParametersFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields";
import { SecretRotationV2ReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields";
import { SecretRotationV2SecretsMappingFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields";
import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2";
import {
SecretRotation,
TSecretRotationV2,
useSecretRotationV2Option
} from "@app/hooks/api/secretRotationsV2";
import {
useCreateSecretRotationV2,
useUpdateSecretRotationV2
} from "@app/hooks/api/secretRotationsV2/mutations";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { SecretRotationV2FormSchema, TSecretRotationV2Form } from "./schemas";
type Props = {
onComplete: (secretRotation: TSecretRotationV2) => void;
type: SecretRotation;
onCancel: () => void;
secretPath: string;
environment?: string;
environments?: WorkspaceEnv[];
secretRotation?: TSecretRotationV2;
};
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Form)[] }[] = [
{
name: "Configuration",
key: "configuration",
fields: [
"isAutoRotationEnabled",
"environment",
"rotationInterval",
"connection",
"rotateAtUtc"
]
},
{ name: "Parameters", key: "parameters", fields: ["parameters"] },
{ name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
const DEFAULT_ROTATION_INTERVAL = 30;
export const SecretRotationV2Form = ({
type,
onComplete,
onCancel,
environment: envSlug,
secretPath,
secretRotation,
environments
}: Props) => {
const createSecretRotation = useCreateSecretRotationV2();
const updateSecretRotation = useUpdateSecretRotationV2();
const { currentWorkspace } = useWorkspace();
const { name: rotationType } = SECRET_ROTATION_MAP[type];
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const { rotationOption } = useSecretRotationV2Option(type);
const formMethods = useForm<TSecretRotationV2Form>({
resolver: zodResolver(SecretRotationV2FormSchema),
defaultValues: secretRotation
? {
...secretRotation,
environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
secretPath
}
: {
type,
isAutoRotationEnabled: true,
rotationInterval: DEFAULT_ROTATION_INTERVAL,
rotateAtUtc: {
hours: 0,
minutes: 0
},
environment: currentWorkspace?.environments.find((env) => env.slug === envSlug),
secretPath,
...rotationOption!.template
},
reValidateMode: "onChange"
});
const onSubmit = async ({
environment,
connection,
...formData
}: TSecretRotationV2Form) => {
const mutation = secretRotation
? updateSecretRotation.mutateAsync({
rotationId: secretRotation.id,
projectId: secretRotation.projectId,
...formData
})
: createSecretRotation.mutateAsync({
...formData,
connectionId: connection.id,
environment: environment.slug,
projectId: currentWorkspace.id
});
try {
const rotation = await mutation;
createNotification({
text: `Successfully ${secretRotation ? "updated" : "created"} ${rotationType} Rotation`,
type: "success"
});
onComplete(rotation);
} catch (err: any) {
createNotification({
title: `Failed to ${secretRotation ? "update" : "create"} ${rotationType} Rotation`,
text: err.message,
type: "error"
});
}
};
const handlePrev = () => {
if (selectedTabIndex === 0) {
onCancel();
return;
}
setSelectedTabIndex((prev) => prev - 1);
};
const { handleSubmit, trigger } = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
return;
}
const isValid = await isStepValid(selectedTabIndex);
if (!isValid) return;
setSelectedTabIndex((prev) => prev + 1);
};
const isTabEnabled = async (index: number) => {
let isEnabled = true;
for (let i = index - 1; i >= 0; i -= 1) {
// eslint-disable-next-line no-await-in-loop
isEnabled = isEnabled && (await isStepValid(i));
}
return isEnabled;
};
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
<FormProvider {...formMethods}>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
{FORM_TABS.map((tab, index) => (
<Tab
onClick={async (e) => {
e.preventDefault();
const isEnabled = await isTabEnabled(index);
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
}}
className={({ selected }) =>
`w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
key={tab.key}
>
{index + 1}. {tab.name}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<SecretRotationV2ConfigurationFields
isUpdate={Boolean(secretRotation)}
environments={environments}
/>
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2ParametersFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2SecretsMappingFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2DetailsFields />
</Tab.Panel>
<Tab.Panel>
<SecretRotationV2ReviewFields />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? `${secretRotation ? "Update" : "Create"} Secret Rotation` : "Next"}
</Button>
<Button onClick={handlePrev} colorSchema="secondary">
Back
</Button>
</div>
</form>
);
};
@@ -0,0 +1,28 @@
import { useFormContext } from "react-hook-form";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { SqlRotationParametersFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationParametersFields,
[SecretRotation.MsSqlCredentials]: SqlRotationParametersFields
};
export const SecretRotationV2ParametersFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const Component = COMPONENT_MAP[rotationType];
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Configure the required parameters for this Secret Rotation.
</p>
<Component />
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationV2ParametersFields";
@@ -0,0 +1,67 @@
import { Controller, useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { FormControl, Input } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationParametersFields = () => {
const { control, watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const type = watch("type");
const { rotationOption } = useSecretRotationV2Option(type);
return (
<>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Database Username 1"
>
<Input value={value} onChange={onChange} placeholder="infiscal_user_1" />
</FormControl>
)}
control={control}
name="parameters.username1"
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Database Username 2"
>
<Input value={value} onChange={onChange} placeholder="infiscal_user_2" />
</FormControl>
)}
control={control}
name="parameters.username2"
/>
<NoticeBannerV2 title="Example Create User Statement">
<p className="mb-3 text-sm text-mineshaft-300">
Infisical requires two database users to be created for rotation.
</p>
<p className="mb-3 text-sm text-mineshaft-300">
These users are intended to be solely managed by Infisical. Altering their login after
rotation may cause unexpected failure.
</p>
<p className="mb-3 text-sm text-mineshaft-300">
Below is an example statement for creating the required users. You may need to modify it
to suit your needs.
</p>
<p className="mb-3 text-sm">
<pre className="max-h-[10rem] overflow-y-auto whitespace-pre-wrap rounded border border-mineshaft-700 bg-mineshaft-800 p-2 text-mineshaft-300">
{rotationOption!.template.createUserStatement}
</pre>
</p>
</NoticeBannerV2>
</>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationParametersFields";
@@ -0,0 +1,61 @@
import { useFormContext } from "react-hook-form";
import { format } from "date-fns";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { SqlRotationReviewFields } from "@app/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/shared";
import { GenericFieldLabel } from "@app/components/v2";
import { getRotateAtLocal } from "@app/helpers/secretRotationsV2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationReviewFields,
[SecretRotation.MsSqlCredentials]: SqlRotationReviewFields
};
export const SecretRotationV2ReviewFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const {
environment,
secretPath,
connection,
type,
name,
description,
rotationInterval,
rotateAtUtc
} = watch();
const Component = COMPONENT_MAP[type];
return (
<div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Configuration</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
<GenericFieldLabel label="Environment">{environment.name}</GenericFieldLabel>
<GenericFieldLabel label="Secret Path">{secretPath}</GenericFieldLabel>
<GenericFieldLabel label="Rotation Interval">
{rotationInterval} Day{rotationInterval > 1 ? "s" : ""}
</GenericFieldLabel>
<GenericFieldLabel label="Rotate At">
{format(getRotateAtLocal(rotateAtUtc), "h:mm aa")}
</GenericFieldLabel>
</div>
</div>
<Component />
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationReviewFields";
@@ -0,0 +1,41 @@
import { useFormContext } from "react-hook-form";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationReviewFields = () => {
const { watch } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const [{ username1, username2 }, { username, password }] = watch([
"parameters",
"secretsMapping"
]);
return (
<>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Parameters</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Database Username 1">{username1}</GenericFieldLabel>
<GenericFieldLabel label="Database Username 2">{username2}</GenericFieldLabel>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Secrets Mapping</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<GenericFieldLabel label="Username">{username}</GenericFieldLabel>
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
</div>
</div>
</>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationReviewFields";
@@ -0,0 +1,28 @@
import { useFormContext } from "react-hook-form";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
import { TSecretRotationV2Form } from "../schemas";
import { SqlRotationSecretsMappingFields } from "./shared";
const COMPONENT_MAP: Record<SecretRotation, React.FC> = {
[SecretRotation.PostgresCredentials]: SqlRotationSecretsMappingFields,
[SecretRotation.MsSqlCredentials]: SqlRotationSecretsMappingFields
};
export const SecretRotationV2SecretsMappingFields = () => {
const { watch } = useFormContext<TSecretRotationV2Form>();
const rotationType = watch("type");
const Component = COMPONENT_MAP[rotationType];
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Map the rotated credentials to secrets in your Infisical project.
</p>
<Component />
</>
);
};
@@ -0,0 +1 @@
export * from "./SecretRotationV2SecretsMappingFields";
@@ -0,0 +1,88 @@
import { Controller, useFormContext } from "react-hook-form";
import { faArrowRight, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
import { Badge, FormControl, FormLabel, Input } from "@app/components/v2";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const SqlRotationSecretsMappingFields = () => {
const { control } = useFormContext<
TSecretRotationV2Form & {
type: SecretRotation.PostgresCredentials; // all sql rotations share these fields
}
>();
const items = [
{
name: "Username",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input value={value} onChange={onChange} placeholder="DB_USERNAME" />
</FormControl>
)}
control={control}
name="secretsMapping.username"
/>
)
},
{
name: "Password",
input: (
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input value={value} onChange={onChange} placeholder="DB_PASSWORD" />
</FormControl>
)}
control={control}
name="secretsMapping.password"
/>
)
}
];
return (
<div className="w-full overflow-hidden">
<table className="w-full table-auto">
<thead>
<tr className="text-left">
<th className="whitespace-nowrap">
<FormLabel label="Rotated Credentials" />
</th>
<th />
<th>
<FormLabel
tooltipClassName="max-w-sm"
tooltipText="The name of the secret that the active credentials will be mapped to."
label="Secret Name"
/>
</th>
</tr>
</thead>
<tbody>
{items.map(({ name, input }) => (
<tr key={name}>
<td className="whitespace-nowrap">
<div className="mb-4 flex h-full items-start justify-center">
<Badge className="pointer-events-none flex h-[36px] w-full items-center justify-center gap-1.5 whitespace-nowrap border border-mineshaft-600 bg-mineshaft-600 text-bunker-200">
<FontAwesomeIcon icon={faKey} />
<span>{name}</span>
</Badge>
</div>
</td>
<td className="whitespace-nowrap pl-5 pr-5">
<div className="mb-4 flex items-center justify-center">
<FontAwesomeIcon className="text-mineshaft-400" icon={faArrowRight} />
</div>
</td>
<td className="w-full">{input}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1 @@
export * from "./SqlRotationSecretsMappingFields";
@@ -0,0 +1 @@
export * from "./SecretRotationV2Form";
@@ -0,0 +1,17 @@
import { z } from "zod";
import { slugSchema } from "@app/lib/schemas";
export const BaseSecretRotationSchema = z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").nullish(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
isAutoRotationEnabled: z.boolean(),
rotationInterval: z.coerce.number(),
rotateAtUtc: z.object({
hours: z.number(),
minutes: z.number()
})
});
@@ -0,0 +1,13 @@
import { z } from "zod";
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
MsSqlCredentialsRotationSchema
]);
export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
export type TSecretRotationV2Form = z.infer<typeof SecretRotationV2FormSchema>;
@@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const MsSqlCredentialsRotationSchema = z
.object({
type: z.literal(SecretRotation.MsSqlCredentials)
})
.merge(SqlCredentialsRotationSchema)
.merge(BaseSecretRotationSchema);
@@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
export const PostgresCredentialsRotationSchema = z
.object({
type: z.literal(SecretRotation.PostgresCredentials)
})
.merge(SqlCredentialsRotationSchema)
.merge(BaseSecretRotationSchema);
@@ -0,0 +1 @@
export * from "./sql-credentials-rotation-schema";
@@ -0,0 +1,14 @@
import { z } from "zod";
import { SecretNameSchema } from "@app/lib/schemas";
export const SqlCredentialsRotationSchema = z.object({
parameters: z.object({
username1: z.string().trim().min(1, "Database Username 1 Required"),
username2: z.string().trim().min(1, "Database Username 2 Required")
}),
secretsMapping: z.object({
username: SecretNameSchema,
password: SecretNameSchema
})
});
@@ -0,0 +1 @@
export * from "./CreateSecretRotationV2Modal";
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -17,9 +17,9 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => {
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Resource Tags">
<GenericFieldLabel label="Resource Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
@@ -49,12 +49,12 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => {
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
</GenericFieldLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<GenericFieldLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</>
);
@@ -71,13 +71,13 @@ export const AwsParameterStoreDestinationReviewFields = () => {
return (
<>
<SecretSyncLabel label="Region">
<GenericFieldLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Path">{path}</SecretSyncLabel>
</GenericFieldLabel>
<GenericFieldLabel label="Path">{path}</GenericFieldLabel>
</>
);
};
@@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
@@ -24,17 +24,17 @@ export const AwsSecretsManagerSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Region">
<GenericFieldLabel label="Region">
{awsRegion?.name}
<Badge className="ml-1" variant="success">
{awsRegion?.slug}{" "}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Mapping Behavior">
</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Mapping Behavior">
{mappingBehavior}
</SecretSyncLabel>
</GenericFieldLabel>
{mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && (
<SecretSyncLabel label="Secret Name">{secretName}</SecretSyncLabel>
<GenericFieldLabel label="Secret Name">{secretName}</GenericFieldLabel>
)}
</>
);
@@ -49,9 +49,9 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => {
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{keyId && <GenericFieldLabel label="KMS Key">{keyId}</GenericFieldLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Tags">
<GenericFieldLabel label="Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
@@ -81,12 +81,12 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => {
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
</GenericFieldLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<GenericFieldLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</>
);
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -13,8 +13,8 @@ export const AzureAppConfigurationSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Configuration URL">{vaultBaseUrl}</SecretSyncLabel>
<SecretSyncLabel label="Label">{label}</SecretSyncLabel>
<GenericFieldLabel label="Configuration URL">{vaultBaseUrl}</GenericFieldLabel>
<GenericFieldLabel label="Label">{label}</GenericFieldLabel>
</>
);
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const AzureKeyVaultSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.AzureKeyVault }>();
const vaultBaseUrl = watch("destinationConfig.vaultBaseUrl");
return <SecretSyncLabel label="Vault URL">{vaultBaseUrl}</SecretSyncLabel>;
return <GenericFieldLabel label="Vault URL">{vaultBaseUrl}</GenericFieldLabel>;
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -8,5 +8,5 @@ export const DatabricksSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Databricks }>();
const scope = watch("destinationConfig.scope");
return <SecretSyncLabel label="Secret Scope">{scope}</SecretSyncLabel>;
return <GenericFieldLabel label="Secret Scope">{scope}</GenericFieldLabel>;
};
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
@@ -10,5 +10,5 @@ export const GcpSyncReviewFields = () => {
>();
const projectId = watch("destinationConfig.projectId");
return <SecretSyncLabel label="Project ID">{projectId}</SecretSyncLabel>;
return <GenericFieldLabel label="Project ID">{projectId}</GenericFieldLabel>;
};
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GitHubSyncScope, TGitHubSync } from "@app/hooks/api/secretSyncs/types/github-sync";
@@ -16,30 +16,30 @@ export const GitHubSyncReviewFields = () => {
switch (config.scope) {
case GitHubSyncScope.Repository:
ScopeComponents = (
<SecretSyncLabel label="Repository">
<GenericFieldLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
</GenericFieldLabel>
);
break;
case GitHubSyncScope.Organization:
ScopeComponents = (
<>
<SecretSyncLabel label="Organization">{config.org}</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Visibility">
<GenericFieldLabel label="Organization">{config.org}</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Visibility">
{config.visibility}
</SecretSyncLabel>
</GenericFieldLabel>
</>
);
break;
case GitHubSyncScope.RepositoryEnvironment:
ScopeComponents = (
<>
<SecretSyncLabel label="Repository">
<GenericFieldLabel label="Repository">
{config.owner}/{config.repo}
</SecretSyncLabel>
<SecretSyncLabel className="capitalize" label="Environment">
</GenericFieldLabel>
<GenericFieldLabel className="capitalize" label="Environment">
{config.env}
</SecretSyncLabel>
</GenericFieldLabel>
</>
);
@@ -52,9 +52,9 @@ export const GitHubSyncReviewFields = () => {
return (
<>
<SecretSyncLabel className="capitalize" label="Scope">
<GenericFieldLabel className="capitalize" label="Scope">
{config.scope.replace("-", " ")}
</SecretSyncLabel>
</GenericFieldLabel>
{ScopeComponents}
</>
);
@@ -1,6 +1,6 @@
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
@@ -14,10 +14,10 @@ export const HumanitecSyncReviewFields = () => {
return (
<>
<SecretSyncLabel label="Organization">{orgId}</SecretSyncLabel>
<SecretSyncLabel label="Application">{appId}</SecretSyncLabel>
<GenericFieldLabel label="Organization">{orgId}</GenericFieldLabel>
<GenericFieldLabel label="Application">{appId}</GenericFieldLabel>
{scope === HumanitecSyncScope.Environment && (
<SecretSyncLabel label="Environment">{envId}</SecretSyncLabel>
<GenericFieldLabel label="Environment">{envId}</GenericFieldLabel>
)}
</>
);
@@ -1,7 +1,7 @@
import { ReactNode } from "react";
import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
@@ -83,8 +83,8 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Source</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Environment">{environment.name}</SecretSyncLabel>
<SecretSyncLabel label="Secret Path">{secretPath}</SecretSyncLabel>
<GenericFieldLabel label="Environment">{environment.name}</GenericFieldLabel>
<GenericFieldLabel label="Secret Path">{secretPath}</GenericFieldLabel>
</div>
</div>
<div className="flex flex-col gap-3">
@@ -92,7 +92,7 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Destination</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Connection">{connection.name}</SecretSyncLabel>
<GenericFieldLabel label="Connection">{connection.name}</GenericFieldLabel>
{DestinationFieldsComponent}
</div>
</div>
@@ -101,21 +101,21 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Sync Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Auto-Sync">
<GenericFieldLabel label="Auto-Sync">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
{isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</SecretSyncLabel>
<SecretSyncLabel label="Initial Sync Behavior">
</GenericFieldLabel>
<GenericFieldLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destinationName).name}
</SecretSyncLabel>
</GenericFieldLabel>
{/* <SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel> */}
{AdditionalSyncOptionsFieldsComponent}
{disableSecretDeletion && (
<SecretSyncLabel label="Secret Deletion">
<GenericFieldLabel label="Secret Deletion">
<Badge variant="primary">Disabled</Badge>
</SecretSyncLabel>
</GenericFieldLabel>
)}
</div>
</div>
@@ -124,8 +124,8 @@ export const SecretSyncReviewFields = () => {
<span className="text-sm text-mineshaft-300">Details</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Name">{name}</SecretSyncLabel>
<SecretSyncLabel label="Description">{description}</SecretSyncLabel>
<GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description}</GenericFieldLabel>
</div>
</div>
</div>
@@ -1,9 +1,9 @@
export * from "../v2/GenericFieldLabel";
export * from "./CreateSecretSyncModal";
export * from "./DeleteSecretSyncModal";
export * from "./EditSecretSyncModal";
export * from "./SecretSyncImportSecretsModal";
export * from "./SecretSyncImportStatusBadge";
export * from "./SecretSyncLabel";
export * from "./SecretSyncRemoveSecretsModal";
export * from "./SecretSyncRemoveStatusBadge";
export * from "./SecretSyncStatusBadge";
+1 -1
View File
@@ -9,7 +9,7 @@ interface IProps {
export const Blur = ({ className, tooltipText }: IProps) => {
return (
<Tooltip content={tooltipText} isDisabled={!tooltipText}>
<Tooltip content={tooltipText} className="max-w-md" isDisabled={!tooltipText}>
<div
className={twMerge("flex w-80 flex-grow items-center py-1 pl-4 pr-2", className)}
tabIndex={0}
@@ -1,14 +1,17 @@
import { ChangeEventHandler, useState } from "react";
import { DayPicker, DayPickerProps } from "react-day-picker";
import { DayPicker, DayPickerProps, getDefaultClassNames, UI } from "react-day-picker";
import { faCalendar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverContentProps, PopoverProps } from "@radix-ui/react-popover";
import { format, setHours, setMinutes } from "date-fns";
import { twMerge } from "tailwind-merge";
import { Button } from "../Button";
import { Input } from "../Input";
import { Popover, PopoverContent, PopoverTrigger } from "../Popoverv2";
const defaultClassNames = getDefaultClassNames();
export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
value?: Date;
onChange: (date?: Date) => void;
@@ -58,7 +61,30 @@ export const DatePicker = ({
{value ? format(value, dateFormat) : "Pick a date and time"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit p-2" {...popUpContentProps}>
<PopoverContent
{...popUpContentProps}
className={twMerge(
"w-fit border border-mineshaft-600 bg-mineshaft-800 p-2 font-inter",
popUpContentProps.className
)}
>
<div className="px-2 pt-4">
<DayPicker
{...props}
mode="single"
selected={value}
onSelect={handleDaySelect}
className="font-inter text-mineshaft-200"
classNames={{
today: "text-primary border-primary",
selected: " text-mineshaft-100 bg-mineshaft-500",
root: `text-mineshaft-300 ${defaultClassNames}`,
[UI.DayButton]: "p-3 rounded hover:text-mineshaft-100",
[UI.Weekday]: "px-3 pt-3",
[UI.Chevron]: "fill-mineshaft-300"
}}
/>
</div>
<div className="mx-4 my-4">
<Input
type="time"
@@ -67,17 +93,6 @@ export const DatePicker = ({
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
<DayPicker
{...props}
mode="single"
selected={value}
onSelect={handleDaySelect}
modifiersStyles={{
selected: {
background: "#cad62d"
}
}}
/>
</PopoverContent>
</Popover>
);
@@ -0,0 +1,159 @@
/* eslint-disable no-underscore-dangle */
import { forwardRef, InputHTMLAttributes } from "react";
import { InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ReactNode } from "@tanstack/react-router";
import { cva, VariantProps } from "cva";
import { EditorState, LexicalEditor } from "lexical";
import { twMerge } from "tailwind-merge";
import { HighlightNode } from "./EditorHighlight";
import { EditorPlaceholderPlugin } from "./EditorPlaceholderPlugin";
// Catch any errors that occur during Lexical updates and log them
// or throw them as needed. If you don't throw them, Lexical will
// try to recover gracefully without losing user data.
function onError(error: Error) {
console.error(error);
}
const inputVariants = cva(
"input w-full py-[0.375rem] text-gray-400 placeholder:text-sm placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2 hover:ring-bunker-400/60 duration-100",
{
variants: {
size: {
xs: ["text-xs"],
sm: ["text-sm"],
md: ["text-md"],
lg: ["text-lg"]
},
isRounded: {
true: ["rounded-md"],
false: ""
},
variant: {
filled: ["bg-mineshaft-900", "text-gray-400"],
outline: ["bg-transparent"],
plain: "bg-transparent outline-none"
},
isError: {
true: "focus:ring-red/50 placeholder-red-300",
false: "focus:ring-primary-400/50 focus:ring-1"
}
},
compoundVariants: []
}
);
const inputParentContainerVariants = cva("inline-flex font-inter items-center border relative", {
variants: {
isRounded: {
true: ["rounded-md"],
false: ""
},
isError: {
true: "border-red",
false: "border-mineshaft-500"
},
isFullWidth: {
true: "w-full",
false: ""
},
variant: {
filled: ["bg-bunker-800", "text-gray-400"],
outline: ["bg-transparent"],
plain: "border-none"
}
}
});
type Props = Omit<
InputHTMLAttributes<HTMLDivElement>,
"size" | "onChange" | "placeholder" | "aria-placeholder"
> &
VariantProps<typeof inputVariants> & {
children?: ReactNode;
namespace?: string;
placeholder?: string;
isFullWidth?: boolean;
isRequired?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
isDisabled?: boolean;
isReadOnly?: boolean;
containerClassName?: string;
onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void;
initialValue?: string;
};
export const Editor = forwardRef<HTMLDivElement, Props>(
(
{
children,
namespace = "infisical-editor",
className,
containerClassName,
isRounded = true,
isFullWidth = true,
isDisabled,
isError = false,
isRequired,
leftIcon,
rightIcon,
variant = "filled",
size = "md",
isReadOnly,
placeholder,
onChange,
...props
},
ref
) => {
const initialConfig: InitialConfigType = {
namespace,
onError,
nodes: [HighlightNode]
};
return (
<div
className={inputParentContainerVariants({
isRounded,
isError,
isFullWidth,
variant,
className: containerClassName
})}
>
{leftIcon && <span className="absolute left-0 ml-3 text-sm">{leftIcon}</span>}
<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
contentEditable={
<ContentEditable
ref={ref}
aria-required={isRequired}
readOnly={isReadOnly}
disabled={isDisabled}
className={twMerge(
leftIcon ? "pl-10" : "pl-2.5",
rightIcon ? "pr-10" : "pr-2.5",
inputVariants({ className, isError, size, isRounded, variant })
)}
{...props}
placeholder={null}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={onChange} />
<EditorPlaceholderPlugin placeholder={placeholder} />
{children}
</LexicalComposer>
{rightIcon && <span className="absolute right-0 mr-3">{rightIcon}</span>}
</div>
);
}
);
@@ -0,0 +1,127 @@
/* eslint-disable no-underscore-dangle,@typescript-eslint/class-methods-use-this */
import { useCallback, useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalTextEntity } from "@lexical/react/useLexicalTextEntity";
import {
$applyNodeReplacement,
EditorConfig,
LexicalNode,
SerializedTextNode,
Spread,
TextNode
} from "lexical";
type HighlightTheme = { contentClassName: string };
type Trigger = { startTrigger: string; endTrigger: string };
export type SerializedHighlightNode = Spread<
{
__highlightTheme: HighlightTheme;
__trigger: Trigger;
},
SerializedTextNode
>;
export class HighlightNode extends TextNode {
__highlightTheme: HighlightTheme;
__trigger: Trigger;
constructor(
text: string,
highlightTheme: HighlightTheme = {
contentClassName: "ph-no-capture text-yellow-200/80"
},
trigger: Trigger = { startTrigger: "${", endTrigger: "}" },
key?: string
) {
super(text, key);
this.__highlightTheme = highlightTheme;
this.__trigger = trigger;
}
static getType(): string {
return "highlight";
}
static clone(node: HighlightNode): HighlightNode {
return new HighlightNode(node.__text, node.__highlightTheme, node.__trigger, node.__key);
}
static importJSON(serializedNode: SerializedHighlightNode): HighlightNode {
return $applyNodeReplacement(new HighlightNode("")).updateFromJSON(serializedNode);
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
dom.style.cursor = "default";
dom.className = this.__highlightTheme.contentClassName;
return dom;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
isTextEntity(): true {
return true;
}
}
export function $createKeywordNode(keyword: string = ""): HighlightNode {
return $applyNodeReplacement(new HighlightNode(keyword));
}
export function $isKeywordNode(node: LexicalNode | null | undefined): boolean {
return node instanceof HighlightNode;
}
type Props = {
contentClassName?: string;
startTrigger?: string;
endTrigger?: string;
};
export const EditorHighlightPlugin = ({
endTrigger = "}",
startTrigger = "${",
contentClassName = "ph-no-capture text-yellow-200/80"
}: Props) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([HighlightNode])) {
throw new Error("HighlightsPlugin: HighlightsNode not registered on editor");
}
}, [editor]);
const createKeywordNode = useCallback((textNode: TextNode): HighlightNode => {
return $applyNodeReplacement(
new HighlightNode(
textNode.getTextContent(),
{ contentClassName },
{ startTrigger, endTrigger }
)
);
}, []);
const getKeywordMatch = useCallback((text: string) => {
for (let i = 0; i < text.length; i += 1) {
if (text.slice(i, i + 2) === startTrigger) {
const closingBracketIndex = text.indexOf(endTrigger, i + 2);
if (closingBracketIndex !== -1) {
return { start: i, end: closingBracketIndex + 1 };
}
return null;
}
}
return null;
}, []);
useLexicalTextEntity<HighlightNode>(getKeywordMatch, HighlightNode, createKeywordNode);
return null;
};
@@ -0,0 +1,22 @@
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalIsTextContentEmpty } from "@lexical/react/useLexicalIsTextContentEmpty";
export const EditorPlaceholderPlugin = ({ placeholder }: { placeholder: string | undefined }) => {
const [editor] = useLexicalComposerContext();
const isEmpty = useLexicalIsTextContentEmpty(editor);
/* Set the placeholder on root. */
useEffect(() => {
const rootElement = editor.getRootElement() as HTMLElement;
if (rootElement) {
if (isEmpty && placeholder) {
rootElement.setAttribute("placeholder", placeholder);
} else {
rootElement.removeAttribute("placeholder");
}
}
}, [editor, isEmpty]); // eslint-disable-line
return null;
};
@@ -0,0 +1,2 @@
export { Editor } from "./Editor";
export { EditorHighlightPlugin } from "./EditorHighlight";
@@ -8,7 +8,7 @@ type Props = {
labelClassName?: string;
};
export const SecretSyncLabel = ({ label, children, className, labelClassName }: Props) => {
export const GenericFieldLabel = ({ label, children, className, labelClassName }: Props) => {
return (
<div className={className}>
<p className={twMerge("text-xs font-medium text-mineshaft-400", labelClassName)}>{label}</p>
@@ -0,0 +1 @@
export * from "./GenericFieldLabel";
@@ -0,0 +1,27 @@
import { ReactNode } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
title: string;
children: ReactNode;
className?: string;
};
export const NoticeBannerV2 = ({ title, children, className }: Props) => {
return (
<div
className={twMerge(
"flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5",
className
)}
>
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
{title}
</div>
{children}
</div>
);
};
@@ -9,12 +9,12 @@ type Props = {
};
export const PageHeader = ({ title, description, children, className }: Props) => (
<div className={twMerge("mb-4", className)}>
<div className={twMerge("mb-4 w-full", className)}>
<div className="flex w-full justify-between">
<div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div>
<div>{children}</div>
<div className="flex items-center">{children}</div>
</div>
<div className="mt-2 text-gray-400">{description}</div>
</div>
@@ -0,0 +1,273 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { faCheck, faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, IconButton, Slider } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type PasswordOptionsType = {
length: number;
useUppercase: boolean;
useLowercase: boolean;
useNumbers: boolean;
useSpecialChars: boolean;
};
type PasswordGeneratorModalProps = {
isOpen: boolean;
onClose: () => void;
onUsePassword?: (password: string) => void;
minLength?: number;
maxLength?: number;
};
const PasswordGeneratorModal = ({
isOpen,
onClose,
onUsePassword,
minLength = 12,
maxLength = 64
}: PasswordGeneratorModalProps) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: "Copy"
});
const [refresh, setRefresh] = useState(false);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptionsType>({
length: minLength,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecialChars: true
});
const modalRef = useRef<HTMLDivElement>(null);
const generatePassword = () => {
const charset = {
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lowercase: "abcdefghijklmnopqrstuvwxyz",
numbers: "0123456789",
specialChars: "-_.~!*"
};
let availableChars = "";
if (passwordOptions.useUppercase) availableChars += charset.uppercase;
if (passwordOptions.useLowercase) availableChars += charset.lowercase;
if (passwordOptions.useNumbers) availableChars += charset.numbers;
if (passwordOptions.useSpecialChars) availableChars += charset.specialChars;
if (availableChars === "") availableChars = charset.lowercase + charset.numbers;
let newPassword = "";
for (let i = 0; i < passwordOptions.length; i += 1) {
const randomIndex = Math.floor(Math.random() * availableChars.length);
newPassword += availableChars[randomIndex];
}
return newPassword;
};
useEffect(() => {
if (isOpen) {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
return () => {};
}, [isOpen, onClose]);
const password = useMemo(() => {
return generatePassword();
}, [passwordOptions, refresh]);
const copyToClipboard = () => {
navigator.clipboard
.writeText(password)
.then(() => {
setCopyText("Copied");
})
.catch(() => {
setCopyText("Copy failed");
});
};
const usePassword = () => {
if (onUsePassword) {
onUsePassword(password);
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div
ref={modalRef}
className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl"
>
<div className="p-6">
<h2 className="mb-1 text-xl font-semibold text-bunker-200">Password Generator</h2>
<p className="mb-6 text-sm text-bunker-400">Generate strong unique passwords</p>
<div className="relative mb-4 rounded-md bg-mineshaft-900 p-4">
<div className="flex items-center justify-between">
<div className="w-4/5 select-all break-all pr-2 font-mono text-lg">{password}</div>
<div className="flex flex-col gap-1">
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={() => setRefresh((prev) => !prev)}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={faRefresh} className="mr-1 h-3 w-3" />
Refresh
</Button>
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={copyToClipboard}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} className="mr-1 h-3 w-3" />
{copyText}
</Button>
</div>
</div>
</div>
<div className="mb-6">
<div className="mb-1 flex items-center justify-between">
<label htmlFor="password-length" className="text-sm text-bunker-300">
Password length: {passwordOptions.length}
</label>
</div>
<Slider
id="password-length"
min={minLength}
max={maxLength}
value={passwordOptions.length}
onChange={(value) => setPasswordOptions({ ...passwordOptions, length: value })}
className="mb-1"
aria-labelledby="password-length-label"
/>
</div>
<div className="mb-6 flex flex-row justify-between gap-2">
<Checkbox
id="useUppercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useUppercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean })
}
>
A-Z
</Checkbox>
<Checkbox
id="useLowercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useLowercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean })
}
>
a-z
</Checkbox>
<Checkbox
id="useNumbers"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useNumbers}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean })
}
>
0-9
</Checkbox>
<Checkbox
id="useSpecialChars"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useSpecialChars}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean })
}
>
-_.~!*
</Checkbox>
</div>
<div className="flex justify-end">
<Button size="sm" colorSchema="primary" variant="outline_bg" onClick={onClose}>
Close
</Button>
{onUsePassword && (
<Button
size="sm"
colorSchema="primary"
variant="outline_bg"
onClick={usePassword}
className="ml-2"
>
Use Password
</Button>
)}
</div>
</div>
</div>
</div>
);
};
export type PasswordGeneratorProps = {
onUsePassword?: (password: string) => void;
isDisabled?: boolean;
minLength?: number;
maxLength?: number;
};
export const PasswordGenerator = ({
onUsePassword,
isDisabled = false,
minLength = 12,
maxLength = 64
}: PasswordGeneratorProps) => {
const [showGenerator, setShowGenerator] = useState(false);
const toggleGenerator = () => {
setShowGenerator(!showGenerator);
};
return (
<>
<IconButton
ariaLabel="generate password"
colorSchema="secondary"
size="sm"
onClick={toggleGenerator}
isDisabled={isDisabled}
className="rounded text-bunker-400 transition-colors duration-150 hover:bg-mineshaft-700 hover:text-bunker-200"
>
<FontAwesomeIcon icon={faKey} />
</IconButton>
<PasswordGeneratorModal
isOpen={showGenerator}
onClose={() => setShowGenerator(false)}
onUsePassword={onUsePassword}
minLength={minLength}
maxLength={maxLength}
/>
</>
);
};
@@ -0,0 +1,2 @@
export type { PasswordGeneratorProps } from "./PasswordGenerator";
export { PasswordGenerator } from "./PasswordGenerator";
@@ -48,7 +48,6 @@ export const SecretPathInput = ({
}, [propValue]);
useEffect(() => {
// update secret path if input is valid
if (
(debouncedInputValue.length > 0 &&
debouncedInputValue[debouncedInputValue.length - 1] === "/") ||
@@ -59,7 +58,6 @@ export const SecretPathInput = ({
}, [debouncedInputValue]);
useEffect(() => {
// filter suggestions based on matching
const searchFragment = debouncedInputValue.split("/").pop() || "";
const filteredSuggestions = folders
.filter((suggestionEntry) =>
@@ -78,7 +76,6 @@ export const SecretPathInput = ({
const validPaths = inputValue.split("/");
validPaths.pop();
// removed trailing slash
const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`;
onChange?.(newValue);
setInputValue(newValue);
@@ -102,7 +99,6 @@ export const SecretPathInput = ({
};
const handleInputChange = (e: any) => {
// propagate event to react-hook-form onChange
if (onChange) {
onChange(e.target.value);
}
@@ -141,7 +137,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)"
}}
>
<div className="h-full w-full flex-col items-center justify-center rounded-md text-white">
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => (
<div
tabIndex={0}
@@ -0,0 +1,237 @@
import {
forwardRef,
InputHTMLAttributes,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from "react";
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
type Props = {
min: number;
max: number;
step?: number;
isDisabled?: boolean;
isRequired?: boolean;
showValue?: boolean;
valuePosition?: "top" | "right";
containerClassName?: string;
trackClassName?: string;
fillClassName?: string;
thumbClassName?: string;
onChange?: (value: number) => void;
onChangeComplete?: (value: number) => void;
};
const sliderTrackVariants = cva("h-1 w-full bg-mineshaft-600 rounded-full relative", {
variants: {
variant: {
default: "",
thin: "h-0.5",
thick: "h-1.5"
},
isDisabled: {
true: "opacity-50 cursor-not-allowed",
false: ""
}
}
});
const sliderFillVariants = cva("absolute h-full rounded-full", {
variants: {
variant: {
default: "bg-primary-500",
secondary: "bg-secondary-500",
danger: "bg-red-500"
},
isDisabled: {
true: "opacity-50",
false: ""
}
}
});
const sliderThumbVariants = cva(
"absolute w-4 h-4 rounded-full shadow transform -translate-x-1/2 -mt-1.5 focus:outline-none",
{
variants: {
variant: {
default: "bg-primary-500 focus:ring-2 focus:ring-primary-400/50",
secondary: "bg-secondary-500 focus:ring-2 focus:ring-secondary-400/50",
danger: "bg-red-500 focus:ring-2 focus:ring-red-400/50"
},
isDisabled: {
true: "opacity-50 cursor-not-allowed",
false: "cursor-pointer"
},
size: {
sm: "w-3 h-3 -mt-1",
md: "w-4 h-4 -mt-1.5",
lg: "w-5 h-5 -mt-2"
}
}
}
);
const sliderContainerVariants = cva("relative inline-flex font-inter", {
variants: {
isFullWidth: {
true: "w-full",
false: ""
}
}
});
export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> &
VariantProps<typeof sliderTrackVariants> &
VariantProps<typeof sliderThumbVariants> &
VariantProps<typeof sliderContainerVariants> &
Props;
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
(
{
className,
containerClassName,
trackClassName,
fillClassName,
thumbClassName,
min = 0,
max = 100,
step = 1,
value,
defaultValue,
isDisabled = false,
isFullWidth = true,
isRequired = false,
showValue = false,
valuePosition = "top",
variant = "default",
size = "md",
onChange,
onChangeComplete,
...props
},
ref
): JSX.Element => {
let initialValue = min;
if (value !== undefined) {
initialValue = Number(value);
} else if (defaultValue !== undefined) {
initialValue = Number(defaultValue);
}
const [currentValue, setCurrentValue] = useState<number>(initialValue);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const percentage = Math.max(0, Math.min(100, ((currentValue - min) / (max - min)) * 100));
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
useEffect(() => {
if (value !== undefined && Number(value) !== currentValue) {
setCurrentValue(Number(value));
}
}, [value, currentValue]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setCurrentValue(newValue);
onChange?.(newValue);
},
[onChange]
);
const handleMouseDown = useCallback(() => {
if (!isDisabled) {
setIsDragging(true);
}
}, [isDisabled]);
const handleChangeComplete = useCallback(() => {
if (isDragging) {
onChangeComplete?.(currentValue);
setIsDragging(false);
}
}, [isDragging, currentValue, onChangeComplete]);
useEffect(() => {
if (isDragging) {
const handleGlobalMouseUp = () => handleChangeComplete();
document.addEventListener("mouseup", handleGlobalMouseUp);
document.addEventListener("touchend", handleGlobalMouseUp);
return () => {
document.removeEventListener("mouseup", handleGlobalMouseUp);
document.removeEventListener("touchend", handleGlobalMouseUp);
};
}
return () => {};
}, [isDragging, handleChangeComplete]);
const ValueDisplay = showValue ? (
<div className="text-xs text-bunker-300">{currentValue}</div>
) : null;
return (
<div
className={twMerge(
sliderContainerVariants({ isFullWidth, className: containerClassName }),
"my-2"
)}
>
{showValue && valuePosition === "top" && ValueDisplay}
<div className="relative flex w-full items-center">
<div
className={twMerge(
sliderTrackVariants({ variant, isDisabled, className: trackClassName })
)}
>
<div
className={twMerge(
sliderFillVariants({ variant, isDisabled, className: fillClassName }),
"left-0"
)}
style={{ width: `${percentage}%` }}
/>
<div
className={twMerge(
sliderThumbVariants({ variant, isDisabled, size, className: thumbClassName })
)}
style={{ left: `${percentage}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={currentValue}
onChange={handleChange}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
disabled={isDisabled}
required={isRequired}
ref={inputRef}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
{...props}
/>
{showValue && valuePosition === "right" && (
<div className="ml-2 text-xs text-bunker-300">{currentValue}</div>
)}
</div>
</div>
);
}
);
Slider.displayName = "Slider";
@@ -0,0 +1,2 @@
export type { SliderProps } from "./Slider";
export { Slider } from "./Slider";
+5
View File
@@ -11,23 +11,28 @@ export * from "./DatePicker";
export * from "./DeleteActionModal";
export * from "./Drawer";
export * from "./Dropdown";
export * from "./Editor";
export * from "./EmailServiceSetupModal";
export * from "./EmptyState";
export * from "./FilterableSelect";
export * from "./FontAwesomeSymbol";
export * from "./FormControl";
export * from "./GenericFieldLabel";
export * from "./HoverCardv2";
export * from "./IconButton";
export * from "./Input";
export * from "./Menu";
export * from "./Modal";
export * from "./NoticeBanner";
export * from "./NoticeBannerV2";
export * from "./PageHeader";
export * from "./Pagination";
export * from "./PasswordGenerator";
export * from "./Popoverv2";
export * from "./SecretInput";
export * from "./Select";
export * from "./Skeleton";
export * from "./Slider";
export * from "./Spinner";
export * from "./Stepper";
export * from "./Switch";
@@ -82,6 +82,15 @@ export enum ProjectPermissionSshHostActions {
IssueHostCert = "issue-host-cert"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
Create = "create",
Edit = "edit",
Delete = "delete",
RotateSecrets = "rotate-secrets"
}
export enum PermissionConditionOperators {
$IN = "$in",
$ALL = "$all",
@@ -177,6 +186,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
export type SecretRotationSubjectFields = {
environment: string;
secretPath: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -206,6 +220,13 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.SecretImports> & SecretImportSubjectFields)
)
]
| [
ProjectPermissionSecretRotationActions,
(
| ProjectPermissionSub.SecretRotation
| (ForcedSubject<ProjectPermissionSub.SecretRotation> & SecretRotationSubjectFields)
)
]
| [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member]
@@ -218,7 +239,6 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [
ProjectPermissionActions,
(
+12 -5
View File
@@ -1,17 +1,19 @@
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { faKey, faLock, faPassport, faUser } from "@fortawesome/free-solid-svg-icons";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AwsConnectionMethod,
AzureAppConfigurationConnectionMethod,
AzureKeyVaultConnectionMethod,
DatabricksConnectionMethod,
GcpConnectionMethod,
GitHubConnectionMethod,
HumanitecConnectionMethod,
MsSqlConnectionMethod,
PostgresConnectionMethod,
TAppConnection
} from "@app/hooks/api/appConnections/types";
import { DatabricksConnectionMethod } from "@app/hooks/api/appConnections/types/databricks-connection";
import { HumanitecConnectionMethod } from "@app/hooks/api/appConnections/types/humanitec-connection";
export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: string }> = {
[AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" },
@@ -26,7 +28,9 @@ export const APP_CONNECTION_MAP: Record<AppConnection, { name: string; image: st
image: "Microsoft Azure.png"
},
[AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" },
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" },
[AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" },
[AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -45,8 +49,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
return { name: "Service Account Impersonation", icon: faUser };
case DatabricksConnectionMethod.ServicePrincipal:
return { name: "Service Principal", icon: faUser };
case HumanitecConnectionMethod.API_TOKEN:
case HumanitecConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
return { name: "Username & Password", icon: faLock };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}
+24
View File
@@ -0,0 +1,24 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
export const SECRET_ROTATION_MAP: Record<SecretRotation, { name: string; image: string }> = {
[SecretRotation.PostgresCredentials]: { name: "PostgreSQL Credentials", image: "Postgres.png" },
[SecretRotation.MsSqlCredentials]: {
name: "Microsoft SQL Server Credentials",
image: "MsSql.png"
}
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
[SecretRotation.PostgresCredentials]: AppConnection.Postgres,
[SecretRotation.MsSqlCredentials]: AppConnection.MsSql
};
export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => {
const now = new Date();
// convert utc rotation time to local datetime
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0)
);
};
@@ -5,5 +5,7 @@ export enum AppConnection {
AzureKeyVault = "azure-key-vault",
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec"
Humanitec = "humanitec",
Postgres = "postgres",
MsSql = "mssql"
}
@@ -3,6 +3,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
export type TAppConnectionOptionBase = {
name: string;
methods: string[];
supportsPlatformManagement?: boolean;
};
export type TAwsConnectionOption = TAppConnectionOptionBase & {
@@ -38,6 +39,14 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Humanitec;
};
export type TPostgresConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Postgres;
};
export type TMsSqlConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.MsSql;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -45,7 +54,9 @@ export type TAppConnectionOption =
| TAzureAppConfigurationConnectionOption
| TAzureKeyVaultConnectionOption
| TDatabricksConnectionOption
| THumanitecConnectionOption;
| THumanitecConnectionOption
| TPostgresConnectionOption
| TMsSqlConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -55,4 +66,6 @@ export type TAppConnectionOptionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption;
[AppConnection.Databricks]: TDatabricksConnectionOption;
[AppConnection.Humanitec]: THumanitecConnectionOption;
[AppConnection.Postgres]: TPostgresConnectionOption;
[AppConnection.MsSql]: TMsSqlConnectionOption;
};
@@ -2,11 +2,11 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum HumanitecConnectionMethod {
API_TOKEN = "api-token"
ApiToken = "api-token"
}
export type THumanitecConnection = TRootAppConnection & { app: AppConnection.Humanitec } & {
method: HumanitecConnectionMethod.API_TOKEN;
method: HumanitecConnectionMethod.ApiToken;
credentials: {
apiToken: string;
};
@@ -1,20 +1,24 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options";
import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection";
import { TDatabricksConnection } from "@app/hooks/api/appConnections/types/databricks-connection";
import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection";
import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humanitec-connection";
import { AppConnection } from "../enums";
import { TAppConnectionOption } from "./app-options";
import { TAwsConnection } from "./aws-connection";
import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection";
import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TGcpConnection } from "./gcp-connection";
import { TGitHubConnection } from "./github-connection";
import { THumanitecConnection } from "./humanitec-connection";
import { TMsSqlConnection } from "./mssql-connection";
import { TPostgresConnection } from "./postgres-connection";
export * from "./aws-connection";
export * from "./azure-app-configuration-connection";
export * from "./azure-key-vault-connection";
export * from "./databricks-connection";
export * from "./gcp-connection";
export * from "./github-connection";
export * from "./humanitec-connection";
export * from "./mssql-connection";
export * from "./postgres-connection";
export type TAppConnection =
| TAwsConnection
@@ -23,7 +27,9 @@ export type TAppConnection =
| TAzureKeyVaultConnection
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection;
| THumanitecConnection
| TPostgresConnection
| TMsSqlConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -35,11 +41,11 @@ export type TAvailableAppConnectionsResponse = { appConnections: TAvailableAppCo
export type TCreateAppConnectionDTO = Pick<
TAppConnection,
"name" | "credentials" | "method" | "app" | "description"
"name" | "credentials" | "method" | "app" | "description" | "isPlatformManagedCredentials"
>;
export type TUpdateAppConnectionDTO = Partial<
Pick<TAppConnection, "name" | "credentials" | "description">
Pick<TAppConnection, "name" | "credentials" | "description" | "isPlatformManagedCredentials">
> & {
connectionId: string;
app: AppConnection;
@@ -58,4 +64,6 @@ export type TAppConnectionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection;
[AppConnection.Databricks]: TDatabricksConnection;
[AppConnection.Humanitec]: THumanitecConnection;
[AppConnection.Postgres]: TPostgresConnection;
[AppConnection.MsSql]: TMsSqlConnection;
};
@@ -0,0 +1,13 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
import { TBaseSqlConnectionCredentials } from "./shared";
export enum MsSqlConnectionMethod {
UsernameAndPassword = "username-and-password"
}
export type TMsSqlConnection = TRootAppConnection & { app: AppConnection.MsSql } & {
method: MsSqlConnectionMethod.UsernameAndPassword;
credentials: TBaseSqlConnectionCredentials;
};
@@ -0,0 +1,13 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
import { TBaseSqlConnectionCredentials } from "./shared";
export enum PostgresConnectionMethod {
UsernameAndPassword = "username-and-password"
}
export type TPostgresConnection = TRootAppConnection & { app: AppConnection.Postgres } & {
method: PostgresConnectionMethod.UsernameAndPassword;
credentials: TBaseSqlConnectionCredentials;
};
@@ -6,4 +6,5 @@ export type TRootAppConnection = {
orgId: string;
createdAt: string;
updatedAt: string;
isPlatformManagedCredentials?: boolean;
};
@@ -0,0 +1 @@
export * from "./sql-connection";
@@ -0,0 +1,9 @@
export type TBaseSqlConnectionCredentials = {
host: string;
port: number;
username: string;
password: string;
database: string;
sslEnabled: boolean;
sslRejectUnauthorized: boolean;
};
+10 -2
View File
@@ -121,7 +121,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.CREATE_APP_CONNECTION]: "Create App Connection",
[EventType.UPDATE_APP_CONNECTION]: "Update App Connection",
[EventType.DELETE_APP_CONNECTION]: "Delete App Connection",
[EventType.GET_SECRET_SYNCS]: "List Secret Syncs",
[EventType.GET_SECRET_SYNCS]: "List secret syncs",
[EventType.GET_SECRET_SYNC]: "Get Secret Sync",
[EventType.CREATE_SECRET_SYNC]: "Create Secret Sync",
[EventType.UPDATE_SECRET_SYNC]: "Update Secret Sync",
@@ -150,7 +150,15 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.KMIP_OPERATION_ACTIVATE]: "KMIP operation activate",
[EventType.KMIP_OPERATION_REVOKE]: "KMIP operation revoke",
[EventType.KMIP_OPERATION_LOCATE]: "KMIP operation locate",
[EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register"
[EventType.KMIP_OPERATION_REGISTER]: "KMIP operation register",
[EventType.GET_SECRET_ROTATIONS]: "List Secret Rotations",
[EventType.GET_SECRET_ROTATION]: "Get Secret Rotation",
[EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS]:
"Get Secret Rotation generated credentials",
[EventType.CREATE_SECRET_ROTATION]: "Create Secret Rotation",
[EventType.UPDATE_SECRET_ROTATION]: "Update Secret Rotation",
[EventType.DELETE_SECRET_ROTATION]: "Delete Secret Rotation",
[EventType.SECRET_ROTATION_ROTATE_SECRETS]: "Secret Rotation secrets rotated"
};
export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = {
+8 -1
View File
@@ -151,5 +151,12 @@ export enum EventType {
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
KMIP_OPERATION_REGISTER = "kmip-operation-register",
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review"
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review",
GET_SECRET_ROTATIONS = "get-secret-rotations",
GET_SECRET_ROTATION = "get-secret-rotation",
GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials",
CREATE_SECRET_ROTATION = "create-secret-rotation",
UPDATE_SECRET_ROTATION = "update-secret-rotation",
DELETE_SECRET_ROTATION = "delete-secret-rotation",
SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets"
}
+52 -10
View File
@@ -132,6 +132,21 @@ export const fetchDashboardProjectSecretsByKeys = async ({
return data;
};
const mergePersonalRotationSecrets = (secrets: (SecretV3Raw | null)[]) => {
const actualSecrets: SecretV3Raw[] = [];
const dummySecrets: null[] = [];
secrets.forEach((secret) => {
if (secret !== null) {
actualSecrets.push(secret);
} else {
dummySecrets.push(secret);
}
});
return [...mergePersonalSecrets(actualSecrets), ...dummySecrets];
};
export const useGetProjectSecretsOverview = (
{
projectId,
@@ -145,6 +160,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}: TGetDashboardProjectSecretsOverviewDTO,
options?: Omit<
@@ -173,6 +189,7 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}),
queryFn: () =>
@@ -188,10 +205,11 @@ export const useGetProjectSecretsOverview = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
environments
}),
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
const { secrets, ...select } = data;
const { secrets, secretRotations, ...select } = data;
const uniqueSecrets = secrets ? unique(secrets, (i) => i.secretKey) : [];
const uniqueFolders = select.folders ? unique(select.folders, (i) => i.name) : [];
@@ -201,14 +219,22 @@ export const useGetProjectSecretsOverview = (
: [];
const uniqueSecretImports = select.imports ? unique(select.imports, (i) => i.id) : [];
const uniqueSecretRotations = secretRotations ? unique(secretRotations, (i) => i.name) : [];
return {
...select,
secrets: secrets ? mergePersonalSecrets(secrets) : undefined,
secretRotations: secretRotations?.map((rotation) => {
return {
...rotation,
secrets: mergePersonalRotationSecrets(rotation.secrets)
};
}),
totalUniqueSecretsInPage: uniqueSecrets.length,
totalUniqueDynamicSecretsInPage: uniqueDynamicSecrets.length,
totalUniqueFoldersInPage: uniqueFolders.length,
totalUniqueSecretImportsInPage: uniqueSecretImports.length
totalUniqueSecretImportsInPage: uniqueSecretImports.length,
totalUniqueSecretRotationsInPage: uniqueSecretRotations.length
};
}, []),
placeholderData: (previousData) => previousData
@@ -230,6 +256,7 @@ export const useGetProjectSecretsDetails = (
viewSecretValue,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}: TGetDashboardProjectSecretsDetailsDTO,
options?: Omit<
@@ -260,6 +287,7 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}),
queryFn: () =>
@@ -277,12 +305,17 @@ export const useGetProjectSecretsDetails = (
includeFolders,
includeImports,
includeDynamicSecrets,
includeSecretRotations,
tags
}),
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
...data,
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined,
secretRotations: data.secretRotations?.map((rotation) => ({
...rotation,
secrets: mergePersonalRotationSecrets(rotation.secrets)
}))
}),
[]
),
@@ -319,12 +352,13 @@ const fetchAccessibleSecrets = async ({
projectId,
secretPath,
environment,
filterByAction
filterByAction,
recursive = false
}: TGetAccessibleSecretsDTO) => {
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
"/api/v1/dashboard/accessible-secrets",
{
params: { projectId, secretPath, environment, filterByAction }
params: { projectId, secretPath, environment, filterByAction, recursive }
}
);
@@ -371,7 +405,7 @@ export const useGetProjectSecretsQuickSearch = (
tags
}),
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsQuickSearch>>) => {
const { secrets, folders, dynamicSecrets } = data;
const { secrets, folders, dynamicSecrets, secretRotations } = data;
const groupedFolders = groupBy(folders, (folder) => folder.path);
const groupedSecrets = groupBy(
@@ -383,11 +417,16 @@ export const useGetProjectSecretsQuickSearch = (
(dynamicSecret) =>
`${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}`
);
const groupedRotations = groupBy(
secretRotations,
(rotation) => `${rotation.folder.path === "/" ? "" : rotation.folder.path}/${rotation.name}`
);
return {
folders: groupedFolders,
secrets: groupedSecrets,
dynamicSecrets: groupedDynamicSecrets
dynamicSecrets: groupedDynamicSecrets,
secretRotations: groupedRotations
};
}, []),
placeholderData: (previousData) => previousData
@@ -399,7 +438,8 @@ export const useGetAccessibleSecrets = ({
secretPath,
environment,
filterByAction,
options
options,
recursive = false
}: TGetAccessibleSecretsDTO & {
options?: Omit<
UseQueryOptions<
@@ -417,8 +457,10 @@ export const useGetAccessibleSecrets = ({
projectId,
secretPath,
environment,
filterByAction
filterByAction,
recursive
}),
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
queryFn: () =>
fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive })
});
};
+21 -1
View File
@@ -3,6 +3,7 @@ import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export type DashboardProjectSecretsOverviewResponse = {
@@ -14,11 +15,16 @@ export type DashboardProjectSecretsOverviewResponse = {
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalImportCount?: number;
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3Raw | null)[];
})[];
totalSecretRotationCount?: number;
totalCount: number;
totalUniqueSecretsInPage: number;
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
totalUniqueSecretRotationsInPage: number;
};
export type DashboardProjectSecretsDetailsResponse = {
@@ -26,10 +32,14 @@ export type DashboardProjectSecretsDetailsResponse = {
folders?: TSecretFolder[];
dynamicSecrets?: TDynamicSecret[];
secrets?: SecretV3Raw[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3Raw | null)[];
})[];
totalImportCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalSecretCount?: number;
totalSecretRotationCount?: number;
totalCount: number;
};
@@ -39,9 +49,12 @@ export type DashboardProjectSecretsByKeys = {
export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets"
"secrets" | "secretRotations"
> & {
secrets?: SecretV3RawSanitized[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
};
export type DashboardProjectSecretsDetails = Omit<
@@ -49,6 +62,9 @@ export type DashboardProjectSecretsDetails = Omit<
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
secretRotations?: (TSecretRotationV2 & {
secrets: (SecretV3RawSanitized | null)[];
})[];
};
export enum DashboardSecretsOrderBy {
@@ -67,6 +83,7 @@ export type TGetDashboardProjectSecretsOverviewDTO = {
includeFolders?: boolean;
includeDynamicSecrets?: boolean;
includeImports?: boolean;
includeSecretRotations?: boolean;
environments: string[];
};
@@ -83,6 +100,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
export type TDashboardProjectSecretsQuickSearchResponse = {
folders: (TSecretFolder & { envId: string; path: string })[];
dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[];
secretRotations: TSecretRotationV2[];
secrets: SecretV3Raw[];
};
@@ -90,6 +108,7 @@ export type TDashboardProjectSecretsQuickSearch = {
folders: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
secrets: Record<string, SecretV3RawSanitized[]>;
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["dynamicSecrets"]>;
secretRotations: Record<string, TDashboardProjectSecretsQuickSearchResponse["secretRotations"]>;
};
export type TGetDashboardProjectSecretsQuickSearchDTO = {
@@ -111,6 +130,7 @@ export type TGetAccessibleSecretsDTO = {
projectId: string;
secretPath: string;
environment: string;
recursive?: boolean;
filterByAction:
| ProjectPermissionSecretActions.DescribeSecret
| ProjectPermissionSecretActions.ReadValue;
+2 -1
View File
@@ -46,5 +46,6 @@ export {
useGetIdentityTokenAuth,
useGetIdentityTokensTokenAuth,
useGetIdentityUniversalAuth,
useGetIdentityUniversalAuthClientSecrets
useGetIdentityUniversalAuthClientSecrets,
useSearchIdentities
} from "./queries";
+23 -1
View File
@@ -15,11 +15,13 @@ import {
IdentityMembershipOrg,
IdentityOidcAuth,
IdentityTokenAuth,
IdentityUniversalAuth
IdentityUniversalAuth,
TSearchIdentitiesDTO
} from "./types";
export const identitiesKeys = {
getIdentityById: (identityId: string) => [{ identityId }, "identity"] as const,
searchIdentities: (dto: TSearchIdentitiesDTO) => ["identity", "search", dto] as const,
getIdentityUniversalAuth: (identityId: string) =>
[{ identityId }, "identity-universal-auth"] as const,
getIdentityUniversalAuthClientSecrets: (identityId: string) =>
@@ -53,6 +55,26 @@ export const useGetIdentityById = (identityId: string) => {
});
};
export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => {
const { limit, search, offset, orderBy, orderDirection } = dto;
return useQuery({
queryKey: identitiesKeys.searchIdentities(dto),
queryFn: async () => {
const { data } = await apiRequest.post<{
identities: IdentityMembershipOrg[];
totalCount: number;
}>("/api/v1/identities/search", {
limit,
offset,
orderBy,
orderDirection,
search
});
return data;
}
});
};
export const useGetIdentityProjectMemberships = (identityId: string) => {
return useQuery({
enabled: Boolean(identityId),
@@ -1,3 +1,5 @@
import { OrderByDirection } from "../generic/types";
import { OrgIdentityOrderBy } from "../organization/types";
import { TOrgRole } from "../roles/types";
import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types";
import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums";
@@ -540,3 +542,14 @@ export type TProjectIdentitiesList = {
identityMemberships: IdentityMembership[];
totalCount: number;
};
export type TSearchIdentitiesDTO = {
limit?: number;
offset?: number;
orderBy?: OrgIdentityOrderBy;
orderDirection?: OrderByDirection;
search: {
name?: { $contains: string };
role?: { $in: string[] };
};
};

Some files were not shown because too many files have changed in this diff Show More