Merge pull request #793 from akhilmhdh/feat/new-overview-page

Feat/new overview page
This commit is contained in:
Maidul Islam
2023-07-27 15:42:34 -04:00
committed by GitHub
60 changed files with 4797 additions and 3648 deletions
@@ -108,7 +108,7 @@ export const getAllSecretsFromImport = async (req: Request, res: Response) => {
}); });
if (!importSecDoc) { if (!importSecDoc) {
return res.status(200).json({ secrets: {} }); return res.status(200).json({ secrets: [] });
} }
const secrets = await getAllImportedSecrets(workspaceId, environment, folderId); const secrets = await getAllImportedSecrets(workspaceId, environment, folderId);
+2
View File
@@ -346,6 +346,7 @@ export const createSecretHelper = async ({
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
folder: folderId, folder: folderId,
type, type,
environment,
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
}); });
@@ -362,6 +363,7 @@ export const createSecretHelper = async ({
secretBlindIndex, secretBlindIndex,
folder: folderId, folder: folderId,
workspace: new Types.ObjectId(workspaceId), workspace: new Types.ObjectId(workspaceId),
environment,
type: SECRET_SHARED type: SECRET_SHARED
}); });
+322 -12
View File
@@ -67,6 +67,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-code-input": "^3.10.1", "react-code-input": "^3.10.1",
"react-contenteditable": "^3.3.7",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-grid-layout": "^1.3.4", "react-grid-layout": "^1.3.4",
"react-hook-form": "^7.43.0", "react-hook-form": "^7.43.0",
@@ -75,6 +76,7 @@
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-redux": "^8.0.2", "react-redux": "^8.0.2",
"react-table": "^7.8.0", "react-table": "^7.8.0",
"sanitize-html": "^2.11.0",
"set-cookie-parser": "^2.5.1", "set-cookie-parser": "^2.5.1",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"styled-components": "^5.3.7", "styled-components": "^5.3.7",
@@ -99,6 +101,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "18.11.9", "@types/node": "18.11.9",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
@@ -7922,6 +7925,89 @@
"redux": "^4.0.0" "redux": "^4.0.0"
} }
}, },
"node_modules/@types/sanitize-html": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
"dev": true,
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dev": true,
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/@types/scheduler": { "node_modules/@types/scheduler": {
"version": "0.16.3", "version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
@@ -10745,7 +10831,6 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11203,7 +11288,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -12690,8 +12774,7 @@
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"dev": true
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
"version": "1.3.0", "version": "1.3.0",
@@ -17860,6 +17943,11 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -18113,7 +18201,6 @@
"version": "8.4.23", "version": "8.4.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz",
"integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -18936,6 +19023,18 @@
"react-dom": ">=16.8.0" "react-dom": ">=16.8.0"
} }
}, },
"node_modules/react-contenteditable": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz",
"integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"prop-types": "^15.7.1"
},
"peerDependencies": {
"react": ">=16.3"
}
},
"node_modules/react-docgen": { "node_modules/react-docgen": {
"version": "5.4.3", "version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",
@@ -20017,6 +20116,88 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true "dev": true
}, },
"node_modules/sanitize-html": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/sass-loader": { "node_modules/sass-loader": {
"version": "12.6.0", "version": "12.6.0",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
@@ -28425,6 +28606,66 @@
"redux": "^4.0.0" "redux": "^4.0.0"
} }
}, },
"@types/sanitize-html": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
"integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
"dev": true,
"requires": {
"htmlparser2": "^8.0.0"
},
"dependencies": {
"dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
}
},
"domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"requires": {
"domelementtype": "^2.3.0"
}
},
"domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dev": true,
"requires": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
}
},
"entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true
},
"htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"dev": true,
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
}
}
},
"@types/scheduler": { "@types/scheduler": {
"version": "0.16.3", "version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
@@ -30633,8 +30874,7 @@
"deepmerge": { "deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
"dev": true
}, },
"default-browser": { "default-browser": {
"version": "4.0.0", "version": "4.0.0",
@@ -30963,8 +31203,7 @@
"domelementtype": { "domelementtype": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
"dev": true
}, },
"domhandler": { "domhandler": {
"version": "4.3.1", "version": "4.3.1",
@@ -32126,8 +32365,7 @@
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"dev": true
}, },
"fast-diff": { "fast-diff": {
"version": "1.3.0", "version": "1.3.0",
@@ -35865,6 +36103,11 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"parseurl": { "parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -36081,7 +36324,6 @@
"version": "8.4.23", "version": "8.4.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz",
"integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==",
"dev": true,
"requires": { "requires": {
"nanoid": "^3.3.6", "nanoid": "^3.3.6",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -36653,6 +36895,15 @@
"dev": true, "dev": true,
"requires": {} "requires": {}
}, },
"react-contenteditable": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz",
"integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==",
"requires": {
"fast-deep-equal": "^3.1.3",
"prop-types": "^15.7.1"
}
},
"react-docgen": { "react-docgen": {
"version": "5.4.3", "version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-5.4.3.tgz",
@@ -37421,6 +37672,65 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true "dev": true
}, },
"sanitize-html": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
"integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
"requires": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"dependencies": {
"dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
}
},
"domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"requires": {
"domelementtype": "^2.3.0"
}
},
"domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"requires": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
}
},
"entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
},
"htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"requires": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
}
}
},
"sass-loader": { "sass-loader": {
"version": "12.6.0", "version": "12.6.0",
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
+3
View File
@@ -75,6 +75,7 @@
"react": "^17.0.2", "react": "^17.0.2",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-code-input": "^3.10.1", "react-code-input": "^3.10.1",
"react-contenteditable": "^3.3.7",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-grid-layout": "^1.3.4", "react-grid-layout": "^1.3.4",
"react-hook-form": "^7.43.0", "react-hook-form": "^7.43.0",
@@ -83,6 +84,7 @@
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-redux": "^8.0.2", "react-redux": "^8.0.2",
"react-table": "^7.8.0", "react-table": "^7.8.0",
"sanitize-html": "^2.11.0",
"set-cookie-parser": "^2.5.1", "set-cookie-parser": "^2.5.1",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"styled-components": "^5.3.7", "styled-components": "^5.3.7",
@@ -107,6 +109,7 @@
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "18.11.9", "@types/node": "18.11.9",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
@@ -58,15 +58,13 @@ export default function NavHeader({
return ( return (
<div className="flex flex-row items-center pt-6"> <div className="flex flex-row items-center pt-6">
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-black text-sm"> <div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black">
{currentOrg?.name?.charAt(0)} {currentOrg?.name?.charAt(0)}
</div> </div>
<Link <Link passHref legacyBehavior href={`/org/${currentOrg?._id}/overview`}>
passHref <a className="pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
legacyBehavior {currentOrg?.name}
href={`/org/${currentOrg?._id}/overview`} </a>
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary pl-0.5">{currentOrg?.name}</a>
</Link> </Link>
{isProjectRelated && ( {isProjectRelated && (
<> <>
@@ -85,7 +83,7 @@ export default function NavHeader({
<Link <Link
passHref passHref
legacyBehavior legacyBehavior
href={{ pathname: "/project/[id]/secrets", query: { id: router.query.id } }} href={{ pathname: "/project/[id]/secrets/overview", query: { id: router.query.id } }}
> >
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a> <a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
</Link> </Link>
@@ -126,7 +124,11 @@ export default function NavHeader({
{index + 1 === folders?.length ? ( {index + 1 === folders?.length ? (
<span className="text-sm font-semibold text-bunker-300">{name}</span> <span className="text-sm font-semibold text-bunker-300">{name}</span>
) : ( ) : (
<Link passHref legacyBehavior href={{ pathname: "/project/[id]/secrets", query }}> <Link
passHref
legacyBehavior
href={{ pathname: "/project/[id]/secrets/[env]", query }}
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary"> <a className="text-sm font-semibold text-primary/80 hover:text-primary">
{name === "root" ? selectedEnv?.name : name} {name === "root" ? selectedEnv?.name : name}
</a> </a>
@@ -0,0 +1,92 @@
/* eslint-disable react/no-danger */
import { HTMLAttributes } from "react";
import ContentEditable from "react-contenteditable";
import sanitizeHtml from "sanitize-html";
import { useToggle } from "@app/hooks";
const REGEX = /\${([^}]+)}/g;
const stripSpanTags = (str: string) => str.replace(/<\/?span[^>]*>/g, "");
const replaceContentWithDot = (str: string) => {
let finalStr = "";
let isHtml = false;
for (let i = 0; i < str.length; i += 1) {
const char = str.at(i);
if (char === "<" || char === ">") {
isHtml = char === "<";
finalStr += char;
} else if (!isHtml && char !== "\n") {
finalStr += "&#8226;";
} else {
finalStr += char;
}
}
return finalStr;
};
const syntaxHighlight = (orgContent?: string | null, isVisible?: boolean) => {
if (!orgContent) return "";
if (!isVisible) return replaceContentWithDot(orgContent);
const content = stripSpanTags(orgContent);
const newContent = content.replace(
REGEX,
(_a, b) =>
`<span class="ph-no-capture text-yellow">&#36;&#123;<span class="ph-no-capture text-yello-200/80">${b}</span>&#125;</span>`
);
return newContent;
};
const sanitizeConf = {
allowedTags: ["div", "span", "br", "p"]
};
type Props = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onBlur"> & {
value?: string | null;
isVisible?: boolean;
isDisabled?: boolean;
onChange?: (val: string, html: string) => void;
onBlur?: (sanitizedHtml: string) => void;
};
export const SecretInput = ({
value,
isVisible,
onChange,
onBlur,
isDisabled,
...props
}: Props) => {
const [isSecretFocused, setIsSecretFocused] = useToggle();
return (
<div
className="thin-scrollbar relative overflow-y-auto overflow-x-hidden"
style={{ maxHeight: `${21 * 7}px` }}
>
<div
dangerouslySetInnerHTML={{
__html: syntaxHighlight(value, isVisible || isSecretFocused)
}}
className="absolute top-0 left-0 z-0 h-full w-full text-ellipsis whitespace-pre-line break-all"
/>
<ContentEditable
className="relative z-10 h-full w-full text-ellipsis whitespace-pre-line break-all text-transparent caret-white outline-none"
role="textbox"
onChange={(evt) => {
if (onChange) onChange(evt.currentTarget.innerText.trim(), evt.currentTarget.innerHTML);
}}
onFocus={() => setIsSecretFocused.on()}
disabled={isDisabled}
spellCheck={false}
onBlur={(evt) => {
if (onBlur) onBlur(sanitizeHtml(evt.currentTarget.innerHTML || "", sanitizeConf));
setIsSecretFocused.off();
}}
html={isVisible || isSecretFocused ? value || "" : syntaxHighlight(value, false)}
{...props}
/>
</div>
);
};
@@ -0,0 +1 @@
export { SecretInput } from "./SecretInput";
@@ -52,7 +52,7 @@ export const Loading: Story = {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
<TableSkeleton columns={3} key="story-book-table" /> <TableSkeleton columns={3} innerKey="story-book-table" />
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
+28 -11
View File
@@ -33,10 +33,7 @@ export type TableProps = {
export const Table = ({ children, className }: TableProps): JSX.Element => ( export const Table = ({ children, className }: TableProps): JSX.Element => (
<table <table
className={twMerge( className={twMerge("w-full bg-mineshaft-800 p-2 text-left text-sm text-gray-300", className)}
"w-full bg-mineshaft-800 p-2 text-left text-sm text-gray-300",
className
)}
> >
{children} {children}
</table> </table>
@@ -58,11 +55,24 @@ export const THead = ({ children, className }: THeadProps): JSX.Element => (
export type TrProps = { export type TrProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
isHoverable?: boolean;
isSelectable?: boolean;
} & HTMLAttributes<HTMLTableRowElement>; } & HTMLAttributes<HTMLTableRowElement>;
export const Tr = ({ children, className, ...props }: TrProps): JSX.Element => ( export const Tr = ({
children,
className,
isHoverable,
isSelectable,
...props
}: TrProps): JSX.Element => (
<tr <tr
className={twMerge("border border-solid border-mineshaft-700 cursor-default", className)} className={twMerge(
"cursor-default border border-solid border-mineshaft-700",
isHoverable && "hover:bg-mineshaft-600",
isSelectable && "cursor-pointer",
className
)}
{...props} {...props}
> >
{children} {children}
@@ -76,7 +86,14 @@ export type ThProps = {
}; };
export const Th = ({ children, className }: ThProps): JSX.Element => ( export const Th = ({ children, className }: ThProps): JSX.Element => (
<th className={twMerge("bg-mineshaft-800 px-5 pt-4 pb-3.5 font-semibold border-b-2 border-mineshaft-600", className)}>{children}</th> <th
className={twMerge(
"border-b-2 border-mineshaft-600 bg-mineshaft-800 px-5 pt-4 pb-3.5 font-semibold",
className
)}
>
{children}
</th>
); );
// table body // table body
@@ -106,15 +123,15 @@ export type TBodyLoader = {
columns: number; columns: number;
className?: string; className?: string;
// unique key for mapping // unique key for mapping
key: string; innerKey: string;
}; };
export const TableSkeleton = ({ rows = 3, columns, key, className }: TBodyLoader): JSX.Element => ( export const TableSkeleton = ({ rows = 3, columns, innerKey, className }: TBodyLoader): JSX.Element => (
<> <>
{Array.apply(0, Array(rows)).map((_x, i) => ( {Array.apply(0, Array(rows)).map((_x, i) => (
<Tr key={`${key}-skeleton-rows-${i + 1}`}> <Tr key={`${innerKey}-skeleton-rows-${i + 1}`}>
{Array.apply(0, Array(columns)).map((_y, j) => ( {Array.apply(0, Array(columns)).map((_y, j) => (
<Td key={`${key}-skeleton-rows-${i + 1}-column-${j + 1}`}> <Td key={`${innerKey}-skeleton-rows-${i + 1}-column-${j + 1}`}>
<Skeleton className={className} /> <Skeleton className={className} />
</Td> </Td>
))} ))}
+2 -1
View File
@@ -4,7 +4,7 @@ export * from "./Checkbox";
export * from "./DeleteActionModal"; export * from "./DeleteActionModal";
export * from "./Drawer"; export * from "./Drawer";
export * from "./Dropdown"; export * from "./Dropdown";
export * from "./EmailServiceSetupModal" export * from "./EmailServiceSetupModal";
export * from "./EmptyState"; export * from "./EmptyState";
export * from "./FormControl"; export * from "./FormControl";
export * from "./HoverCardv2"; export * from "./HoverCardv2";
@@ -13,6 +13,7 @@ export * from "./Input";
export * from "./Menu"; export * from "./Menu";
export * from "./Modal"; export * from "./Modal";
export * from "./Popoverv2"; export * from "./Popoverv2";
export * from "./SecretInput";
export * from "./Select"; export * from "./Select";
export * from "./Skeleton"; export * from "./Skeleton";
export * from "./Spinner"; export * from "./Spinner";
@@ -1,6 +1,7 @@
export { export {
useCreateFolder, useCreateFolder,
useDeleteFolder, useDeleteFolder,
useGetFoldersByEnv,
useGetProjectFolders, useGetProjectFolders,
useGetProjectFoldersBatch, useGetProjectFoldersBatch,
useUpdateFolder useUpdateFolder
@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@@ -9,6 +9,7 @@ import {
DeleteFolderDTO, DeleteFolderDTO,
GetProjectFoldersBatchDTO, GetProjectFoldersBatchDTO,
GetProjectFoldersDTO, GetProjectFoldersDTO,
TGetFoldersByEnvDTO,
TSecretFolder, TSecretFolder,
UpdateFolderDTO UpdateFolderDTO
} from "./types"; } from "./types";
@@ -62,6 +63,48 @@ export const useGetProjectFolders = ({
) )
}); });
export const useGetFoldersByEnv = ({
parentFolderPath,
workspaceId,
environments,
parentFolderId
}: TGetFoldersByEnvDTO) => {
const folders = useQueries({
queries: environments.map((env) => ({
queryKey: queryKeys.getSecretFolders(workspaceId, env, parentFolderPath || parentFolderId),
queryFn: async () => fetchProjectFolders(workspaceId, env, parentFolderId, parentFolderPath),
enabled: Boolean(workspaceId) && Boolean(env)
}))
});
const folderNames = useMemo(() => {
const names = new Set<string>();
folders?.forEach(({ data }) => {
data?.folders.forEach(({ name }) => {
names.add(name);
});
});
return [...names];
}, [(folders || []).map((folder) => folder.data)]);
const isFolderPresentInEnv = useCallback(
(name: string, env: string) => {
const selectedEnvIndex = environments.indexOf(env);
if (selectedEnvIndex !== -1) {
return Boolean(
folders?.[selectedEnvIndex]?.data?.folders?.find(
({ name: folderName }) => folderName === name
)
);
}
return false;
},
[(folders || []).map((folder) => folder.data)]
);
return { folders, folderNames, isFolderPresentInEnv };
};
export const useGetProjectFoldersBatch = ({ export const useGetProjectFoldersBatch = ({
folders = [], folders = [],
isPaused, isPaused,
@@ -17,6 +17,13 @@ export type GetProjectFoldersBatchDTO = {
parentFolderPath?: string; parentFolderPath?: string;
}; };
export type TGetFoldersByEnvDTO = {
environments: string[];
workspaceId: string;
parentFolderPath?: string;
parentFolderId?: string;
};
export type CreateFolderDTO = { export type CreateFolderDTO = {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
+2 -1
View File
@@ -1,6 +1,7 @@
export { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "./mutations";
export { export {
useBatchSecretsOp, useBatchSecretsOp,
useGetProjectSecrets, useGetProjectSecrets,
useGetProjectSecretsByKey, useGetProjectSecretsAllEnv,
useGetSecretVersion useGetSecretVersion
} from "./queries"; } from "./queries";
@@ -0,0 +1,172 @@
import crypto from "crypto";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
decryptAssymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import { secretKeys } from "./queries";
import { TCreateSecretsV3DTO, TDeleteSecretsV3DTO, TUpdateSecretsV3DTO } from "./types";
const encryptSecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
// encrypt key
const {
ciphertext: secretKeyCiphertext,
iv: secretKeyIV,
tag: secretKeyTag
} = encryptSymmetric({
plaintext: key,
key: randomBytes
});
// encrypt value
const {
ciphertext: secretValueCiphertext,
iv: secretValueIV,
tag: secretValueTag
} = encryptSymmetric({
plaintext: value ?? "",
key: randomBytes
});
// encrypt comment
const {
ciphertext: secretCommentCiphertext,
iv: secretCommentIV,
tag: secretCommentTag
} = encryptSymmetric({
plaintext: comment ?? "",
key: randomBytes
});
return {
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag
};
};
export const useCreateSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretsV3DTO>({
mutationFn: async ({
secretPath = "/",
type,
environment,
workspaceId,
secretName,
secretValue,
latestFileKey,
secretComment
}) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const randomBytes = latestFileKey
? decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
})
: crypto.randomBytes(16).toString("hex");
const reqBody = {
workspaceId,
environment,
type,
secretPath,
...encryptSecret(randomBytes, secretName, secretValue, secretComment)
};
const { data } = await apiRequest.post(`/api/v3/secrets/${secretName}`, reqBody);
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
export const useUpdateSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretsV3DTO>({
mutationFn: async ({
secretPath = "/",
type,
environment,
workspaceId,
secretName,
secretValue,
latestFileKey
}) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const randomBytes = latestFileKey
? decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
})
: crypto.randomBytes(16).toString("hex");
const { secretValueIV, secretValueTag, secretValueCiphertext } = encryptSecret(
randomBytes,
secretName,
secretValue,
""
);
const reqBody = {
workspaceId,
environment,
type,
secretPath,
secretValueIV,
secretValueTag,
secretValueCiphertext
};
const { data } = await apiRequest.patch(`/api/v3/secrets/${secretName}`, reqBody);
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
export const useDeleteSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TDeleteSecretsV3DTO>({
mutationFn: async ({ secretPath = "/", type, environment, workspaceId, secretName }) => {
const reqBody = {
workspaceId,
environment,
type,
secretPath
};
const { data } = await apiRequest.delete(`/api/v3/secrets/${secretName}`, {
data: reqBody
});
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
+71 -70
View File
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
decryptAssymmetric, decryptAssymmetric,
@@ -15,7 +15,8 @@ import {
EncryptedSecret, EncryptedSecret,
EncryptedSecretVersion, EncryptedSecretVersion,
GetProjectSecretsDTO, GetProjectSecretsDTO,
GetSecretVersionsDTO GetSecretVersionsDTO,
TGetProjectSecretsAllEnvDTO
} from "./types"; } from "./types";
export const secretKeys = { export const secretKeys = {
@@ -37,40 +38,15 @@ const fetchProjectEncryptedSecrets = async (
folderId?: string, folderId?: string,
secretPath?: string secretPath?: string
) => { ) => {
if (typeof env === "string") { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", { params: {
params: { environment: env,
environment: env, workspaceId,
workspaceId, folderId: folderId || undefined,
folderId: folderId || undefined, secretPath
secretPath
}
});
return data.secrets;
}
if (typeof env === "object") {
let allEnvData: any = [];
// eslint-disable-next-line no-restricted-syntax
for (const envPoint of env) {
// eslint-disable-next-line no-await-in-loop
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", {
params: {
environment: envPoint,
workspaceId,
folderId,
secretPath
}
});
allEnvData = allEnvData.concat(data.secrets);
} }
});
return allEnvData; return data.secrets;
// eslint-disable-next-line no-else-return
} else {
return null;
}
}; };
export const useGetProjectSecrets = ({ export const useGetProjectSecrets = ({
@@ -160,22 +136,19 @@ export const useGetProjectSecrets = ({
) )
}); });
export const useGetProjectSecretsByKey = ({ export const useGetProjectSecretsAllEnv = ({
workspaceId, workspaceId,
env, envs,
decryptFileKey, decryptFileKey,
isPaused,
folderId, folderId,
secretPath secretPath
}: GetProjectSecretsDTO) => }: TGetProjectSecretsAllEnvDTO) => {
useQuery({ const secrets = useQueries({
// wait for all values to be available queries: envs.map((env) => ({
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath || folderId),
// right now secretpath is passed as folderid as only this is used in overview enabled: Boolean(decryptFileKey && workspaceId && env),
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath), queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath), select: (data: EncryptedSecret[]) => {
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey; const latestKey = decryptFileKey;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
@@ -185,12 +158,11 @@ export const useGetProjectSecretsByKey = ({
privateKey: PRIVATE_KEY privateKey: PRIVATE_KEY
}); });
const sharedSecrets: Record<string, DecryptedSecret[]> = {}; const sharedSecrets: Record<string, DecryptedSecret> = {};
const personalSecrets: Record<string, { id: string; value: string }> = {}; const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard // this used for add-only mode in dashboard
// type won't be there thus only one key is shown // type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {}; const duplicateSecretKey: Record<string, boolean> = {};
const uniqSecKeys: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => { data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({ const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext, ciphertext: encSecret.secretKeyCiphertext,
@@ -198,7 +170,6 @@ export const useGetProjectSecretsByKey = ({
tag: encSecret.secretKeyTag, tag: encSecret.secretKeyTag,
key key
}); });
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
const secretValue = decryptSymmetric({ const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext, ciphertext: encSecret.secretValueCiphertext,
@@ -226,35 +197,65 @@ export const useGetProjectSecretsByKey = ({
}; };
if (encSecret.type === "personal") { if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { personalSecrets[decryptedSecret.key] = {
id: encSecret._id, id: encSecret._id,
value: secretValue value: secretValue
}; };
} else { } else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { if (!duplicateSecretKey?.[decryptedSecret.key]) {
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = []; sharedSecrets[decryptedSecret.key] = decryptedSecret;
sharedSecrets[secretKey].push(decryptedSecret);
} }
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true; duplicateSecretKey[decryptedSecret.key] = true;
} }
}); });
Object.keys(sharedSecrets).forEach((secName) => {
sharedSecrets[secName].forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
}
});
});
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length }; Object.keys(sharedSecrets).forEach((val) => {
}, if (personalSecrets?.[val]) {
[decryptFileKey] sharedSecrets[val].idOverride = personalSecrets[val].id;
) sharedSecrets[val].valueOverride = personalSecrets[val].value;
sharedSecrets[val].overrideAction = "modified";
}
});
return sharedSecrets;
}
}))
}); });
const secKeys = useMemo(() => {
const keys = new Set<string>();
secrets?.forEach(({ data }) => {
// TODO(akhilmhdh): find out why this is unknown
Object.keys(data || {}).forEach((key) => keys.add(key));
});
return [...keys];
}, [(secrets || []).map((sec) => sec.data)]);
const getEnvSecretKeyCount = useCallback(
(env: string) => {
const selectedEnvIndex = envs.indexOf(env);
if (selectedEnvIndex !== -1) {
return Object.keys(secrets[selectedEnvIndex]?.data || {}).length;
}
return 0;
},
[(secrets || []).map((sec) => sec.data)]
);
const getSecretByKey = useCallback(
(env: string, key: string) => {
const selectedEnvIndex = envs.indexOf(env);
if (selectedEnvIndex !== -1) {
const sec = secrets[selectedEnvIndex]?.data?.[key];
return sec;
}
return undefined;
},
[(secrets || []).map((sec) => sec.data)]
);
return { data: secrets, secKeys, getSecretByKey, getEnvSecretKeyCount };
};
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>( const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>(
`/api/v1/secret/${secretId}/secret-versions`, `/api/v1/secret/${secretId}/secret-versions`,
+38
View File
@@ -103,9 +103,47 @@ export type GetProjectSecretsDTO = {
onSuccess?: (data: DecryptedSecret[]) => void; onSuccess?: (data: DecryptedSecret[]) => void;
}; };
export type TGetProjectSecretsAllEnvDTO = {
workspaceId: string;
envs: string[];
decryptFileKey: UserWsKeyPair;
folderId?: string;
secretPath?: string;
isPaused?: boolean;
};
export type GetSecretVersionsDTO = { export type GetSecretVersionsDTO = {
secretId: string; secretId: string;
limit: number; limit: number;
offset: number; offset: number;
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
}; };
export type TCreateSecretsV3DTO = {
latestFileKey: UserWsKeyPair;
secretName: string;
secretValue: string;
secretComment: string;
secretPath: string;
workspaceId: string;
environment: string;
type: string;
};
export type TUpdateSecretsV3DTO = {
latestFileKey: UserWsKeyPair;
workspaceId: string;
environment: string;
type: string;
secretPath: string;
secretName: string;
secretValue: string;
};
export type TDeleteSecretsV3DTO = {
workspaceId: string;
environment: string;
type: string;
secretPath: string;
secretName: string;
};
+335 -254
View File
@@ -13,7 +13,18 @@ import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
import { faAngleDown, faArrowLeft, faArrowUpRightFromSquare, faBook, faCheck, faEnvelope, faInfinity, faMobile, faPlus, faQuestion } from "@fortawesome/free-solid-svg-icons"; import {
faAngleDown,
faArrowLeft,
faArrowUpRightFromSquare,
faBook,
faCheck,
faEnvelope,
faInfinity,
faMobile,
faPlus,
faQuestion
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
@@ -41,7 +52,14 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useGetOrgTrialUrl, useLogoutUser, useUploadWsKey } from "@app/hooks/api"; import {
fetchOrgUsers,
useAddUserToWs,
useCreateWorkspace,
useGetOrgTrialUrl,
useLogoutUser,
useUploadWsKey
} from "@app/hooks/api";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -89,7 +107,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
// const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true); // const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true);
const isAddingProjectsAllowed = subscription?.workspaceLimit ? (subscription.workspacesUsed < subscription.workspaceLimit) : true; const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const createWs = useCreateWorkspace(); const createWs = useCreateWorkspace();
const uploadWsKey = useUploadWsKey(); const uploadWsKey = useUploadWsKey();
@@ -110,22 +130,22 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const handleRouteChange = () => { const handleRouteChange = () => {
(window).Intercom("update"); window.Intercom("update");
}; };
router.events.on("routeChangeComplete", handleRouteChange); router.events.on("routeChangeComplete", handleRouteChange);
return () => { return () => {
router.events.off("routeChangeComplete", handleRouteChange); router.events.off("routeChangeComplete", handleRouteChange);
}; };
}, []); }, []);
const logout = useLogoutUser(); const logout = useLogoutUser();
const logOutUser = async () => { const logOutUser = async () => {
try { try {
console.log("Logging out...") console.log("Logging out...");
await logout.mutateAsync(); await logout.mutateAsync();
localStorage.removeItem("protectedKey"); localStorage.removeItem("protectedKey");
localStorage.removeItem("protectedKeyIV"); localStorage.removeItem("protectedKeyIV");
@@ -145,25 +165,28 @@ export const AppLayout = ({ children }: LayoutProps) => {
const changeOrg = async (orgId) => { const changeOrg = async (orgId) => {
localStorage.setItem("orgData.id", orgId); localStorage.setItem("orgData.id", orgId);
router.push(`/org/${orgId}/overview`) router.push(`/org/${orgId}/overview`);
} };
// TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid // TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid
// Placing the localstorage as much as possible // Placing the localstorage as much as possible
// Wait till tony integrates the azure and its launched // Wait till tony integrates the azure and its launched
useEffect(() => { useEffect(() => {
// Put a user in an org if they're not in one yet // Put a user in an org if they're not in one yet
const putUserInOrg = async () => { const putUserInOrg = async () => {
if (tempLocalStorage("orgData.id") === "") { if (tempLocalStorage("orgData.id") === "") {
localStorage.setItem("orgData.id", orgs[0]?._id); localStorage.setItem("orgData.id", orgs[0]?._id);
} }
if (currentOrg && ( if (
(workspaces?.length === 0 && router.asPath.includes("project")) currentOrg &&
|| router.asPath.includes("/project/undefined") ((workspaces?.length === 0 && router.asPath.includes("project")) ||
|| (!orgs?.map(org => org._id)?.includes(router.query.id) && !router.asPath.includes("project") && !router.asPath.includes("personal") && !router.asPath.includes("integration")) router.asPath.includes("/project/undefined") ||
)) { (!orgs?.map((org) => org._id)?.includes(router.query.id) &&
!router.asPath.includes("project") &&
!router.asPath.includes("personal") &&
!router.asPath.includes("integration")))
) {
router.push(`/org/${currentOrg?._id}/overview`); router.push(`/org/${currentOrg?._id}/overview`);
} }
// else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) { // else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) {
@@ -233,7 +256,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
} }
createNotification({ text: "Workspace created", type: "success" }); createNotification({ text: "Workspace created", type: "success" });
handlePopUpClose("addNewWs"); handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets`); router.push(`/project/${newWorkspaceId}/secrets/overview`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" }); createNotification({ text: "Failed to create workspace", type: "error" });
@@ -244,190 +267,235 @@ export const AppLayout = ({ children }: LayoutProps) => {
<> <>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex"> <div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row"> <div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<aside className="w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60 dark"> <aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]"> <nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div> <div>
{!router.asPath.includes("personal") && <div className="h-12 px-3 flex items-center pt-6 cursor-default"> {!router.asPath.includes("personal") && (
{(router.asPath.includes("project") || router.asPath.includes("integrations")) && <Link href={`/org/${currentOrg?._id}/overview`}><div className="pl-1 pr-2 text-mineshaft-400 hover:text-mineshaft-100 duration-200"> <div className="flex h-12 cursor-default items-center px-3 pt-6">
<FontAwesomeIcon icon={faArrowLeft} /> {(router.asPath.includes("project") ||
</div></Link>} router.asPath.includes("integrations")) && (
<DropdownMenu> <Link href={`/org/${currentOrg?._id}/overview`}>
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600"> <div className="pl-1 pr-2 text-mineshaft-400 duration-200 hover:text-mineshaft-100">
<div className="mr-auto flex items-center hover:bg-mineshaft-600 py-1.5 pl-1.5 pr-2 rounded-md"> <FontAwesomeIcon icon={faArrowLeft} />
<div className="w-5 h-5 rounded-md bg-primary flex justify-center items-center text-sm">{currentOrg?.name.charAt(0)}</div> </div>
<div className="pl-3 text-mineshaft-100 text-sm">{currentOrg?.name} <FontAwesomeIcon icon={faAngleDown} className="text-xs pl-1 pt-1 text-mineshaft-300" /></div> </Link>
</div> )}
</DropdownMenuTrigger> <DropdownMenu>
<DropdownMenuContent align="start" className="p-1"> <DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
<div className="text-xs text-mineshaft-400 px-2 py-1">{user?.email}</div> <div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
{orgs?.map(org => <DropdownMenuItem key={org._id}> <div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm">
<Button {currentOrg?.name.charAt(0)}
onClick={() => changeOrg(org?._id)} </div>
variant="plain" <div className="pl-3 text-sm text-mineshaft-100">
colorSchema="secondary" {currentOrg?.name}{" "}
size="xs" <FontAwesomeIcon
className="w-full flex items-center justify-start p-0 font-normal" icon={faAngleDown}
leftIcon={currentOrg._id === org._id && <FontAwesomeIcon icon={faCheck} className="mr-3 text-primary"/>} className="pl-1 pt-1 text-xs text-mineshaft-300"
> />
<div className="w-full flex justify-between items-center">{org.name}</div> </div>
</Button> </div>
</DropdownMenuItem> </DropdownMenuTrigger>
)} <DropdownMenuContent align="start" className="p-1">
<div className="h-1 mt-1 border-t border-mineshaft-600"/> <div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<button {orgs?.map((org) => (
type="button" <DropdownMenuItem key={org._id}>
onClick={logOutUser} <Button
className="w-full" onClick={() => changeOrg(org?._id)}
> variant="plain"
<DropdownMenuItem>Log Out</DropdownMenuItem> colorSchema="secondary"
</button> size="xs"
</DropdownMenuContent> className="flex w-full items-center justify-start p-0 font-normal"
</DropdownMenu> leftIcon={
<DropdownMenu> currentOrg._id === org._id && (
<DropdownMenuTrigger asChild className="hover:bg-primary-400 hover:text-black data-[state=open]:text-black data-[state=open]:bg-primary-400 p-1"> <FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
<div className="child w-6 h-6 rounded-full bg-mineshaft hover:bg-mineshaft-500 pr-1 text-xs text-mineshaft-300 flex justify-center items-center"> )
{user?.firstName?.charAt(0)}{user?.lastName && user?.lastName?.charAt(0)} }
</div> >
</DropdownMenuTrigger> <div className="flex w-full items-center justify-between">
<DropdownMenuContent align="start" className="p-1"> {org.name}
<div className="text-xs text-mineshaft-400 px-2 py-1">{user?.email}</div> </div>
<Link href="/personal-settings"><DropdownMenuItem>Personal Settings</DropdownMenuItem></Link> </Button>
<a </DropdownMenuItem>
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="w-full mt-3 text-sm text-mineshaft-300 font-normal leading-[1.2rem] hover:text-mineshaft-100"
>
<DropdownMenuItem>Documentation<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-1.5 text-xxs mb-[0.06rem]" /></DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="w-full mt-3 text-sm text-mineshaft-300 font-normal leading-[1.2rem] hover:text-mineshaft-100"
>
<DropdownMenuItem>Join Slack Community<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-1.5 text-xxs mb-[0.06rem]" /></DropdownMenuItem>
</a>
<div className="h-1 mt-1 border-t border-mineshaft-600"/>
<button
type="button"
onClick={logOutUser}
className="w-full"
>
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div>}
{!router.asPath.includes("org") && (!router.asPath.includes("personal") && currentWorkspace ? (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?._id}
value={currentWorkspace?._id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => {
router.push(`/project/${value}/secrets`);
localStorage.setItem("projectData.id", value);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className='h-full no-scrollbar no-scrollbar::-webkit-scrollbar'>
{workspaces
.filter((ws) => ws.organization === currentOrg?._id)
.map(({ _id, name }) => (
<SelectItem
key={`ws-layout-list-${_id}`}
value={_id}
className={`${currentWorkspace?._id === _id && "bg-mineshaft-600"}`}
>
{name}
</SelectItem>
))} ))}
</div> <div className="mt-1 h-1 border-t border-mineshaft-600" />
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" /> <button type="button" onClick={logOutUser} className="w-full">
<div className="w-full"> <DropdownMenuItem>Log Out</DropdownMenuItem>
<Button </button>
className="w-full bg-mineshaft-700 py-2 text-bunker-200" </DropdownMenuContent>
colorSchema="primary" </DropdownMenu>
variant="outline_bg" <DropdownMenu>
size="sm" <DropdownMenuTrigger
onClick={() => { asChild
if (isAddingProjectsAllowed) { className="p-1 hover:bg-primary-400 hover:text-black data-[state=open]:bg-primary-400 data-[state=open]:text-black"
handlePopUpOpen("addNewWs") >
} else { <div className="child flex h-6 w-6 items-center justify-center rounded-full bg-mineshaft pr-1 text-xs text-mineshaft-300 hover:bg-mineshaft-500">
handlePopUpOpen("upgradePlan"); {user?.firstName?.charAt(0)}
} {user?.lastName && user?.lastName?.charAt(0)}
}} </div>
leftIcon={<FontAwesomeIcon icon={faPlus} />} </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<Link href="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
> >
Add Project <DropdownMenuItem>
</Button> Documentation
</div> <FontAwesomeIcon
</Select> icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Join Slack Community
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
) : <Link href={`/org/${currentOrg?._id}/overview`}><div className="pr-2 my-6 flex justify-center items-center text-mineshaft-300 hover:text-mineshaft-100 cursor-default text-sm"> )}
<FontAwesomeIcon icon={faArrowLeft} className="pr-3"/> {!router.asPath.includes("org") &&
Back to organization (!router.asPath.includes("personal") && currentWorkspace ? (
</div></Link>)} <div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?._id}
value={currentWorkspace?._id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => {
router.push(`/project/${value}/secrets/overview`);
localStorage.setItem("projectData.id", value);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
{workspaces
.filter((ws) => ws.organization === currentOrg?._id)
.map(({ _id, name }) => (
<SelectItem
key={`ws-layout-list-${_id}`}
value={_id}
className={`${currentWorkspace?._id === _id && "bg-mineshaft-600"}`}
>
{name}
</SelectItem>
))}
</div>
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
<div className="w-full">
<Button
className="w-full bg-mineshaft-700 py-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
</div>
</Select>
</div>
) : (
<Link href={`/org/${currentOrg?._id}/overview`}>
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
<FontAwesomeIcon icon={faArrowLeft} className="pr-3" />
Back to organization
</div>
</Link>
))}
<div className={`px-1 ${!router.asPath.includes("personal") ? "block" : "hidden"}`}> <div className={`px-1 ${!router.asPath.includes("personal") ? "block" : "hidden"}`}>
{((router.asPath.includes("project") || router.asPath.includes("integrations")) && currentWorkspace) ? <Menu> {(router.asPath.includes("project") || router.asPath.includes("integrations")) &&
<Link href={`/project/${currentWorkspace?._id}/secrets`} passHref> currentWorkspace ? (
<a> <Menu>
<MenuItem <Link href={`/project/${currentWorkspace?._id}/secrets/overview`} passHref>
isSelected={router.asPath.includes(`/project/${currentWorkspace?._id}/secrets`)} <a>
icon="system-outline-90-lock-closed" <MenuItem
> isSelected={router.asPath.includes(
{t("nav.menu.secrets")} `/project/${currentWorkspace?._id}/secrets/overview`
</MenuItem> )}
</a> icon="system-outline-90-lock-closed"
</Link> >
<Link href={`/project/${currentWorkspace?._id}/members`} passHref> {t("nav.menu.secrets")}
<a> </MenuItem>
<MenuItem </a>
isSelected={router.asPath === `/project/${currentWorkspace?._id}/members`} </Link>
icon="system-outline-96-groups" <Link href={`/project/${currentWorkspace?._id}/members`} passHref>
> <a>
{t("nav.menu.members")} <MenuItem
</MenuItem> isSelected={
</a> router.asPath === `/project/${currentWorkspace?._id}/members`
</Link> }
<Link href={`/integrations/${currentWorkspace?._id}`} passHref> icon="system-outline-96-groups"
<a> >
<MenuItem {t("nav.menu.members")}
isSelected={router.asPath === `/integrations/${currentWorkspace?._id}`} </MenuItem>
icon="system-outline-82-extension" </a>
> </Link>
{t("nav.menu.integrations")} <Link href={`/integrations/${currentWorkspace?._id}`} passHref>
</MenuItem> <a>
</a> <MenuItem
</Link> isSelected={router.asPath === `/integrations/${currentWorkspace?._id}`}
<Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref> icon="system-outline-82-extension"
<a> >
<MenuItem {t("nav.menu.integrations")}
isSelected={ </MenuItem>
router.asPath === `/project/${currentWorkspace?._id}/allowlist` </a>
} </Link>
icon="system-outline-126-verified" <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
> <a>
IP Allowlist <MenuItem
</MenuItem> isSelected={
</a> router.asPath === `/project/${currentWorkspace?._id}/allowlist`
</Link> }
<Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref> icon="system-outline-126-verified"
<a> >
<MenuItem IP Allowlist
isSelected={router.asPath === `/project/${currentWorkspace?._id}/audit-logs`} </MenuItem>
icon="system-outline-168-view-headline" </a>
> </Link>
Audit Logs <Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref>
</MenuItem> <a>
</a> <MenuItem
</Link> isSelected={
{/* <Link href={`/project/${currentWorkspace?._id}/secret-scanning`} passHref> router.asPath === `/project/${currentWorkspace?._id}/audit-logs`
}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
{/* <Link href={`/project/${currentWorkspace?._id}/secret-scanning`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={router.asPath === `/project/${currentWorkspace?._id}/secret-scanning`} isSelected={router.asPath === `/project/${currentWorkspace?._id}/secret-scanning`}
@@ -438,20 +506,21 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> */} </Link> */}
<Link href={`/project/${currentWorkspace?._id}/settings`} passHref> <Link href={`/project/${currentWorkspace?._id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={ isSelected={
router.asPath === `/project/${currentWorkspace?._id}/settings` router.asPath === `/project/${currentWorkspace?._id}/settings`
} }
icon="system-outline-109-slider-toggle-settings" icon="system-outline-109-slider-toggle-settings"
> >
{t("nav.menu.project-settings")} {t("nav.menu.project-settings")}
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
</Menu> </Menu>
: <Menu className="mt-4"> ) : (
<Menu className="mt-4">
<Link href={`/org/${currentOrg?._id}/overview`} passHref> <Link href={`/org/${currentOrg?._id}/overview`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -462,7 +531,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
{/* {workspaces.map(project => <Link key={project._id} href={`/project/${project?._id}/secrets`} passHref> {/* {workspaces.map(project => <Link key={project._id} href={`/project/${project?._id}/secrets/overview`} passHref>
<a> <a>
<SubMenuItem <SubMenuItem
isSelected={false} isSelected={false}
@@ -508,20 +577,25 @@ export const AppLayout = ({ children }: LayoutProps) => {
<Link href={`/org/${currentOrg?._id}/settings`} passHref> <Link href={`/org/${currentOrg?._id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={ isSelected={router.asPath === `/org/${currentOrg?._id}/settings`}
router.asPath === `/org/${currentOrg?._id}/settings`
}
icon="system-outline-109-slider-toggle-settings" icon="system-outline-109-slider-toggle-settings"
> >
Organization Settings Organization Settings
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
</Menu>} </Menu>
)}
</div> </div>
</div> </div>
<div className={`relative mt-10 ${subscription && subscription.slug === "starter" && !subscription.has_used_trial ? "mb-2" : "mb-4"} w-full px-3 text-mineshaft-400 cursor-default text-sm flex flex-col items-center`}> <div
{/* <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[9.9rem] ${router.asPath.includes("org") ? "bottom-[8.4rem]" : "bottom-[5.4rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-30`}/> className={`relative mt-10 ${
subscription && subscription.slug === "starter" && !subscription.has_used_trial
? "mb-2"
: "mb-4"
} flex w-full cursor-default flex-col items-center px-3 text-sm text-mineshaft-400`}
>
{/* <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[9.9rem] ${router.asPath.includes("org") ? "bottom-[8.4rem]" : "bottom-[5.4rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-30`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[10.7rem] ${router.asPath.includes("org") ? "bottom-[8.15rem]" : "bottom-[5.15rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-50`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[10.7rem] ${router.asPath.includes("org") ? "bottom-[8.15rem]" : "bottom-[5.15rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-50`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[11.5rem] ${router.asPath.includes("org") ? "bottom-[7.9rem]" : "bottom-[4.9rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-70`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[11.5rem] ${router.asPath.includes("org") ? "bottom-[7.9rem]" : "bottom-[4.9rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-70`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[12.3rem] ${router.asPath.includes("org") ? "bottom-[7.65rem]" : "bottom-[4.65rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-90`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[12.3rem] ${router.asPath.includes("org") ? "bottom-[7.65rem]" : "bottom-[4.65rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-90`}/>
@@ -549,22 +623,24 @@ export const AppLayout = ({ children }: LayoutProps) => {
</a> </a>
</div> </div>
</div> */} </div> */}
{router.asPath.includes("org") && <div {router.asPath.includes("org") && (
onKeyDown={() => null} <div
role="button" onKeyDown={() => null}
tabIndex={0} role="button"
onClick={() => router.push(`/org/${router.query.id}/members?action=invite`)} tabIndex={0}
className="w-full" onClick={() => router.push(`/org/${router.query.id}/members?action=invite`)}
> className="w-full"
<div className="hover:text-mineshaft-200 duration-200 mb-3 pl-5 w-full"> >
<FontAwesomeIcon icon={faPlus} className="mr-3"/> <div className="mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
Invite people <FontAwesomeIcon icon={faPlus} className="mr-3" />
Invite people
</div>
</div> </div>
</div>} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="hover:text-mineshaft-200 duration-200 mb-2 pl-5 w-full"> <div className="mb-2 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faQuestion} className="px-[0.1rem] mr-3"/> <FontAwesomeIcon icon={faQuestion} className="mr-3 px-[0.1rem]" />
Help & Support Help & Support
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -586,28 +662,33 @@ export const AppLayout = ({ children }: LayoutProps) => {
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( {subscription &&
<button subscription.slug === "starter" &&
type="button" !subscription.has_used_trial && (
onClick={async () => { <button
if (!subscription || !currentOrg) return; type="button"
onClick={async () => {
if (!subscription || !currentOrg) return;
// direct user to start pro trial // direct user to start pro trial
const url = await mutateAsync({ const url = await mutateAsync({
orgId: currentOrg._id, orgId: currentOrg._id,
success_url: window.location.href success_url: window.location.href
}); });
window.location.href = url; window.location.href = url;
}} }}
className="w-full mt-1.5" className="mt-1.5 w-full"
> >
<div className="hover:text-primary-400 text-mineshaft-300 duration-200 flex justify-left items-center py-1 bg-mineshaft-600 rounded-md hover:bg-mineshaft-500 mb-1.5 mt-1.5 pl-4 w-full"> <div className="justify-left mb-1.5 mt-1.5 flex w-full items-center rounded-md bg-mineshaft-600 py-1 pl-4 text-mineshaft-300 duration-200 hover:bg-mineshaft-500 hover:text-primary-400">
<FontAwesomeIcon icon={faInfinity} className="mr-3 ml-0.5 py-2 text-primary"/> <FontAwesomeIcon
Start Free Pro Trial icon={faInfinity}
</div> className="mr-3 ml-0.5 py-2 text-primary"
</button> />
)} Start Free Pro Trial
</div>
</button>
)}
</div> </div>
</nav> </nav>
</aside> </aside>
+221 -127
View File
@@ -1,4 +1,3 @@
import crypto from "crypto"; import crypto from "crypto";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -9,14 +8,31 @@ import { useRouter } from "next/router";
import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; import { faFolderOpen } from "@fortawesome/free-regular-svg-icons";
import { faArrowRight, faCheckCircle, faHandPeace, faMagnifyingGlass, faNetworkWired, faPlug, faPlus, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import {
faArrowRight,
faCheckCircle,
faHandPeace,
faMagnifyingGlass,
faNetworkWired,
faPlug,
faPlus,
faUserPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck"; import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck";
import { Button, Checkbox, FormControl, Input, Modal, ModalContent, UpgradePlanModal } from "@app/components/v2"; import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
UpgradePlanModal
} from "@app/components/v2";
import { TabsObject } from "@app/components/v2/Tabs"; import { TabsObject } from "@app/components/v2/Tabs";
import { useSubscription, useUser, useWorkspace } from "@app/context"; import { useSubscription, useUser, useWorkspace } from "@app/context";
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useUploadWsKey } from "@app/hooks/api"; import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useUploadWsKey } from "@app/hooks/api";
@@ -25,11 +41,14 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { encryptAssymmetric } from "../../../../components/utilities/cryptography/crypto"; import { encryptAssymmetric } from "../../../../components/utilities/cryptography/crypto";
import registerUserAction from "../../../api/userActions/registerUserAction"; import registerUserAction from "../../../api/userActions/registerUserAction";
const features = [{ const features = [
"_id": 0, {
"name": "Kubernetes Operator", _id: 0,
"description": "Pull secrets into your Kubernetes containers and automatically redeploy upon secret changes." name: "Kubernetes Operator",
}] description:
"Pull secrets into your Kubernetes containers and automatically redeploy upon secret changes."
}
];
type ItemProps = { type ItemProps = {
text: string; text: string;
@@ -58,7 +77,11 @@ const learningItem = ({
className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`} className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`}
href={link} href={link}
> >
<div className={`${complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""} mb-3 rounded-md`}> <div
className={`${
complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""
} mb-3 rounded-md`}
>
<div <div
onKeyDown={() => null} onKeyDown={() => null}
role="button" role="button"
@@ -70,7 +93,11 @@ const learningItem = ({
}); });
} }
}} }}
className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${complete? "bg-gradient-to-r from-[#0e1f01] to-mineshaft-700 border-mineshaft-900 cursor-default" : "bg-mineshaft-800 hover:bg-mineshaft-700 border-mineshaft-600 shadow-xl cursor-pointer"} duration-200 text-mineshaft-100`} className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${
complete
? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700"
: "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700"
} text-mineshaft-100 duration-200`}
> >
<div className="mr-4 flex flex-row items-center"> <div className="mr-4 flex flex-row items-center">
<FontAwesomeIcon icon={icon} className="mx-2 w-16 text-4xl" /> <FontAwesomeIcon icon={icon} className="mx-2 w-16 text-4xl" />
@@ -148,7 +175,11 @@ const learningItemSquare = ({
className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`} className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`}
href={link} href={link}
> >
<div className={`${complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""} rounded-md w-full`}> <div
className={`${
complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""
} w-full rounded-md`}
>
<div <div
onKeyDown={() => null} onKeyDown={() => null}
role="button" role="button"
@@ -160,23 +191,32 @@ const learningItemSquare = ({
}); });
} }
}} }}
className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${complete? "bg-gradient-to-r from-[#0e1f01] to-mineshaft-700 border-mineshaft-900 cursor-default" : "bg-mineshaft-800 hover:bg-mineshaft-700 border-mineshaft-600 shadow-xl cursor-pointer"} duration-200 text-mineshaft-100`} className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${
complete
? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700"
: "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700"
} text-mineshaft-100 duration-200`}
> >
<div className="flex flex-col items-center w-full px-6 py-4"> <div className="flex w-full flex-col items-center px-6 py-4">
<div className="flex flex-row items-start justify-between w-full"> <div className="flex w-full flex-row items-start justify-between">
<FontAwesomeIcon icon={icon} className="w-16 text-5xl text-mineshaft-200 group-hover:text-mineshaft-100 duration-100 pt-2" /> <FontAwesomeIcon
icon={icon}
className="w-16 pt-2 text-5xl text-mineshaft-200 duration-100 group-hover:text-mineshaft-100"
/>
{complete && ( {complete && (
<div className="absolute left-14 top-12 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700"> <div className="absolute left-14 top-12 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
<FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-primary" /> <FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-primary" />
</div> </div>
)} )}
<div <div
className={`text-right text-sm font-normal text-mineshaft-300 ${complete ? "text-primary font-semibold" : ""}`} className={`text-right text-sm font-normal text-mineshaft-300 ${
complete ? "font-semibold text-primary" : ""
}`}
> >
{complete ? "Complete!" : `About ${time}`} {complete ? "Complete!" : `About ${time}`}
</div> </div>
</div> </div>
<div className="flex flex-col items-start justify-start w-full pt-4"> <div className="flex w-full flex-col items-start justify-start pt-4">
<div className="mt-0.5 text-lg font-medium">{text}</div> <div className="mt-0.5 text-lg font-medium">{text}</div>
<div className="text-sm font-normal text-mineshaft-300">{subText}</div> <div className="text-sm font-normal text-mineshaft-300">{subText}</div>
</div> </div>
@@ -202,7 +242,10 @@ export default function Organization() {
const router = useRouter(); const router = useRouter();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const orgWorkspaces = workspaces?.filter(workspace => workspace.organization === localStorage.getItem("orgData.id")) || [] const orgWorkspaces =
workspaces?.filter(
(workspace) => workspace.organization === localStorage.getItem("orgData.id")
) || [];
const currentOrg = String(router.query.id); const currentOrg = String(router.query.id);
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const addWsUser = useAddUserToWs(); const addWsUser = useAddUserToWs();
@@ -269,7 +312,7 @@ export default function Organization() {
} }
createNotification({ text: "Workspace created", type: "success" }); createNotification({ text: "Workspace created", type: "success" });
handlePopUpClose("addNewWs"); handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets`); router.push(`/project/${newWorkspaceId}/secrets/overview`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" }); createNotification({ text: "Failed to create workspace", type: "error" });
@@ -278,7 +321,9 @@ export default function Organization() {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit ? (subscription.workspacesUsed < subscription.workspaceLimit) : true; const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
useEffect(() => { useEffect(() => {
onboardingCheck({ onboardingCheck({
@@ -290,16 +335,16 @@ export default function Organization() {
}, []); }, []);
return ( return (
<div className="flex max-w-7xl mx-auto flex-col justify-start bg-bunker-800 md:h-screen"> <div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800 md:h-screen">
<Head> <Head>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title> <title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
</Head> </Head>
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4"> <div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl">
<p className="mr-4 font-semibold text-white">Projects</p> <p className="mr-4 font-semibold text-white">Projects</p>
<div className="w-full flex flex-row mt-6"> <div className="mt-6 flex w-full flex-row">
<Input <Input
className="h-[2.3rem] text-sm bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by project name..." placeholder="Search by project name..."
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} onChange={(e) => setSearchFilter(e.target.value)}
@@ -310,7 +355,7 @@ export default function Organization() {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (isAddingProjectsAllowed) { if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs") handlePopUpOpen("addNewWs");
} else { } else {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
} }
@@ -320,120 +365,169 @@ export default function Organization() {
Add New Project Add New Project
</Button> </Button>
</div> </div>
<div className="mt-4 w-full grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{orgWorkspaces.filter(ws => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())).map(workspace => <div key={workspace._id} className="h-40 min-w-72 rounded-md bg-mineshaft-800 border border-mineshaft-600 p-4 flex flex-col justify-between"> {orgWorkspaces
<div className="text-lg text-mineshaft-100 mt-0">{workspace.name}</div> .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
<div className="text-sm text-mineshaft-300 mt-0 pb-6">{(workspace.environments?.length || 0)} environments</div> .map((workspace) => (
<button type="button" onClick={() => { <div
router.push(`/project/${workspace._id}/secrets`); key={workspace._id}
localStorage.setItem("projectData.id", workspace._id); className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
}}> >
<div className="group cursor-default ml-auto hover:bg-primary-800/20 text-sm text-mineshaft-300 hover:text-mineshaft-200 bg-mineshaft-900 py-2 px-4 rounded-full w-max border border-mineshaft-600 hover:border-primary-500/80">Explore <FontAwesomeIcon icon={faArrowRight} className="pl-1.5 pr-0.5 group-hover:pl-2 group-hover:pr-0 duration-200" /></div> <div className="mt-0 text-lg text-mineshaft-100">{workspace.name}</div>
</button> <div className="mt-0 pb-6 text-sm text-mineshaft-300">
</div>)} {workspace.environments?.length || 0} environments
</div>
<button
type="button"
onClick={() => {
router.push(`/project/${workspace._id}/secrets/overview`);
localStorage.setItem("projectData.id", workspace._id);
}}
>
<div className="group ml-auto w-max cursor-default rounded-full border border-mineshaft-600 bg-mineshaft-900 py-2 px-4 text-sm text-mineshaft-300 hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 group-hover:pl-2 group-hover:pr-0"
/>
</div>
</button>
</div>
))}
</div> </div>
{orgWorkspaces.length === 0 && ( {orgWorkspaces.length === 0 && (
<div className="w-full rounded-md bg-mineshaft-800 border border-mineshaft-700 px-4 py-6 text-mineshaft-300 text-base"> <div className="w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon icon={faFolderOpen} className="w-full text-center text-5xl mb-4 mt-2 text-mineshaft-400" /> <FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light"> <div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will appear You are not part of any projects in this organization yet. When you are, they will
here. appear here.
</div> </div>
<div className="mt-0.5 text-center font-light"> <div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary permissions. Create a new project, or ask other organization members to give you necessary
permissions.
</div> </div>
</div> </div>
)} )}
</div> </div>
{((new Date()).getTime() - (new Date(user?.createdAt)).getTime()) < 30 * 24 * 60 * 60 * 1000 && <div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4"> {new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000 && (
<p className="mr-4 font-semibold text-white mb-4">Onboarding Guide</p> <div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3 w-full mb-3"> <p className="mr-4 mb-4 font-semibold text-white">Onboarding Guide</p>
{learningItemSquare({ <div className="mb-3 grid w-full grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
text: "Watch Infisical demo", {learningItemSquare({
subText: "Set up Infisical in 3 min.", text: "Watch Infisical demo",
complete: hasUserClickedIntro, subText: "Set up Infisical in 3 min.",
icon: faHandPeace, complete: hasUserClickedIntro,
time: "3 min", icon: faHandPeace,
userAction: "intro_cta_clicked", time: "3 min",
link: "https://www.youtube.com/watch?v=PK23097-25I" userAction: "intro_cta_clicked",
})} link: "https://www.youtube.com/watch?v=PK23097-25I"
{orgWorkspaces.length !== 0 && learningItemSquare({ })}
text: "Add your secrets", {orgWorkspaces.length !== 0 &&
subText: "Drop a .env file or type your secrets.", learningItemSquare({
complete: hasUserPushedSecrets, text: "Add your secrets",
icon: faPlus, subText: "Drop a .env file or type your secrets.",
time: "1 min", complete: hasUserPushedSecrets,
userAction: "first_time_secrets_pushed", icon: faPlus,
link: `/project/${orgWorkspaces[0]?._id}/secrets` time: "1 min",
})} userAction: "first_time_secrets_pushed",
{learningItemSquare({ link: `/project/${orgWorkspaces[0]?._id}/secrets`
text: "Invite your teammates", })}
subText: "Infisical is better used as a team.", {learningItemSquare({
complete: usersInOrg, text: "Invite your teammates",
icon: faUserPlus, subText: "Infisical is better used as a team.",
time: "2 min", complete: usersInOrg,
link: `/org/${router.query.id}/members?action=invite` icon: faUserPlus,
})} time: "2 min",
<div className="block xl:hidden 2xl:block">{learningItemSquare({ link: `/org/${router.query.id}/members?action=invite`
text: "Join Infisical Slack", })}
subText: "Have any questions? Ask us!", <div className="block xl:hidden 2xl:block">
complete: hasUserClickedSlack, {learningItemSquare({
icon: faSlack, text: "Join Infisical Slack",
time: "1 min", subText: "Have any questions? Ask us!",
userAction: "slack_cta_clicked", complete: hasUserClickedSlack,
link: "https://infisical.com/slack" icon: faSlack,
})}</div> time: "1 min",
</div> userAction: "slack_cta_clicked",
{orgWorkspaces.length !== 0 && <div className="group text-mineshaft-100 relative mb-3 flex h-full w-full cursor-default flex-col items-center justify-between overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-800 pl-2 pr-2 pt-4 pb-2 shadow-xl duration-200"> link: "https://infisical.com/slack"
<div className="mb-4 flex w-full flex-row items-center pr-4"> })}
<div className="mr-4 flex w-full flex-row items-center">
<FontAwesomeIcon icon={faNetworkWired} className="mx-2 w-16 text-4xl" />
{false && (
<div className="absolute left-12 top-10 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
<FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-green" />
</div>
)}
<div className="flex flex-col items-start pl-0.5">
<div className="mt-0.5 text-xl font-semibold">Inject secrets locally</div>
<div className="text-sm font-normal">
Replace .env files with a more secure and efficient alternative.
</div>
</div>
</div>
<div className={`w-28 pr-4 text-right text-sm font-semibold ${false && "text-green"}`}>
About 2 min
</div> </div>
</div> </div>
<TabsObject /> {orgWorkspaces.length !== 0 && (
{false && <div className="absolute bottom-0 left-0 h-1 w-full bg-green" />} <div className="group relative mb-3 flex h-full w-full cursor-default flex-col items-center justify-between overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-800 pl-2 pr-2 pt-4 pb-2 text-mineshaft-100 shadow-xl duration-200">
</div>} <div className="mb-4 flex w-full flex-row items-center pr-4">
{orgWorkspaces.length !== 0 && learningItem({ <div className="mr-4 flex w-full flex-row items-center">
text: "Integrate Infisical with your infrastructure", <FontAwesomeIcon icon={faNetworkWired} className="mx-2 w-16 text-4xl" />
subText: "Connect Infisical to various 3rd party services and platforms.", {false && (
complete: false, <div className="absolute left-12 top-10 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
icon: faPlug, <FontAwesomeIcon
time: "15 min", icon={faCheckCircle}
link: "https://infisical.com/docs/integrations/overview" className="h-5 w-5 text-4xl text-green"
})} />
</div>} </div>
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4 pb-6"> )}
<p className="mr-4 font-semibold text-white">Explore More</p> <div className="flex flex-col items-start pl-0.5">
<div className="mt-4 w-full grid grid-flow-dense gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 4fr))" }}> <div className="mt-0.5 text-xl font-semibold">Inject secrets locally</div>
{features.map(feature => <div key={feature._id} className="h-44 w-96 rounded-md bg-mineshaft-800 border border-mineshaft-600 p-4 flex flex-col justify-between"> <div className="text-sm font-normal">
<div className="text-lg text-mineshaft-100 mt-0">{feature.name}</div> Replace .env files with a more secure and efficient alternative.
<div className="text-[15px] font-light text-mineshaft-300 mb-4 mt-2">{feature.description}</div> </div>
<div className="w-full flex items-center"> </div>
<div className="text-mineshaft-300 text-[15px] font-light">Setup time: 20 min</div> </div>
<a <div
target="_blank" className={`w-28 pr-4 text-right text-sm font-semibold ${false && "text-green"}`}
rel="noopener noreferrer" >
className="group cursor-default ml-auto hover:bg-primary-800/20 text-sm text-mineshaft-300 hover:text-mineshaft-200 bg-mineshaft-900 py-2 px-4 rounded-full w-max border border-mineshaft-600 hover:border-primary-500/80" About 2 min
href="https://infisical.com/docs/documentation/getting-started/kubernetes" </div>
> </div>
Learn more <FontAwesomeIcon icon={faArrowRight} className="pl-1.5 pr-0.5 group-hover:pl-2 group-hover:pr-0 duration-200"/> <TabsObject />
</a> {false && <div className="absolute bottom-0 left-0 h-1 w-full bg-green" />}
</div> </div>
</div>)} )}
{orgWorkspaces.length !== 0 &&
learningItem({
text: "Integrate Infisical with your infrastructure",
subText: "Connect Infisical to various 3rd party services and platforms.",
complete: false,
icon: faPlug,
time: "15 min",
link: "https://infisical.com/docs/integrations/overview"
})}
</div>
)}
<div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 pb-6 text-3xl">
<p className="mr-4 font-semibold text-white">Explore More</p>
<div
className="mt-4 grid w-full grid-flow-dense gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 4fr))" }}
>
{features.map((feature) => (
<div
key={feature._id}
className="flex h-44 w-96 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">{feature.name}</div>
<div className="mb-4 mt-2 text-[15px] font-light text-mineshaft-300">
{feature.description}
</div>
<div className="flex w-full items-center">
<div className="text-[15px] font-light text-mineshaft-300">Setup time: 20 min</div>
<a
target="_blank"
rel="noopener noreferrer"
className="group ml-auto w-max cursor-default rounded-full border border-mineshaft-600 bg-mineshaft-900 py-2 px-4 text-sm text-mineshaft-300 hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200"
href="https://infisical.com/docs/documentation/getting-started/kubernetes"
>
Learn more{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 group-hover:pl-2 group-hover:pr-0"
/>
</a>
</div>
</div>
))}
</div> </div>
</div> </div>
<Modal <Modal
@@ -1,16 +1,10 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router";
import { DashboardPage } from "@app/views/DashboardPage"; import { DashboardPage } from "@app/views/DashboardPage";
import { DashboardEnvOverview } from "@app/views/DashboardPage/DashboardEnvOverview";
const Dashboard = () => { const Dashboard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const queryEnv = router.query.env as string;
const isOverviewMode = !queryEnv;
return ( return (
<> <>
@@ -22,7 +16,7 @@ const Dashboard = () => {
<meta name="og:description" content={String(t("dashboard.og-description"))} /> <meta name="og:description" content={String(t("dashboard.og-description"))} />
</Head> </Head>
<div className="h-full"> <div className="h-full">
{isOverviewMode ? <DashboardEnvOverview /> : <DashboardPage envFromTop={queryEnv} />} <DashboardPage />
</div> </div>
</> </>
); );
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { SecretOverviewPage } from "@app/views/SecretOverviewPage";
const Dashboard = () => {
const { t } = useTranslation();
return (
<>
<Head>
<title>{t("common.head-title", { title: t("dashboard.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={String(t("dashboard.og-title"))} />
<meta name="og:description" content={String(t("dashboard.og-description"))} />
</Head>
<div className="h-full">
<SecretOverviewPage />
</div>
</>
);
};
export default Dashboard;
Dashboard.requireAuth = true;
+27 -8
View File
@@ -13,6 +13,11 @@
.flex-3 { .flex-3 {
flex-grow: 3; flex-grow: 3;
} }
.min-table-row {
width: 1%;
white-space: nowrap;
}
} }
@layer components { @layer components {
@@ -40,7 +45,7 @@
.breadcrumb::after, .breadcrumb::after,
.breadcrumb::before { .breadcrumb::before {
content: ''; content: "";
height: 60%; height: 60%;
width: 100%; width: 100%;
z-index: -1; z-index: -1;
@@ -58,18 +63,32 @@
} }
.breadcrumb::after { .breadcrumb::after {
left: 5px; left: 4px;
bottom: -3px; bottom: -2.5px;
transform: skew(-30deg); transform: skew(-30deg);
} }
.breadcrumb::before { .breadcrumb::before {
left: 5px; left: 4px;
top: -3px; top: -2.5px;
transform: skew(30deg); transform: skew(30deg);
} }
.thin-scrollbar::-webkit-scrollbar {
width: 0.25rem;
background-color: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: gray;
}
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: gray transparent;
}
} }
@import '@fontsource/inter/400.css'; @import "@fontsource/inter/400.css";
@import '@fontsource/inter/500.css'; @import "@fontsource/inter/500.css";
@import '@fontsource/inter/700.css'; @import "@fontsource/inter/700.css";
@@ -1,326 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { faFolderOpen, faKey, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import NavHeader from "@app/components/navigation/NavHeader";
import { Button, Input, TableContainer, Tooltip } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useGetProjectFoldersBatch,
useGetProjectSecretsByKey,
useGetUserWsEnvironments,
useGetUserWsKey
} from "@app/hooks/api";
import { EnvComparisonRow } from "./components/EnvComparisonRow";
import { FolderComparisonRow } from "./components/EnvComparisonRow/FolderComparisonRow";
export const DashboardEnvOverview = () => {
const { t } = useTranslation();
const router = useRouter();
const { currentWorkspace, isLoading } = useWorkspace();
const { currentOrg } = useOrganization();
const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState("");
const secretPath = router.query?.secretPath as string;
useEffect(() => {
if (!isLoading && !workspaceId && router.isReady) {
router.push(`/org/${currentOrg?._id}/overview`);
}
}, [isLoading, workspaceId, router.isReady]);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId
});
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecretsByKey({
workspaceId,
env: userAvailableEnvs?.map((env) => env.slug) ?? [],
decryptFileKey: latestFileKey!,
isPaused: false,
secretPath
});
const folders = useGetProjectFoldersBatch({
folders:
userAvailableEnvs?.map((env) => ({
environment: env.slug,
workspaceId
})) ?? [],
parentFolderPath: secretPath
});
const foldersGroupedByEnv = useMemo(() => {
const res: Record<string, Record<string, boolean>> = {};
folders.forEach(({ data }) => {
data?.folders
?.filter(({ name }) => name.toLowerCase().includes(searchFilter))
?.forEach((folder) => {
if (!res?.[folder.name]) res[folder.name] = {};
res[folder.name][data.environment] = true;
});
});
return res;
}, [folders, userAvailableEnvs, searchFilter]);
const numSecretsMissingPerEnv = useMemo(() => {
// first get all sec in the env then subtract with total to get missing ones
const secPerEnvMissing: Record<string, number> = Object.fromEntries(
(userAvailableEnvs || [])?.map(({ slug }) => [slug, 0])
);
Object.keys(secrets?.secrets || {}).forEach((key) =>
secrets?.secrets?.[key].forEach((val) => {
secPerEnvMissing[val.env] += 1;
})
);
Object.keys(secPerEnvMissing).forEach((k) => {
secPerEnvMissing[k] = (secrets?.uniqueSecCount || 0) - secPerEnvMissing[k];
});
return secPerEnvMissing;
}, [secrets, userAvailableEnvs]);
const onExploreEnv = (slug: string) => {
const query: Record<string, string> = { ...router.query, env: slug };
delete query.secretPath;
// the dir return will have the present directory folder id
// use that when clicking on explore to redirect user to there
const envFolder = folders.find(({ data }) => slug === data?.environment);
const dir = envFolder?.data?.dir?.pop();
if (dir) {
query.folderId = dir.id;
}
router.push({
pathname: router.pathname,
query
});
};
const onFolderClick = (path: string) => {
router.push({
pathname: router.pathname,
query: {
...router.query,
secretPath: `${router.query?.secretPath || ""}/${path}`
}
});
};
const onFolderCrumbClick = (index: number) => {
const newSecPath = secretPath.split("/").filter(Boolean).slice(0, index).join("/");
const query = { ...router.query, secretPath: `/${newSecPath}` } as Record<string, string>;
// root condition
if (index === 0) delete query.secretPath;
router.push({
pathname: router.pathname,
query
});
};
if (isSecretsLoading || isEnvListLoading) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
</div>
);
}
const filteredSecrets = Object.keys(secrets?.secrets || {})?.filter((secret: any) =>
secret.toUpperCase().includes(searchFilter.toUpperCase())
);
// when secrets is not loading and secrets list is empty
const isDashboardSecretEmpty = !isSecretsLoading && !filteredSecrets?.length;
const isFoldersEmtpy =
!folders.some(({ isLoading: isFolderLoading }) => isFolderLoading) &&
!Object.keys(foldersGroupedByEnv).length;
const isDashboardEmpty = isFoldersEmtpy && isDashboardSecretEmpty;
return (
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
<div className="mt-6">
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
or
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
</p>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-600 py-1 pl-5 pr-2 text-sm"
onClick={() => onFolderCrumbClick(0)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
<FontAwesomeIcon icon={faFolderOpen} className="text-primary" />
</div>
{(secretPath || "")
.split("/")
.filter(Boolean)
.map((path, index, arr) => (
<div
key={`secret-path-${index + 1}`}
className={`breadcrumb relative z-20 ${
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
{path}
</div>
))}
</div>
<div className="w-80">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret/folder name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
</div>
</div>
<div className="overflow-y-auto">
<div className="sticky top-0 mt-3 flex h-10 min-w-[60.3rem] flex-row rounded-md border border-mineshaft-600 bg-mineshaft-800">
<div className="sticky top-0 flex w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">{0}</div>
</div>
<div className="sticky top-0 border-none">
<div className="relative flex h-full w-full min-w-[200px] items-center justify-start lg:min-w-[220px] xl:min-w-[250px]">
<div className="text-sm font-medium ">Secret</div>
</div>
</div>
{numSecretsMissingPerEnv &&
userAvailableEnvs?.map((env) => {
return (
<div
key={`header-${env.slug}`}
className="flex w-full min-w-[11rem] flex-row items-center rounded-md border-none bg-mineshaft-800"
>
<div className="flex w-full flex-row justify-center text-center text-sm font-medium text-bunker-200/[.99]">
{env.name}
{numSecretsMissingPerEnv[env.slug] > 0 && (
<div className="mt-0.5 ml-2.5 flex h-[1.1rem] w-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red text-xs text-bunker-100">
<Tooltip
content={`${
numSecretsMissingPerEnv[env.slug]
} secrets missing compared to other environments`}
>
<span className="text-bunker-100">
{numSecretsMissingPerEnv[env.slug]}
</span>
</Tooltip>
</div>
)}
</div>
</div>
);
})}
</div>
<div
className={`${
isDashboardEmpty ? "" : ""
} no-scrollbar::-webkit-scrollbar mt-3 flex h-full max-h-[calc(100vh-370px)] w-full min-w-[60.3rem] flex-grow flex-row items-start justify-center overflow-x-hidden rounded-md border border-mineshaft-600 no-scrollbar`}
>
{!isDashboardEmpty && (
<TableContainer className="border-none">
<table className="secret-table relative w-full bg-mineshaft-900">
<tbody className="max-h-screen overflow-y-auto">
{Object.keys(foldersGroupedByEnv || {}).map((folderName, index) => (
<FolderComparisonRow
key={`${folderName}-${index + 1}`}
folderName={folderName}
userAvailableEnvs={userAvailableEnvs}
folderInEnv={foldersGroupedByEnv[folderName]}
onClick={onFolderClick}
/>
))}
{Object.keys(secrets?.secrets || {})
?.filter((secret: any) =>
secret.toUpperCase().includes(searchFilter.toUpperCase())
)
.map((key) => (
<EnvComparisonRow
key={`row-${key}`}
secrets={secrets?.secrets?.[key]}
isReadOnly
isSecretValueHidden
userAvailableEnvs={userAvailableEnvs}
/>
))}
</tbody>
</table>
</TableContainer>
)}
{isDashboardEmpty && (
<div className="flex h-40 w-full flex-row rounded-md">
<div className="flex w-full min-w-[11rem] flex-col items-center justify-center rounded-md border-none bg-mineshaft-800 text-bunker-300">
<FontAwesomeIcon icon={faKey} className="mb-4 text-4xl" />
<span className="mb-1">No secrets/folders found.</span>
<span>To add more secrets you can explore any environment.</span>
</div>
</div>
)}
</div>
<div className="group mt-4 flex min-w-[60.3rem] flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">0</div>
</div>
<div className="flex min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
<span className="text-transparent">0</span>
<button type="button" className="mr-2 text-transparent">
1
</button>
</div>
{userAvailableEnvs?.map((env) => {
return (
<div
key={`button-${env.slug}`}
className="mx-2 mb-1 flex h-10 w-full min-w-[11rem] flex-row items-center justify-center border-none"
>
<Button
onClick={() => onExploreEnv(env.slug)}
variant="outline_bg"
colorSchema="primary"
isFullWidth
className="h-10"
>
Explore {env.name}
</Button>
</div>
);
})}
</div>
</div>
</div>
);
};
+409 -370
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { FormProvider, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -30,7 +30,11 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -119,12 +123,13 @@ type TDeleteSecretImport = { environment: string; secretPath: string };
* Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving * Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving
* They will get it back * They will get it back
*/ */
export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { export const DashboardPage = () => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const envQuery = router.query.env as string;
const secretContainer = useRef<HTMLDivElement | null>(null); const secretContainer = useRef<HTMLDivElement | null>(null);
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
@@ -172,8 +177,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
onSuccess: (data) => { onSuccess: (data) => {
// get an env with one of the access available // get an env with one of the access available
const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied); const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
if (env && data?.map((wsenv) => wsenv.slug).includes(envFromTop)) { if (env && data?.map((wsenv) => wsenv.slug).includes(envQuery)) {
setSelectedEnv(data?.filter((dp) => dp.slug === envFromTop)[0]); setSelectedEnv(data?.filter((dp) => dp.slug === envQuery)[0]);
} }
} }
}); });
@@ -293,11 +298,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
}); });
const { const {
register,
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
setValue, setValue,
formState: { isSubmitting, isDirty }, formState: { isSubmitting, isDirty, errors },
reset reset
} = method; } = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" });
@@ -461,9 +467,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
} }
}; };
const onDrawerOpen = (dto: TSecretDetailsOpen) => { const onDrawerOpen = useCallback((id: string | undefined, index: number) => {
handlePopUpOpen("secretDetails", dto); handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen);
}; }, []);
const onEnvChange = (slug: string) => { const onEnvChange = (slug: string) => {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
@@ -480,17 +486,27 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
}); });
}; };
const handleDownloadSecret = () => {
const secretsFromImport: { key: string; value: string; comment: string }[] = [];
importedSecrets?.forEach(({ secrets: impSec }) => {
impSec.forEach((el) => {
secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment });
});
});
downloadSecret(getValues("secrets"), secretsFromImport, selectedEnv?.slug);
};
// record all deleted ids // record all deleted ids
// This will make final deletion easier // This will make final deletion easier
const onSecretDelete = (index: number, id?: string, overrideId?: string) => { const onSecretDelete = useCallback((index: number, id?: string, overrideId?: string) => {
if (id) deletedSecretIds.current.push(id); if (id) deletedSecretIds.current.push(id);
if (overrideId) deletedSecretIds.current.push(overrideId); if (overrideId) deletedSecretIds.current.push(overrideId);
remove(index); remove(index);
// just the case if this is called from drawer // just the case if this is called from drawer
handlePopUpClose("secretDetails"); handlePopUpClose("secretDetails");
}; }, []);
const onCreateWsTag = async (tagName: string) => { const onCreateWsTag = useCallback(async (tagName: string) => {
try { try {
await createWsTag({ await createWsTag({
workspaceID: workspaceId, workspaceID: workspaceId,
@@ -509,10 +525,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
type: "error" type: "error"
}); });
} }
}; }, []);
const handleFolderOpen = (id: string) => { const handleFolderOpen = useCallback((id: string) => {
setSearchFilter(""); setSearchFilter("");
console.log(router.query);
router.push({ router.push({
pathname: router.pathname, pathname: router.pathname,
query: { query: {
@@ -520,10 +537,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
folderId: id folderId: id
} }
}); });
}; }, []);
const isEditFolder = Boolean(popUp?.folderForm?.data); const isEditFolder = Boolean(popUp?.folderForm?.data);
// FOLDER SECTION
const handleFolderCreate = async (name: string) => { const handleFolderCreate = async (name: string) => {
try { try {
await createFolder({ await createFolder({
@@ -546,7 +564,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
} }
}; };
const handleFolderUpdate = async (name: string) => { const handleFolderUpdate = useCallback(async (name: string) => {
const { id } = popUp?.folderForm?.data as TDeleteFolderForm; const { id } = popUp?.folderForm?.data as TDeleteFolderForm;
try { try {
await updateFolder({ await updateFolder({
@@ -567,9 +585,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
type: "error" type: "error"
}); });
} }
}; }, []);
const handleFolderDelete = async () => { const handleFolderDelete = useCallback(async () => {
const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm;
try { try {
deleteFolder({ deleteFolder({
@@ -589,8 +607,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
type: "error" type: "error"
}); });
} }
}; }, []);
// SECRET IMPORT SECTION
const handleSecretImportCreate = async (env: string, secretPath: string) => { const handleSecretImportCreate = async (env: string, secretPath: string) => {
try { try {
await createSecretImport({ await createSecretImport({
@@ -664,6 +683,25 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
} }
}; };
// OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING
const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []);
const handleFolderCreatePopUpOpen = useCallback(
(id: string, name: string) => handlePopUpOpen("folderForm", { id, name }),
[]
);
const handleFolderDeletePopUpOpen = useCallback(
(id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }),
[]
);
const handleSecretImportDelPopUpOpen = useCallback(
(impSecEnv: string, impSecPath: string) =>
handlePopUpOpen("deleteSecretImport", {
environment: impSecEnv,
secretPath: impSecPath
}),
[]
);
// when secrets is not loading and secrets list is empty // when secrets is not loading and secrets list is empty
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
@@ -693,259 +731,259 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
return ( return (
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<FormProvider {...method}> <form autoComplete="off" className="h-full">
<form autoComplete="off" className="h-full"> {/* breadcrumb row */}
{/* breadcrumb row */} <div className="relative right-6 -top-2 mb-2 ml-6">
<div className="relative right-6 -top-2 mb-2 ml-6"> <NavHeader
<NavHeader pageName={t("dashboard.title")}
pageName={t("dashboard.title")} currentEnv={userAvailableEnvs?.filter((envir) => envir.slug === envQuery)[0].name || ""}
currentEnv={ isFolderMode
userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || "" folders={folderData?.dir}
} isProjectRelated
isFolderMode userAvailableEnvs={userAvailableEnvs}
folders={folderData?.dir} onEnvChange={onEnvChange}
isProjectRelated />
userAvailableEnvs={userAvailableEnvs} </div>
onEnvChange={onEnvChange} <div className="mb-4">
<h6 className="text-2xl">{isRollbackMode ? "Secret Snapshot" : ""}</h6>
{isRollbackMode && Boolean(snapshotSecret) && (
<Tag colorSchema="green">
{new Date(snapshotSecret?.createdAt || "").toLocaleString()}
</Tag>
)}
</div>
{/* Environment, search and other action row */}
<div className="flex items-center justify-between space-x-2">
<div className="flex max-w-lg flex-grow space-x-2">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name, comment..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/> />
</div> </div>
<div className="mb-4"> <div className="flex items-center space-x-2">
<h6 className="text-2xl">{isRollbackMode ? "Secret Snapshot" : ""}</h6> <div>
{isRollbackMode && Boolean(snapshotSecret) && ( <Popover>
<Tag colorSchema="green"> <PopoverTrigger asChild>
{new Date(snapshotSecret?.createdAt || "").toLocaleString()} <IconButton ariaLabel="download" variant="outline_bg">
</Tag> <FontAwesomeIcon icon={faDownload} />
)} </IconButton>
</div> </PopoverTrigger>
{/* Environment, search and other action row */} <PopoverContent
<div className="flex items-center justify-between space-x-2"> className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-1"
<div className="flex max-w-lg flex-grow space-x-2"> hideCloseBtn
<Input >
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" <div className="flex flex-col space-y-2">
placeholder="Search by folder name, key name, comment..." <Button
value={searchFilter} onClick={handleDownloadSecret}
onChange={(e) => setSearchFilter(e.target.value)} colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} variant="outline_bg"
/> className="h-8 bg-bunker-700"
>
Download as .env
</Button>
</div>
</PopoverContent>
</Popover>
</div> </div>
<div className="flex items-center space-x-2"> <div>
<div> <Tooltip content={isSecretValueHidden ? "Reveal Secrets" : "Hide secrets"}>
<Popover> <IconButton
<PopoverTrigger asChild> ariaLabel="reveal"
<IconButton ariaLabel="download" variant="outline_bg">
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</PopoverTrigger>
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-1"
hideCloseBtn
>
<div className="flex flex-col space-y-2">
<Button
onClick={() => downloadSecret(getValues("secrets"), selectedEnv?.slug)}
colorSchema="primary"
variant="outline_bg"
className="h-8 bg-bunker-700"
>
Download as .env
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div>
<Tooltip content={isSecretValueHidden ? "Reveal Secrets" : "Hide secrets"}>
<IconButton
ariaLabel="reveal"
variant="outline_bg"
onClick={() => setIsSecretValueHidden.toggle()}
>
<FontAwesomeIcon icon={isSecretValueHidden ? faEye : faEyeSlash} />
</IconButton>
</Tooltip>
</div>
<div className="block xl:hidden">
<Tooltip content="Point-in-time Recovery">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
onClick={() => handlePopUpOpen("secretSnapshots")}
>
<FontAwesomeIcon icon={faCodeCommit} />
</IconButton>
</Tooltip>
</div>
<div className="hidden xl:block">
<Button
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => setIsSecretValueHidden.toggle()}
if (subscription && subscription.pitRecovery) {
handlePopUpOpen("secretSnapshots");
return;
}
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isLoadingSnapshotCount}
isDisabled={!canDoRollback}
className="h-10"
> >
{snapshotCount} Commits <FontAwesomeIcon icon={isSecretValueHidden ? faEye : faEyeSlash} />
</Button> </IconButton>
</div> </Tooltip>
{!isReadOnly && !isRollbackMode && ( </div>
<div className="flex flex-row items-center justify-center"> <div className="block xl:hidden">
<button <Tooltip content="Point-in-time Recovery">
type="button" <IconButton
onClick={() => { ariaLabel="recovery"
if (!(isReadOnly || isRollbackMode)) { variant="outline_bg"
if (secretContainer.current) { onClick={() => handlePopUpOpen("secretSnapshots")}
secretContainer.current.scroll({
top: 0,
behavior: "smooth"
});
}
prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
setSearchFilter("");
}
}}
className="font-semibold bg-mineshaft-600 border border-mineshaft-500 p-2 rounded-l-md text-sm text-mineshaft-300 cursor-pointer hover:bg-primary/[0.1] hover:border-primary/40 pr-4 duration-200"
>
<FontAwesomeIcon icon={faPlus} className="px-2"/>Add Secret
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
<div className="bg-mineshaft-600 border border-mineshaft-500 p-2 rounded-r-md text-sm text-mineshaft-300 cursor-pointer hover:bg-primary/[0.1] hover:border-primary/40 duration-200">
<FontAwesomeIcon icon={faAngleDown} className="pr-2 pl-1.5"/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="mt-1 z-[60] left-20 w-[10.8rem]">
<div className="bg-mineshaft-800 p-1 border border-mineshaft-600 rounded-md">
<div className="w-full pb-1">
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => handlePopUpOpen("folderForm")}
isDisabled={isReadOnly || isRollbackMode}
variant="outline_bg"
className="h-10"
isFullWidth
>
Add Folder
</Button>
</div>
<div className="w-full">
<Button
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
onClick={() => handlePopUpOpen("addSecretImport")}
isDisabled={isReadOnly || isRollbackMode}
variant="outline_bg"
className="h-10"
isFullWidth
>
Add Import
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{isRollbackMode && (
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
> >
Go back <FontAwesomeIcon icon={faCodeCommit} />
</Button> </IconButton>
)} </Tooltip>
</div>
<div className="hidden xl:block">
<Button <Button
isDisabled={isSubmitDisabled} variant="outline_bg"
isLoading={isSubmitting} onClick={() => {
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />} if (subscription && subscription.pitRecovery) {
onClick={handleSubmit(onSaveSecret)} handlePopUpOpen("secretSnapshots");
className="h-10 text-black" return;
color="primary" }
variant="solid"
handlePopUpOpen("upgradePlan");
}}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isLoadingSnapshotCount}
isDisabled={!canDoRollback}
className="h-10"
> >
{isRollbackMode ? "Rollback" : "Save Changes"} {snapshotCount} Commits
</Button> </Button>
</div> </div>
</div> {!isReadOnly && !isRollbackMode && (
<div <div className="flex flex-row items-center justify-center">
className={`${ <button
isEmptyPage ? "flex flex-col items-center justify-center" : "" type="button"
} no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`} onClick={() => {
ref={secretContainer} if (!(isReadOnly || isRollbackMode)) {
> if (secretContainer.current) {
{!isEmptyPage && ( secretContainer.current.scroll({
<DndContext top: 0,
onDragEnd={handleDragEnd} behavior: "smooth"
sensors={sensors} });
collisionDetection={closestCenter} }
modifiers={[restrictToVerticalAxis]} prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
> setSearchFilter("");
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar"> }
<table className="secret-table relative"> }}
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} /> className="cursor-pointer rounded-l-md border border-mineshaft-500 bg-mineshaft-600 p-2 pr-4 text-sm font-semibold text-mineshaft-300 duration-200 hover:border-primary/40 hover:bg-primary/[0.1]"
<tbody className="max-h-96 overflow-y-auto"> >
<SecretImportSection <FontAwesomeIcon icon={faPlus} className="px-2" />
onSecretImportDelete={(impSecEnv, impSecPath) => Add Secret
handlePopUpOpen("deleteSecretImport", { </button>
environment: impSecEnv, <DropdownMenu>
secretPath: impSecPath <DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
}) <div className="cursor-pointer rounded-r-md border border-mineshaft-500 bg-mineshaft-600 p-2 text-sm text-mineshaft-300 duration-200 hover:border-primary/40 hover:bg-primary/[0.1]">
} <FontAwesomeIcon icon={faAngleDown} className="pr-2 pl-1.5" />
secrets={secrets?.secrets} </div>
importedSecrets={importedSecrets} </DropdownMenuTrigger>
items={items} <DropdownMenuContent align="end" className="left-20 z-[60] mt-1 w-[10.8rem]">
/> <div className="rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<FolderSection <div className="w-full pb-1">
onFolderOpen={handleFolderOpen} <Button
onFolderUpdate={(id, name) => handlePopUpOpen("folderForm", { id, name })} leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onFolderDelete={(id, name) => handlePopUpOpen("deleteFolder", { id, name })} onClick={() => handlePopUpOpen("folderForm")}
folders={folderList} isDisabled={isReadOnly || isRollbackMode}
search={searchFilter} variant="outline_bg"
/> className="h-10"
{fields.map(({ id, _id }, index) => ( isFullWidth
<SecretInputRow >
key={id} Add Folder
isReadOnly={isReadOnly} </Button>
isRollbackMode={isRollbackMode} </div>
isAddOnly={isAddOnly} <div className="w-full">
index={index} <Button
searchTerm={searchFilter} leftIcon={<FontAwesomeIcon icon={faFileImport} />}
onSecretDelete={onSecretDelete} onClick={() => handlePopUpOpen("addSecretImport")}
onRowExpand={() => onDrawerOpen({ id: _id as string, index })} isDisabled={isReadOnly || isRollbackMode}
isSecretValueHidden={isSecretValueHidden} variant="outline_bg"
wsTags={wsTags} className="h-10"
onCreateTagOpen={() => handlePopUpOpen("addTag")} isFullWidth
/> >
))} Add Import
{!isReadOnly && !isRollbackMode && ( </Button>
<tr> </div>
<td colSpan={3} className="hover:bg-mineshaft-700"> </div>
<button </DropdownMenuContent>
type="button" </DropdownMenu>
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300" </div>
onClick={onAppendSecret}
>
<FontAwesomeIcon icon={faPlus} />
<span className="ml-2 w-20">Add Secret</span>
</button>
</td>
</tr>
)}
</tbody>
</table>
</TableContainer>
</DndContext>
)} )}
{isRollbackMode && (
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
)}
<Button
isDisabled={isSubmitDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />}
onClick={handleSubmit(onSaveSecret)}
className="h-10 text-black"
color="primary"
variant="solid"
>
{isRollbackMode ? "Rollback" : "Save Changes"}
</Button>
</div>
</div>
<div
className={`${
isEmptyPage ? "flex flex-col items-center justify-center" : ""
} no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`}
ref={secretContainer}
>
{!isEmptyPage && (
<DndContext
onDragEnd={handleDragEnd}
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
>
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
<table className="secret-table relative">
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
<tbody className="max-h-96 overflow-y-auto">
<SecretImportSection
onSecretImportDelete={handleSecretImportDelPopUpOpen}
secrets={secrets?.secrets}
importedSecrets={importedSecrets}
items={items}
/>
<FolderSection
onFolderOpen={handleFolderOpen}
onFolderUpdate={handleFolderCreatePopUpOpen}
onFolderDelete={handleFolderDeletePopUpOpen}
folders={folderList}
search={searchFilter}
/>
{fields.map(({ id, _id }, index) => (
<SecretInputRow
key={id}
secUniqId={_id}
isReadOnly={isReadOnly}
isRollbackMode={isRollbackMode}
isAddOnly={isAddOnly}
index={index}
searchTerm={searchFilter}
onSecretDelete={onSecretDelete}
isKeyError={Boolean(errors?.secrets?.[index]?.key?.message)}
keyError={errors?.secrets?.[index]?.key?.message}
onRowExpand={onDrawerOpen}
isSecretValueHidden={isSecretValueHidden}
wsTags={wsTags}
onCreateTagOpen={handleCreateTagModalOpen}
register={register}
control={control}
setValue={setValue}
/>
))}
{!isReadOnly && !isRollbackMode && (
<tr>
<td colSpan={3} className="hover:bg-mineshaft-700">
<button
type="button"
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
onClick={onAppendSecret}
>
<FontAwesomeIcon icon={faPlus} />
<span className="ml-2 w-20">Add Secret</span>
</button>
</td>
</tr>
)}
</tbody>
</table>
</TableContainer>
</DndContext>
)}
<FormProvider {...method}>
<PitDrawer <PitDrawer
isDrawerOpen={popUp?.secretSnapshots?.isOpen} isDrawerOpen={popUp?.secretSnapshots?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("secretSnapshots", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("secretSnapshots", isOpen)}
@@ -966,120 +1004,121 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index}
onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)}
/> />
<SecretDropzone </FormProvider>
isSmaller={!isEmptyPage}
onParsedEnv={handleUploadedEnv} <SecretDropzone
onAddNewSecret={onAppendSecret} isSmaller={!isEmptyPage}
/> onParsedEnv={handleUploadedEnv}
</div> onAddNewSecret={onAppendSecret}
{/* secrets table and drawers, modals */} />
</form> </div>
{/* Create a new tag modal */} {/* secrets table and drawers, modals */}
<Modal </form>
isOpen={popUp?.addTag?.isOpen} {/* Create a new tag modal */}
onOpenChange={(open) => { <Modal
handlePopUpToggle("addTag", open); isOpen={popUp?.addTag?.isOpen}
}} onOpenChange={(open) => {
handlePopUpToggle("addTag", open);
}}
>
<ModalContent
title="Create tag"
subTitle="Specify your tag name, and the slug will be created automatically."
> >
<ModalContent <CreateTagModal onCreateTag={onCreateWsTag} />
title="Create tag" </ModalContent>
subTitle="Specify your tag name, and the slug will be created automatically." </Modal>
> {/* Uploaded env override or not confirmation modal */}
<CreateTagModal onCreateTag={onCreateWsTag} /> <Modal
</ModalContent> isOpen={popUp?.uploadedSecOpts?.isOpen}
</Modal> onOpenChange={(open) => handlePopUpToggle("uploadedSecOpts", open)}
{/* Uploaded env override or not confirmation modal */} >
<Modal <ModalContent
isOpen={popUp?.uploadedSecOpts?.isOpen} title="Duplicate Secrets"
onOpenChange={(open) => handlePopUpToggle("uploadedSecOpts", open)} footerContent={[
<Button
key="keep-old-btn"
className="mr-4"
onClick={() => handlePopUpClose("uploadedSecOpts")}
>
Keep old
</Button>,
<Button colorSchema="danger" key="overwrite-btn" onClick={onOverwriteSecrets}>
Overwrite
</Button>
]}
> >
<ModalContent <div className="flex flex-col space-y-2 text-gray-300">
title="Duplicate Secrets" <div>Your file contains following duplicate secrets</div>
footerContent={[ <div className="text-sm text-gray-400">
<Button {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {})
key="keep-old-btn" ?.map((key) => key)
className="mr-4" .join(", ")}
onClick={() => handlePopUpClose("uploadedSecOpts")}
>
Keep old
</Button>,
<Button colorSchema="danger" key="overwrite-btn" onClick={onOverwriteSecrets}>
Overwrite
</Button>
]}
>
<div className="flex flex-col space-y-2 text-gray-300">
<div>Your file contains following duplicate secrets</div>
<div className="text-sm text-gray-400">
{Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {})
?.map((key) => key)
.join(", ")}
</div>
<div>Are you sure you want to overwrite these secrets?</div>
</div> </div>
</ModalContent> <div>Are you sure you want to overwrite these secrets?</div>
</Modal> </div>
<Modal </ModalContent>
isOpen={popUp?.folderForm?.isOpen} </Modal>
onOpenChange={(isOpen) => handlePopUpToggle("folderForm", isOpen)} <Modal
isOpen={popUp?.folderForm?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("folderForm", isOpen)}
>
<ModalContent title={isEditFolder ? "Edit Folder" : "Create Folder"}>
<FolderForm
isEdit={isEditFolder}
onUpdateFolder={handleFolderUpdate}
onCreateFolder={handleFolderCreate}
defaultFolderName={(popUp?.folderForm?.data as TEditFolderForm)?.name}
/>
</ModalContent>
</Modal>
<Modal
isOpen={popUp?.addSecretImport?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
>
<ModalContent
title="Add Secret Link"
subTitle="To inherit secrets from another environment or folder"
> >
<ModalContent title={isEditFolder ? "Edit Folder" : "Create Folder"}> <SecretImportForm
<FolderForm environments={currentWorkspace?.environments}
isEdit={isEditFolder} onCreate={handleSecretImportCreate}
onUpdateFolder={handleFolderUpdate} />
onCreateFolder={handleFolderCreate} </ModalContent>
defaultFolderName={(popUp?.folderForm?.data as TEditFolderForm)?.name} </Modal>
/> <DeleteActionModal
</ModalContent> isOpen={popUp.deleteFolder.isOpen}
</Modal> deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
<Modal title="Do you want to delete this folder?"
isOpen={popUp?.addSecretImport?.isOpen} onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
onOpenChange={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} onDeleteApproved={handleFolderDelete}
/>
<DeleteActionModal
isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>
<Modal
isOpen={popUp?.compareSecrets?.isOpen}
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
>
<ModalContent
title={popUp?.compareSecrets?.data as string}
subTitle="Below is the comparison of secret values across available environments"
overlayClassName="z-[90]"
> >
<ModalContent <CompareSecret
title="Add Secret Link" workspaceId={workspaceId}
subTitle="To inherit secrets from another environment or folder" envs={userAvailableEnvs || []}
> secretKey={popUp?.compareSecrets?.data as string}
<SecretImportForm />
environments={currentWorkspace?.environments} </ModalContent>
onCreate={handleSecretImportCreate} </Modal>
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteFolder.isOpen}
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
title="Do you want to delete this folder?"
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
onDeleteApproved={handleFolderDelete}
/>
<DeleteActionModal
isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>
<Modal
isOpen={popUp?.compareSecrets?.isOpen}
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
>
<ModalContent
title={popUp?.compareSecrets?.data as string}
subTitle="Below is the comparison of secret values across available environments"
overlayClassName="z-[90]"
>
<CompareSecret
workspaceId={workspaceId}
envs={userAvailableEnvs || []}
secretKey={popUp?.compareSecrets?.data as string}
/>
</ModalContent>
</Modal>
</FormProvider>
{subscription && ( {subscription && (
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
@@ -75,12 +75,29 @@ export type FormData = yup.InferType<typeof schema>;
export type TSecretDetailsOpen = { index: number; id: string }; export type TSecretDetailsOpen = { index: number; id: string };
export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> }; export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> };
export const downloadSecret = (secrets: FormData["secrets"] = [], env: string = "unknown") => { export const downloadSecret = (
const finalSecret = secrets.map(({ key, value, valueOverride, overrideAction, comment }) => ({ secrets: FormData["secrets"] = [],
key, importedSecrets: { key: string; value?: string; comment?: string }[] = [],
value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value, env: string = "unknown"
comment ) => {
})); const importSecPos: Record<string, number> = {};
importedSecrets.forEach((el, index) => {
importSecPos[el.key] = index;
});
const finalSecret = [...importedSecrets];
secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => {
const newValue = {
key,
value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value,
comment
};
// can also be zero thus failing
if (typeof importSecPos?.[key] === "undefined") {
finalSecret.push(newValue);
} else {
finalSecret[importSecPos[key]] = newValue;
}
});
let file = ""; let file = "";
finalSecret.forEach(({ key, value, comment }) => { finalSecret.forEach(({ key, value, comment }) => {
@@ -1,135 +0,0 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useRef, useState } from "react";
import { faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSyntaxHighlight } from "@app/hooks";
import { useToggle } from "@app/hooks/useToggle";
type Props = {
secrets: any[] | undefined;
// permission and external state's that decided to hide or show
isReadOnly?: boolean;
isSecretValueHidden: boolean;
userAvailableEnvs?: any[];
};
const SEC_VAL_LINE_HEIGHT = 21;
const MAX_MULTI_LINE = 6;
const DashboardInput = ({
isOverridden,
isSecretValueHidden,
secret,
isReadOnly = true
}: {
isOverridden: boolean;
isSecretValueHidden: boolean;
isReadOnly?: boolean;
secret?: any;
}): JSX.Element => {
const ref = useRef<HTMLElement | null>(null);
const [isFocused, setIsFocused] = useToggle();
const syntaxHighlight = useSyntaxHighlight();
const value = isOverridden ? secret.valueOverride : secret?.value;
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
return (
<td
key={`row-${secret?.key || ""}--`}
className={`flex w-full cursor-default flex-row ${
!(secret?.value || secret?.value === "") ? "bg-red-800/10" : "bg-mineshaft-900/30"
}`}
>
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
<textarea
readOnly={isReadOnly}
value={value}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
<pre className="whitespace-pre-wrap break-words">
<code
ref={ref}
className={`absolute top-1.5 left-3.5 z-10 overflow-auto font-mono text-sm transition-all no-scrollbar ${
isOverridden && "text-primary-300"
} ${
(value || "") === "" && "text-mineshaft-400"
}`}
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
>
{value === undefined ? (
<span className="cursor-default font-sans text-xs italic text-red-500/80">
<FontAwesomeIcon icon={faMinus} className="mt-1" />
</span>
) : (
syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)
)}
</code>
</pre>
</div>
</td>
);
};
export const EnvComparisonRow = ({
secrets,
isSecretValueHidden,
isReadOnly,
userAvailableEnvs
}: Props): JSX.Element => {
const [areValuesHiddenThisRow, setAreValuesHiddenThisRow] = useState(true);
const getSecretByEnv = useCallback(
(secEnv: string, secs?: any[]) => secs?.find(({ env }) => env === secEnv),
[]
);
return (
<tr className="group flex min-w-full flex-row hover:bg-mineshaft-800">
<td className="flex w-10 justify-center border-none px-4">
<div className="flex h-8 w-10 items-center justify-center text-center text-xs text-bunker-400">
<FontAwesomeIcon icon={faKey} />
</div>
</td>
<td className="flex min-w-[200px] flex-row justify-between lg:min-w-[220px] xl:min-w-[250px]">
<div className="flex h-8 cursor-default flex-row items-center justify-center truncate">
{secrets![0].key || ""}
</div>
<button
type="button"
className="invisible mr-1 ml-2 text-bunker-400 hover:text-bunker-300 group-hover:visible"
onClick={() => setAreValuesHiddenThisRow(!areValuesHiddenThisRow)}
>
<FontAwesomeIcon icon={areValuesHiddenThisRow ? faEye : faEyeSlash} />
</button>
</td>
{userAvailableEnvs?.map(({ slug }) => (
<DashboardInput
isReadOnly={isReadOnly}
key={`row-${secrets![0].key || ""}-${slug}`}
isOverridden={false}
secret={getSecretByEnv(slug, secrets)}
isSecretValueHidden={areValuesHiddenThisRow && isSecretValueHidden}
/>
))}
</tr>
);
};
@@ -1,42 +0,0 @@
import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
folderInEnv: Record<string, boolean>;
userAvailableEnvs?: Array<{ slug: string; name: string }>;
folderName: string;
onClick: (folderName: string) => void;
};
export const FolderComparisonRow = ({
folderInEnv = {},
userAvailableEnvs = [],
folderName,
onClick
}: Props) => (
<tr
className="group flex min-w-full cursor-pointer flex-row items-center hover:bg-mineshaft-800"
onClick={() => onClick(folderName)}
>
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">
<FontAwesomeIcon icon={faFolder} className="text-primary-700" />
</div>
</td>
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[200px] xl:min-w-[250px]">
<div className="flex h-8 flex-row items-center truncate">{folderName}</div>
</td>
{userAvailableEnvs?.map(({ slug }) => (
<td
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
folderInEnv[slug]
? "bg-mineshaft-900/30 text-green-500/80"
: "bg-red-800/10 text-red-500/80"
}`}
key={`${folderName}-${slug}`}
>
<FontAwesomeIcon icon={folderInEnv[slug] ? faCheck : faXmark} />
</td>
))}
</tr>
);
@@ -1 +0,0 @@
export { EnvComparisonRow } from "./EnvComparisonRow";
@@ -1,3 +1,4 @@
import { memo } from "react";
import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -11,70 +12,74 @@ type Props = {
onFolderOpen: (folderId: string) => void; onFolderOpen: (folderId: string) => void;
}; };
export const FolderSection = ({ export const FolderSection = memo(
onFolderUpdate: handleFolderUpdate, ({
onFolderDelete: handleFolderDelete, onFolderUpdate: handleFolderUpdate,
onFolderOpen: handleFolderOpen, onFolderDelete: handleFolderDelete,
search = "", onFolderOpen: handleFolderOpen,
folders = [] search = "",
}: Props) => { folders = []
return ( }: Props) => {
<> return (
{folders <>
.filter(({ name }) => name.toLowerCase().includes(search.toLowerCase())) {folders
.map(({ id, name }) => ( .filter(({ name }) => name.toLowerCase().includes(search.toLowerCase()))
<tr .map(({ id, name }) => (
key={id} <tr
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700" key={id}
> className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
<td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
<FontAwesomeIcon icon={faFolder} className="text-primary-700" />
</td>
<td
colSpan={2}
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
style={{ paddingTop: "0", paddingBottom: "0" }}
> >
<div <td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
className="flex-grow cursor-default p-2" <FontAwesomeIcon icon={faFolder} className="text-primary-700" />
onKeyDown={() => null} </td>
tabIndex={0} <td
role="button" colSpan={2}
onClick={() => handleFolderOpen(id)} className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
style={{ paddingTop: "0", paddingBottom: "0" }}
> >
{name} <div
</div> className="flex-grow cursor-default p-2"
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all"> onKeyDown={() => null}
<div className="opacity-0 group-hover:opacity-100"> tabIndex={0}
<Tooltip content="Settings" className="capitalize"> role="button"
<IconButton onClick={() => handleFolderOpen(id)}
size="md" >
colorSchema="primary" {name}
variant="plain"
onClick={() => handleFolderUpdate(id, name)}
ariaLabel="expand"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div> </div>
<div className="opacity-0 group-hover:opacity-100"> <div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
<Tooltip content="Delete" className="capitalize"> <div className="opacity-0 group-hover:opacity-100">
<IconButton <Tooltip content="Settings" className="capitalize">
size="md" <IconButton
variant="plain" size="md"
colorSchema="danger" colorSchema="primary"
ariaLabel="delete" variant="plain"
onClick={() => handleFolderDelete(id, name)} onClick={() => handleFolderUpdate(id, name)}
> ariaLabel="expand"
<FontAwesomeIcon icon={faXmark} size="lg" /> >
</IconButton> <FontAwesomeIcon icon={faEdit} />
</Tooltip> </IconButton>
</Tooltip>
</div>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete" className="capitalize">
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
onClick={() => handleFolderDelete(id, name)}
>
<FontAwesomeIcon icon={faXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
</div> </div>
</div> </td>
</td> </tr>
</tr> ))}
))} </>
</> );
); }
}; );
FolderSection.displayName = "FolderSection";
@@ -9,7 +9,7 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, IconButton, TableContainer, Tooltip } from "@app/components/v2"; import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks/useToggle"; import { useToggle } from "@app/hooks/useToggle";
@@ -138,7 +138,7 @@ export const SecretImportItem = ({
{key} {key}
</td> </td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{value} <SecretInput value={value} isDisabled isVisible />
</td> </td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} /> <EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
@@ -1,3 +1,4 @@
import { memo } from "react";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
@@ -62,32 +63,31 @@ type Props = {
items: { id: string; environment: string; secretPath: string }[]; items: { id: string; environment: string; secretPath: string }[];
}; };
export const SecretImportSection = ({ export const SecretImportSection = memo(
secrets = [], ({ secrets = [], importedSecrets = [], onSecretImportDelete, items = [] }: Props) => {
importedSecrets = [], const { currentWorkspace } = useWorkspace();
onSecretImportDelete, const environments = currentWorkspace?.environments || [];
items = []
}: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
return ( return (
<SortableContext items={items} strategy={verticalListSortingStrategy}> <SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => ( {items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => (
<SecretImportItem <SecretImportItem
key={id} key={id}
importedEnv={importSecEnv} importedEnv={importSecEnv}
importedSecrets={computeImportedSecretRows( importedSecrets={computeImportedSecretRows(
importSecEnv, importSecEnv,
impSecPath, impSecPath,
importedSecrets, importedSecrets,
secrets, secrets,
environments environments
)} )}
onDelete={onSecretImportDelete} onDelete={onSecretImportDelete}
importedSecPath={impSecPath} importedSecPath={impSecPath}
/> />
))} ))}
</SortableContext> </SortableContext>
); );
}; }
);
SecretImportSection.displayName = "SecretImportSection";
@@ -1,107 +0,0 @@
import { useRef } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useSyntaxHighlight, useToggle } from "@app/hooks";
import { FormData } from "../../DashboardPage.utils";
type Props = {
isReadOnly?: boolean;
isSecretValueHidden?: boolean;
isOverridden?: boolean;
index: number;
};
const SEC_VAL_LINE_HEIGHT = 21;
const MAX_MULTI_LINE = 6;
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
const { control } = useFormContext<FormData>();
const ref = useRef<HTMLElement | null>(null);
const [isFocused, setIsFocused] = useToggle();
const syntaxHighlight = useSyntaxHighlight();
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
const value = isOverridden ? secretValueOverride : secretValue;
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
return (
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
{isOverridden ? (
<Controller
control={control}
name={`secrets.${index}.valueOverride`}
render={({ field }) => (
<textarea
key={`secrets.${index}.valueOverride`}
{...field}
readOnly={isReadOnly}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
)}
/>
) : (
<Controller
control={control}
name={`secrets.${index}.value`}
key={`secrets.${index}.value`}
render={({ field }) => (
<textarea
{...field}
readOnly={isReadOnly}
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
style={{ height: `${maxMultilineHeight}px` }}
spellCheck="false"
onBlur={() => setIsFocused.off()}
onFocus={() => setIsFocused.on()}
onInput={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
onScroll={(el) => {
if (ref.current) {
ref.current.scrollTop = el.currentTarget.scrollTop;
ref.current.scrollLeft = el.currentTarget.scrollLeft;
}
}}
/>
)}
/>
)}
<pre className="whitespace-pre-wrap break-words">
<code
ref={ref}
className={`absolute top-1.5 left-3.5 z-10 w-full overflow-auto font-mono text-sm transition-all no-scrollbar ${
isOverridden && "text-primary-300"
} ${
(value || "") === "" && "text-mineshaft-400"
}`}
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
>
{syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)}
</code>
</pre>
</div>
);
};
@@ -1,6 +1,13 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { memo, useEffect, useRef } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form"; import {
Control,
Controller,
useFieldArray,
UseFormRegister,
UseFormSetValue,
useWatch
} from "react-hook-form";
import { import {
faCheck, faCheck,
faCodeBranch, faCodeBranch,
@@ -28,6 +35,7 @@ import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
SecretInput,
Tag, Tag,
TextArea, TextArea,
Tooltip Tooltip
@@ -36,24 +44,6 @@ import { useToggle } from "@app/hooks";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
import { FormData, SecretActionType } from "../../DashboardPage.utils"; import { FormData, SecretActionType } from "../../DashboardPage.utils";
import { MaskedInput } from "./MaskedInput";
type Props = {
index: number;
// permission and external state's that decided to hide or show
isReadOnly?: boolean;
isAddOnly?: boolean;
isRollbackMode?: boolean;
isSecretValueHidden: boolean;
searchTerm: string;
// to record the ids of deleted ones
onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
// sidebar control props
onRowExpand: () => void;
// tag props
wsTags?: WsTag[];
onCreateTagOpen: () => void;
};
const tagColors = [ const tagColors = [
{ bg: "bg-[#f1c40f]/40", text: "text-[#fcf0c3]/70" }, { bg: "bg-[#f1c40f]/40", text: "text-[#fcf0c3]/70" },
@@ -66,6 +56,31 @@ const tagColors = [
{ bg: "bg-[#332FD0]/40", text: "text-[#DFF6FF]/70" } { bg: "bg-[#332FD0]/40", text: "text-[#DFF6FF]/70" }
]; ];
type Props = {
index: number;
// backend generated unique id
secUniqId?: string;
// permission and external state's that decided to hide or show
isReadOnly?: boolean;
isAddOnly?: boolean;
isRollbackMode?: boolean;
isSecretValueHidden: boolean;
searchTerm: string;
// to record the ids of deleted ones
onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
// sidebar control props
onRowExpand: (secId: string | undefined, index: number) => void;
// tag props
wsTags?: WsTag[];
onCreateTagOpen: () => void;
// rhf specific functions, dont put this using useFormContext. This is passed as props to avoid re-rendering
control: Control<FormData>;
register: UseFormRegister<FormData>;
setValue: UseFormSetValue<FormData>;
isKeyError?: boolean;
keyError?: string;
};
export const SecretInputRow = memo( export const SecretInputRow = memo(
({ ({
index, index,
@@ -77,10 +92,15 @@ export const SecretInputRow = memo(
wsTags, wsTags,
onCreateTagOpen, onCreateTagOpen,
onSecretDelete, onSecretDelete,
searchTerm searchTerm,
control,
register,
setValue,
isKeyError,
keyError,
secUniqId
}: Props): JSX.Element => { }: Props): JSX.Element => {
const isKeySubDisabled = useRef<boolean>(false); const isKeySubDisabled = useRef<boolean>(false);
const { register, setValue, control } = useFormContext<FormData>();
// comment management in a row // comment management in a row
const { const {
fields: secretTags, fields: secretTags,
@@ -89,28 +109,40 @@ export const SecretInputRow = memo(
} = useFieldArray({ control, name: `secrets.${index}.tags` }); } = useFieldArray({ control, name: `secrets.${index}.tags` });
// to get details on a secret // to get details on a secret
const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` }); const overrideAction = useWatch({
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride` }); control,
const secComment = useWatch({ control, name: `secrets.${index}.comment` }); name: `secrets.${index}.overrideAction`,
exact: true
});
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride`, exact: true });
const secComment = useWatch({ control, name: `secrets.${index}.comment`, exact: true });
const hasComment = Boolean(secComment); const hasComment = Boolean(secComment);
const secKey = useWatch({ const secKey = useWatch({
control, control,
name: `secrets.${index}.key`, name: `secrets.${index}.key`,
disabled: isKeySubDisabled.current disabled: isKeySubDisabled.current,
exact: true
}); });
const secValue = useWatch({ const secValue = useWatch({
control, control,
name: `secrets.${index}.value`, name: `secrets.${index}.value`,
disabled: isKeySubDisabled.current disabled: isKeySubDisabled.current,
exact: true
}); });
const secValueOverride = useWatch({ const secValueOverride = useWatch({
control, control,
name: `secrets.${index}.valueOverride`, name: `secrets.${index}.valueOverride`,
disabled: isKeySubDisabled.current disabled: isKeySubDisabled.current,
}) exact: true
const secId = useWatch({ control, name: `secrets.${index}._id` }); });
// when secret is override by personal values
const isOverridden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const [editorRef, setEditorRef] = useState(isOverridden ? secValueOverride : secValue);
const tags = useWatch({ control, name: `secrets.${index}.tags`, defaultValue: [] }) || []; const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true });
const tags =
useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || [];
const selectedTagIds = tags.reduce<Record<string, boolean>>( const selectedTagIds = tags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.slug]: true }), (prev, curr) => ({ ...prev, [curr.slug]: true }),
{} {}
@@ -126,15 +158,15 @@ export const SecretInputRow = memo(
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isInviteLinkCopied]); }, [isInviteLinkCopied]);
useEffect(() => {
setEditorRef(isOverridden ? secValueOverride : secValue);
}, [isOverridden]);
const copyTokenToClipboard = () => { const copyTokenToClipboard = () => {
navigator.clipboard.writeText((secValueOverride || secValue) as string); navigator.clipboard.writeText((secValueOverride || secValue) as string);
setInviteLinkCopied.on(); setInviteLinkCopied.on();
}; };
// when secret is override by personal values
const isOverridden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const onSecretOverride = () => { const onSecretOverride = () => {
if (isOverridden) { if (isOverridden) {
// when user created a new override but then removes // when user created a new override but then removes
@@ -193,10 +225,10 @@ export const SecretInputRow = memo(
control={control} control={control}
defaultValue="" defaultValue=""
name={`secrets.${index}.key`} name={`secrets.${index}.key`}
render={({ fieldState: { error }, field }) => ( render={({ field }) => (
<HoverCard openDelay={0} open={error?.message ? undefined : false}> <HoverCard openDelay={0} open={isKeyError ? undefined : false}>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<td className={cx(error?.message ? "rounded ring ring-red/50" : null)}> <td className={cx(isKeyError ? "rounded ring ring-red/50" : null)}>
<div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]"> <div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]">
<Input <Input
autoComplete="off" autoComplete="off"
@@ -220,21 +252,70 @@ export const SecretInputRow = memo(
<div> <div>
<FontAwesomeIcon icon={faInfoCircle} className="text-red" /> <FontAwesomeIcon icon={faInfoCircle} className="text-red" />
</div> </div>
<div className="text-sm">{error?.message}</div> <div className="text-sm">{keyError}</div>
</div> </div>
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
)} )}
/> />
<td className="flex w-full flex-grow flex-row border-r border-none border-red"> <td
<MaskedInput className="flex w-full flex-grow flex-row border-r border-none border-red"
isReadOnly={ style={{ padding: "0.5rem 0 0.5rem 1rem" }}
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly) >
} <div className="w-full">
isOverridden={isOverridden} {isOverridden ? (
isSecretValueHidden={isSecretValueHidden} <Controller
index={index} control={control}
/> name={`secrets.${index}.valueOverride`}
render={({ field: { onChange, onBlur } }) => (
<SecretInput
key={`secrets.${index}.valueOverride`}
isDisabled={
isReadOnly ||
isRollbackMode ||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
}
value={editorRef}
isVisible={!isSecretValueHidden}
onChange={(val, html) => {
console.log(val);
onChange(val);
setEditorRef(html);
}}
onBlur={(html) => {
setEditorRef(html);
onBlur();
}}
/>
)}
/>
) : (
<Controller
control={control}
name={`secrets.${index}.value`}
render={({ field: { onBlur, onChange } }) => (
<SecretInput
key={`secrets.${index}.value`}
isVisible={!isSecretValueHidden}
isDisabled={
isReadOnly ||
isRollbackMode ||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
}
onChange={(val, html) => {
onChange(val);
setEditorRef(html);
}}
value={editorRef}
onBlur={(html) => {
setEditorRef(html);
onBlur();
}}
/>
)}
/>
)}
</div>
</td> </td>
<td className="min-w-sm flex"> <td className="min-w-sm flex">
<div className="flex h-8 items-center pl-2"> <div className="flex h-8 items-center pl-2">
@@ -251,7 +332,7 @@ export const SecretInputRow = memo(
{slug} {slug}
</Tag> </Tag>
))} ))}
<div className="w-0 group-hover:w-6 overflow-hidden"> <div className="w-0 overflow-hidden group-hover:w-6">
<Tooltip content="Copy value"> <Tooltip content="Copy value">
<IconButton <IconButton
variant="plain" variant="plain"
@@ -396,7 +477,7 @@ export const SecretInputRow = memo(
size="lg" size="lg"
colorSchema="primary" colorSchema="primary"
variant="plain" variant="plain"
onClick={onRowExpand} onClick={() => onRowExpand(secUniqId, index)}
ariaLabel="expand" ariaLabel="expand"
> >
<FontAwesomeIcon icon={faEllipsis} /> <FontAwesomeIcon icon={faEllipsis} />
@@ -53,7 +53,7 @@ export const OrgIncidentContactsTable = ({
isLoading isLoading
}: Props) => { }: Props) => {
const [searchContact, setSearchContact] = useState(""); const [searchContact, setSearchContact] = useState("");
const {data: serverDetails } = useFetchServerStatus() const { data: serverDetails } = useFetchServerStatus();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addContact", "addContact",
"removeContact", "removeContact",
@@ -98,7 +98,7 @@ export const OrgIncidentContactsTable = ({
<Button <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (serverDetails?.emailConfigured){ if (serverDetails?.emailConfigured) {
handlePopUpOpen("addContact"); handlePopUpOpen("addContact");
} else { } else {
handlePopUpOpen("setUpEmail"); handlePopUpOpen("setUpEmail");
@@ -119,7 +119,7 @@ export const OrgIncidentContactsTable = ({
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={2} key="incident-contact" />} {isLoading && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email }) => ( {filteredContacts?.map(({ email }) => (
<Tr key={email}> <Tr key={email}>
<Td className="w-full">{email}</Td> <Td className="w-full">{email}</Td>
@@ -1,7 +1,14 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons"; import {
faCheck,
faCopy,
faMagnifyingGlass,
faPlus,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
@@ -10,7 +17,8 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmailServiceSetupModal, EmptyState, EmailServiceSetupModal,
EmptyState,
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
@@ -29,7 +37,7 @@ import {
Tr, Tr,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization , useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useGetSSOConfig } from "@app/hooks/api"; import { useGetSSOConfig } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
@@ -47,8 +55,8 @@ type Props = {
onGrantAccess: (userId: string, publicKey: string) => Promise<void>; onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
// the current user id to block remove org button // the current user id to block remove org button
userId: string; userId: string;
completeInviteLink: string | undefined, completeInviteLink: string | undefined;
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>> setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>;
}; };
const addMemberFormSchema = yup.object({ const addMemberFormSchema = yup.object({
@@ -76,7 +84,7 @@ export const OrgMembersTable = ({
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? ""); const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? "");
const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [searchMemberFilter, setSearchMemberFilter] = useState("");
const {data: serverDetails } = useFetchServerStatus() const { data: serverDetails } = useFetchServerStatus();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -101,11 +109,11 @@ export const OrgMembersTable = ({
const onAddMember = async ({ email }: TAddMemberForm) => { const onAddMember = async ({ email }: TAddMemberForm) => {
await onInviteMember(email); await onInviteMember(email);
if (serverDetails?.emailConfigured){ if (serverDetails?.emailConfigured) {
handlePopUpClose("addMember"); handlePopUpClose("addMember");
} }
reset(); reset();
}; };
const onRemoveOrgMemberApproved = async () => { const onRemoveOrgMemberApproved = async () => {
@@ -190,7 +198,7 @@ export const OrgMembersTable = ({
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={5} key="org-members" />} {isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading && {!isLoading &&
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
const name = user ? `${user.firstName} ${user.lastName}` : "-"; const name = user ? `${user.firstName} ${user.lastName}` : "-";
@@ -219,11 +227,17 @@ export const OrgMembersTable = ({
<SelectItem value="member">member</SelectItem> <SelectItem value="member">member</SelectItem>
</Select> </Select>
)} )}
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && ( {(status === "invited" || status === "verified") &&
<Button className='w-40' colorSchema="primary" variant="outline_bg" onClick={() => onInviteMember(email)}> serverDetails?.emailConfigured && (
Resend Invite <Button
</Button> className="w-40"
)} colorSchema="primary"
variant="outline_bg"
onClick={() => onInviteMember(email)}
>
Resend Invite
</Button>
)}
{status === "completed" && ( {status === "completed" && (
<Button <Button
colorSchema="secondary" colorSchema="secondary"
@@ -241,18 +255,33 @@ export const OrgMembersTable = ({
</Tag> </Tag>
)) ))
) : ( ) : (
<div className='flex flex-row'> <div className="flex flex-row">
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) {(status === "invited" || status === "verified") &&
? <Tag colorSchema="red">This user hasn&apos;t accepted the invite yet</Tag> serverDetails?.emailConfigured ? (
: <Tag colorSchema="red">This user isn&apos;t part of any projects yet</Tag>} <Tag colorSchema="red">
{router.query.id !== "undefined" && !((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && <button This user hasn&apos;t accepted the invite yet
type="button" </Tag>
onClick={() => router.push(`/project/${workspaces[0]?._id}/members`)} ) : (
className='text-sm bg-mineshaft w-max px-1.5 py-0.5 hover:bg-primary duration-200 hover:text-black cursor-pointer rounded-sm' <Tag colorSchema="red">
> This user isn&apos;t part of any projects yet
<FontAwesomeIcon icon={faPlus} className="mr-1" /> </Tag>
Add to projects )}
</button>} {router.query.id !== "undefined" &&
!(
(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured
) && (
<button
type="button"
onClick={() =>
router.push(`/project/${workspaces[0]?._id}/members`)
}
className="w-max cursor-pointer rounded-sm bg-mineshaft px-1.5 py-0.5 text-sm duration-200 hover:bg-primary hover:text-black"
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
Add to projects
</button>
)}
</div> </div>
)} )}
</Td> </Td>
@@ -282,67 +311,73 @@ export const OrgMembersTable = ({
isOpen={popUp?.addMember?.isOpen} isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen); handlePopUpToggle("addMember", isOpen);
setCompleteInviteLink(undefined) setCompleteInviteLink(undefined);
}} }}
> >
<ModalContent <ModalContent
title={`Invite others to ${orgName}`} title={`Invite others to ${orgName}`}
subTitle={ subTitle={
<div> <div>
{!completeInviteLink && <div> {!completeInviteLink && (
An invite is specific to an email address and expires after 1 day. <div>
<br /> An invite is specific to an email address and expires after 1 day.
For security reasons, you will need to separately add members to projects. <br />
</div>} For security reasons, you will need to separately add members to projects.
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} </div>
)}
{completeInviteLink &&
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div> </div>
} }
> >
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} > {!completeInviteLink && (
<Controller <form onSubmit={handleSubmit(onAddMember)}>
control={control} <Controller
defaultValue="" control={control}
name="email" defaultValue=""
render={({ field, fieldState: { error } }) => ( name="email"
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}> render={({ field, fieldState: { error } }) => (
<Input {...field} /> <FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
</FormControl> <Input {...field} />
)} </FormControl>
/> )}
<div className="mt-8 flex items-center"> />
<Button <div className="mt-8 flex items-center">
className="mr-4" <Button
size="sm" className="mr-4"
type="submit" size="sm"
isLoading={isSubmitting} type="submit"
isDisabled={isSubmitting} isLoading={isSubmitting}
> isDisabled={isSubmitting}
Add Member >
</Button> Add Member
<Button </Button>
colorSchema="secondary" <Button
variant="plain" colorSchema="secondary"
onClick={() => handlePopUpClose("addMember")} variant="plain"
> onClick={() => handlePopUpClose("addMember")}
Cancel >
</Button> Cancel
</div> </Button>
</form>} </div>
{ </form>
completeInviteLink && )}
{completeInviteLink && (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400"> <div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{completeInviteLink}</p> <p className="mr-4 break-all">{completeInviteLink}</p>
<IconButton <IconButton
ariaLabel="copy icon" ariaLabel="copy icon"
colorSchema="secondary" colorSchema="secondary"
className="group relative" className="group relative"
onClick={copyTokenToClipboard} onClick={copyTokenToClipboard}
> >
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} /> <FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span> <span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
</IconButton> click to copy
</div> </span>
} </IconButton>
</div>
)}
</ModalContent> </ModalContent>
</Modal> </Modal>
<DeleteActionModal <DeleteActionModal
@@ -1,14 +1,15 @@
import { useEffect, useMemo,useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller,useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
faCheck, faCheck,
faCopy, faCopy,
faMagnifyingGlass, faMagnifyingGlass,
faPencil, faPencil,
faPlus, faPlus,
faServer, faServer,
faTrash} from "@fortawesome/free-solid-svg-icons"; faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
@@ -37,9 +38,10 @@ import {
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { import {
useCreateServiceAccount, useCreateServiceAccount,
useDeleteServiceAccount, useDeleteServiceAccount,
useGetServiceAccounts} from "@app/hooks/api"; useGetServiceAccounts
} from "@app/hooks/api";
const serviceAccountExpiration = [ const serviceAccountExpiration = [
{ label: "1 Day", value: 86400 }, { label: "1 Day", value: 86400 },
@@ -51,317 +53,315 @@ const serviceAccountExpiration = [
]; ];
const addServiceAccountFormSchema = yup.object({ const addServiceAccountFormSchema = yup.object({
name: yup.string().required().label("Name").trim(), name: yup.string().required().label("Name").trim(),
expiresIn: yup.string().required().label("Service Account Expiration") expiresIn: yup.string().required().label("Service Account Expiration")
}); });
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>; type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => { export const OrgServiceAccountsTable = () => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || ""; const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false); const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey, setAccessKey] = useState(""); const [accessKey, setAccessKey] = useState("");
const [publicKey, setPublicKey] = useState(""); const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState(""); const [privateKey, setPrivateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(""); const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount", "addServiceAccount",
"removeServiceAccount", "removeServiceAccount"
] as const); ] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
useGetServiceAccounts(orgId);
const createServiceAccount = useCreateServiceAccount(); const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount(); const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (isAccessKeyCopied) { if (isAccessKeyCopied) {
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
}
if (isPublicKeyCopied) {
timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
}
if (isPrivateKeyCopied) {
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPublicKey(keyPair.publicKey);
setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn: Number(expiresIn)
});
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
} }
const onRemoveServiceAccount = async () => { if (isPublicKeyCopied) {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose("removeServiceAccount");
} }
const filteredServiceAccounts = useMemo( if (isPrivateKeyCopied) {
() => timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
serviceAccounts.filter(
({ name }) =>
name.toLowerCase().includes(searchServiceAccountFilter)
),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<form onSubmit={handleSubmit(onAddServiceAccount)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />
}
} }
return ( return () => clearTimeout(timer);
<div className="w-full"> }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
<div className="mb-4 flex">
<div className="mr-4 flex-1"> const {
<Input control,
value={searchServiceAccountFilter} handleSubmit,
onChange={(e) => setSearchServiceAccountFilter(e.target.value)} reset,
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} formState: { isSubmitting }
placeholder="Search service accounts..." } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
/>
</div> const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
<Button if (!currentOrg?._id) return;
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { const keyPair = generateKeyPair();
setStep(0); setPublicKey(keyPair.publicKey);
reset(); setPrivateKey(keyPair.privateKey);
handlePopUpOpen("addServiceAccount");
}} const serviceAccountDetails = await createServiceAccount.mutateAsync({
> name,
Add Service Account organizationId: currentOrg?._id,
</Button> publicKey: keyPair.publicKey,
</div> expiresIn: Number(expiresIn)
<TableContainer> });
<Table>
<THead> setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
<Th>Name</Th>
<Th className="w-full">Valid Until</Th> setStep(1);
<Th aria-label="actions" /> reset();
</THead> };
<TBody>
{isServiceAccountsLoading && <TableSkeleton columns={5} key="org-service-accounts" />} const onRemoveServiceAccount = async () => {
{!isServiceAccountsLoading && ( const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
filteredServiceAccounts.map(({ await removeServiceAccount.mutateAsync(serviceAccountId);
name, handlePopUpClose("removeServiceAccount");
expiresAt, };
_id: serviceAccountId
}) => { const filteredServiceAccounts = useMemo(
return ( () =>
<Tr key={`org-service-account-${serviceAccountId}`}> serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
<Td>{name}</Td> [serviceAccounts, searchServiceAccountFilter]
<Td>{new Date(expiresAt).toUTCString()}</Td> );
<Td>
<div className="flex"> const renderStep = (stepToRender: number) => {
<IconButton switch (stepToRender) {
ariaLabel="edit" case 0:
colorSchema="secondary" return (
onClick={() => { <form onSubmit={handleSubmit(onAddServiceAccount)}>
if (currentWorkspace?._id) { <Controller
router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); control={control}
} defaultValue=""
}} name="name"
className="mr-2" render={({ field, fieldState: { error } }) => (
> <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<FontAwesomeIcon icon={faPencil} /> <Input {...field} />
</IconButton> </FormControl>
<IconButton )}
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/> />
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />;
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
</div> </div>
); <Button
} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && (
<TableSkeleton columns={5} innerKey="org-service-accounts" />
)}
{!isServiceAccountsLoading &&
filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(
`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() =>
handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
};
@@ -0,0 +1,348 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
import {
Button,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useCreateSecretV3,
useDeleteSecretV3,
useGetFoldersByEnv,
useGetProjectSecretsAllEnv,
useGetUserWsEnvironments,
useGetUserWsKey,
useUpdateSecretV3
} from "@app/hooks/api";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
export const SecretOverviewPage = () => {
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
const router = useRouter();
// this is to set expandable table width
// coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
useEffect(() => {
const handleParentTableWidthResize = () => {
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
};
window.addEventListener("resize", handleParentTableWidthResize);
return () => window.removeEventListener("resize", handleParentTableWidthResize);
}, []);
useEffect(() => {
if (parentTableRef.current) {
setExpandableTableWidth(parentTableRef.current.clientWidth);
}
}, [parentTableRef.current]);
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const { currentOrg } = useOrganization();
const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState("");
const secretPath = router.query?.secretPath as string;
useEffect(() => {
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
router.push(`/org/${currentOrg?._id}/overview`);
}
}, [isWorkspaceLoading, workspaceId, router.isReady]);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId
});
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied) || [];
const {
data: secrets,
getSecretByKey,
secKeys,
getEnvSecretKeyCount
} = useGetProjectSecretsAllEnv({
workspaceId,
envs: userAvailableEnvs.map(({ slug }) => slug),
secretPath,
decryptFileKey: latestFileKey!
});
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
workspaceId,
environments: userAvailableEnvs.map(({ slug }) => slug),
parentFolderPath: secretPath
});
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3();
const handleSecretCreate = async (env: string, key: string, value: string) => {
try {
await createSecretV3({
environment: env,
workspaceId,
secretPath,
secretName: key,
secretValue: value,
secretComment: "",
type: "shared",
latestFileKey: latestFileKey!
});
createNotification({
type: "success",
text: "Successfully created secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to create secret"
});
}
};
const handleSecretUpdate = async (env: string, key: string, value: string) => {
try {
await updateSecretV3({
environment: env,
workspaceId,
secretPath,
secretName: key,
secretValue: value,
type: "shared",
latestFileKey: latestFileKey!
});
createNotification({
type: "success",
text: "Successfully updated secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to update secret"
});
}
};
const handleSecretDelete = async (env: string, key: string) => {
try {
await deleteSecretV3({
environment: env,
workspaceId,
secretPath,
secretName: key,
type: "shared"
});
createNotification({
type: "success",
text: "Successfully deleted secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete secret"
});
}
};
const handleResetSearch = () => setSearchFilter("");
const handleFolderClick = (path: string) => {
router.push({
pathname: router.pathname,
query: {
...router.query,
secretPath: `${router.query?.secretPath || ""}/${path}`
}
});
};
const handleExploreEnvClick = (slug: string) => {
const query: Record<string, string> = { ...router.query, env: slug };
delete query.secretPath;
// the dir return will have the present directory folder id
// use that when clicking on explore to redirect user to there
const envIndex = userAvailableEnvs.findIndex((el) => slug === el.slug);
if (envIndex !== -1) {
const envFolder = folders?.[envIndex];
const dir = envFolder?.data?.dir?.pop();
if (dir) {
query.folderId = dir.id;
}
router.push({
pathname: "/project/[id]/secrets/[env]",
query
});
}
};
if (isEnvListLoading) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
</div>
);
}
const isTableLoading =
folders?.some(({ isLoading }) => isLoading) && secrets?.some(({ isLoading }) => isLoading);
const filteredSecretNames = secKeys?.filter((name) =>
name.toUpperCase().includes(searchFilter.toUpperCase())
);
const filteredFolderNames = folderNames?.filter((name) =>
name.toLowerCase().includes(searchFilter.toLowerCase())
);
return (
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
<div className="mt-6">
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
or
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
</p>
</div>
<div className="mt-8 flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="w-80">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret/folder name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
</div>
</div>
<div className="thin-scrollbar mt-4 max-h-[calc(100vh-250px)] overflow-y-auto" ref={parentTableRef}>
<TableContainer>
<Table>
<THead className="sticky top-0">
<Tr>
<Th className="sticky left-0 z-10 min-w-[20rem] bg-clip-padding">Name</Th>
{userAvailableEnvs?.map(({ name, slug }, index) => {
const envSecKeyCount = getEnvSecretKeyCount(slug);
const missingKeyCount = secKeys.length - envSecKeyCount;
return (
<Th
className="min-table-row min-w-[11rem] text-center"
key={`secret-overview-${name}-${index + 1}`}
>
<div className="flex items-center justify-center">
{name}
{missingKeyCount > 0 && (
<Tooltip
className="max-w-none lowercase"
content={`${missingKeyCount} secrets missing\n compared to other environments`}
>
<div className="ml-2 h-[1.1rem] font-medium flex cursor-default items-center justify-center rounded-sm bg-red-600 border border-red-400 p-1 text-xs text-bunker-100">
<span className="text-bunker-100">{missingKeyCount}</span>
</div>
</Tooltip>
)}
</div>
</Th>
);
})}
</Tr>
</THead>
<TBody>
{isTableLoading && (
<TableSkeleton
columns={userAvailableEnvs.length + 1}
innerKey="secret-overview-loading"
rows={5}
className="bg-mineshaft-700"
/>
)}
{filteredFolderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
environments={userAvailableEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
/>
))}
{filteredSecretNames.map((key, index) => (
<SecretOverviewTableRow
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={userAvailableEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
))}
<Tr>
<Td className="fixed left-0 z-10 border-x border-mineshaft-700 bg-mineshaft-800 bg-clip-padding" />
{userAvailableEnvs.map(({ name, slug }) => (
<Td key={`explore-${name}-btn`} className=" border-x border-mineshaft-700">
<div className="flex items-center justify-center">
<Button
size="xs"
variant="outline_bg"
isFullWidth
onClick={() => handleExploreEnvClick(slug)}
>
Explore
</Button>
</div>
</Td>
))}
</Tr>
</TBody>
</Table>
</TableContainer>
</div>
</div>
);
};
@@ -0,0 +1,57 @@
import { useRouter } from "next/router";
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
secretPath: string;
onResetSearch: () => void;
};
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
const router = useRouter();
const onFolderCrumbClick = (index: number) => {
const newSecPath = secretPath.split("/").filter(Boolean).slice(0, index).join("/");
if (secretPath === `/${newSecPath}`) return;
const query = { ...router.query, secretPath: `/${newSecPath}` } as Record<string, string>;
// root condition
if (index === 0) delete query.secretPath;
router
.push({
pathname: router.pathname,
query
})
.then(() => onResetSearch());
};
return (
<div className="flex items-center space-x-2">
<div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pl-5 pr-2 text-sm hover:bg-mineshaft-600"
onClick={() => onFolderCrumbClick(0)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
<FontAwesomeIcon icon={faFolderOpen} className="text-primary" />
</div>
{(secretPath || "")
.split("/")
.filter(Boolean)
.map((path, index, arr) => (
<div
key={`secret-path-${index + 1}`}
className={`breadcrumb relative z-20 ${
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
{path}
</div>
))}
</div>
);
};
@@ -0,0 +1 @@
export { FolderBreadCrumbs } from "./FolderBreadCrumbs";
@@ -0,0 +1,48 @@
import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Td, Tr } from "@app/components/v2";
type Props = {
folderName: string;
environments: { name: string; slug: string }[];
isFolderPresentInEnv: (name: string, env: string) => boolean;
onClick: (path: string) => void;
};
export const SecretOverviewFolderRow = ({
folderName,
environments = [],
isFolderPresentInEnv,
onClick
}: Props) => {
return (
<Tr isHoverable isSelectable className="group" onClick={() => onClick(folderName)}>
<Td className="sticky left-0 z-10 border-x border-mineshaft-700 bg-mineshaft-800 bg-clip-padding py-2.5 group-hover:bg-mineshaft-700">
<div className="flex items-center space-x-5">
<div className="text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
</div>
<div>{folderName}</div>
</div>
</Td>
{environments.map(({ slug }, i) => {
const isPresent = isFolderPresentInEnv(folderName, slug);
return (
<Td
key={`sec-overview-${slug}-${i + 1}-folder`}
className={twMerge(
"border-x border-mineshaft-700 py-3 group-hover:bg-mineshaft-700",
isPresent ? "text-green-600" : "text-red-600"
)}
>
<div className="flex justify-center">
<FontAwesomeIcon icon={isPresent ? faCheck : faXmark} />
</div>
</Td>
);
})}
</Tr>
);
};
@@ -0,0 +1 @@
export { SecretOverviewFolderRow } from "./SecretOverviewFolderRow";
@@ -0,0 +1,171 @@
import { useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { IconButton, SecretInput, Tooltip } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
defaultValue?: string | null;
secretName: string;
isCreatable?: boolean;
isVisible?: boolean;
environment: string;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (env: string, key: string, value: string) => Promise<void>;
onSecretDelete: (env: string, key: string) => Promise<void>;
};
export const SecretEditRow = ({
defaultValue,
isCreatable,
onSecretUpdate,
secretName,
onSecretCreate,
onSecretDelete,
environment,
isVisible
}: Props) => {
const {
handleSubmit,
control,
reset,
getValues,
formState: { isDirty, isSubmitting }
} = useForm({
values: {
value: defaultValue
}
});
const editorRef = useRef(defaultValue);
const [isDeleting, setIsDeleting] = useToggle();
const { createNotification } = useNotificationContext();
const handleFormReset = () => {
reset();
const val = getValues();
editorRef.current = val.value;
};
const handleCopySecretToClipboard = async () => {
const { value } = getValues();
if (value) {
try {
await window.navigator.clipboard.writeText(value);
createNotification({ type: "success", text: "Copied secret to clipboard" });
} catch (error) {
console.log(error);
createNotification({ type: "error", text: "Failed to copy secret to clipboard" });
}
}
};
const handleFormSubmit = async ({ value }: { value?: string | null }) => {
if (value && secretName) {
if (isCreatable) {
await onSecretCreate(environment, secretName, value);
} else {
await onSecretUpdate(environment, secretName, value);
}
}
reset({ value });
};
const handleDeleteSecret = async () => {
setIsDeleting.on();
try {
await onSecretDelete(environment, secretName);
reset({ value: undefined });
editorRef.current = undefined;
} finally {
setIsDeleting.off();
}
};
return (
<div className="group flex w-full cursor-text items-start space-x-2 items-center">
<div className="flex-grow border-r border-r-mineshaft-600 pr-2 pl-1">
<Controller
control={control}
name="value"
render={({ field: { onChange, onBlur } }) => (
<SecretInput
value={editorRef.current}
onChange={(val, html) => {
onChange(val);
editorRef.current = html;
}}
onBlur={(html) => {
editorRef.current = html;
onBlur();
}}
isVisible={isVisible}
/>
)}
/>
</div>
<div className="flex w-16 justify-center space-x-3 pl-2 transition-all">
{isDirty ? (
<>
<div>
<Tooltip content="save">
<IconButton
variant="plain"
ariaLabel="submit-value"
className="h-full"
isDisabled={isSubmitting}
onClick={handleSubmit(handleFormSubmit)}
>
<FontAwesomeIcon icon={faCheck} />
</IconButton>
</Tooltip>
</div>
<div>
<Tooltip content="cancel">
<IconButton
variant="plain"
ariaLabel="reset-value"
className="h-full"
onClick={handleFormReset}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</div>
</>
) : (
<>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Copy Secret">
<IconButton
ariaLabel="copy-value"
onClick={handleCopySecretToClipboard}
variant="plain"
className="h-full"
>
<FontAwesomeIcon icon={faCopy} />
</IconButton>
</Tooltip>
</div>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete">
<IconButton
variant="plain"
ariaLabel="delete-value"
className="h-full"
onClick={handleDeleteSecret}
isDisabled={isDeleting}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,140 @@
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import { faCheck, faEye, faEyeSlash, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, TableContainer, Td, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { SecretEditRow } from "./SecretEditRow";
type Props = {
secretKey: string;
environments: { name: string; slug: string }[];
expandableColWidth: number;
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (env: string, key: string, value: string) => Promise<void>;
onSecretDelete: (env: string, key: string) => Promise<void>;
};
export const SecretOverviewTableRow = ({
secretKey,
environments = [],
getSecretByKey,
onSecretUpdate,
onSecretCreate,
onSecretDelete,
expandableColWidth
}: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row
const [isSecretVisible, setIsSecretVisible] = useToggle();
return (
<>
<Tr isHoverable isSelectable onClick={() => setIsFormExpanded.toggle()} className="group">
<Td className="sticky left-0 z-10 border-x border-mineshaft-700 bg-mineshaft-800 bg-clip-padding py-2.5 group-hover:bg-mineshaft-700">
<div className="flex items-center space-x-5">
<div className="text-blue-300/70">
<FontAwesomeIcon icon={faKey} />
</div>
<div>{secretKey}</div>
</div>
</Td>
{environments.map(({ slug }, i) => {
const secret = getSecretByKey(slug, secretKey);
const isSecretPresent = Boolean(secret);
const isSecretEmpty = secret?.value === "";
return (
<Td
key={`sec-overview-${slug}-${i + 1}-value`}
className={twMerge(
"border-x border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
isSecretPresent && !isSecretEmpty ? "text-green-600" : "",
isSecretPresent && isSecretEmpty ? "text-yellow" : "",
!isSecretPresent && !isSecretEmpty ? "text-red-600" : ""
)}
>
<div className="flex justify-center">
{!isSecretEmpty && <FontAwesomeIcon icon={!isSecretPresent ? faCheck : faXmark} />}
{isSecretEmpty && <FontAwesomeIcon icon={faCircle} />}
</div>
</Td>
);
})}
</Tr>
{isFormExpanded && (
<Tr>
<Td colSpan={totalCols}>
<div
className="rounded-md bg-bunker-700 p-2"
style={{
width: `calc(${expandableColWidth}px - 2rem)`,
position: "sticky",
left: "1.25rem",
right: "1.25rem"
}}
>
<TableContainer>
<table className="secret-table">
<thead>
<tr className="h-10 border-b-2 border-mineshaft-600">
<th
style={{ padding: "0.5rem 1rem" }}
className="min-table-row min-w-[11rem]"
>
Environment
</th>
<th style={{ padding: "0.5rem 1rem" }} className="border-none">Value</th>
<div className="absolute top-0 right-0 w-min ml-auto mt-1 mr-1">
<Button
variant="outline_bg"
className="p-1"
leftIcon={<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />}
onClick={() => setIsSecretVisible.toggle()}
>
{isSecretVisible ? "Hide Values" : "Reveal Values"}
</Button>
</div>
</tr>
</thead>
<tbody className="border-t-2 border-mineshaft-600">
{environments.map(({ name, slug }) => {
const secret = getSecretByKey(slug, secretKey);
const isCreatable = !secret;
return (
<tr
key={`secret-expanded-${slug}-${secretKey}`}
className="hover:bg-mineshaft-700"
>
<td className="flex" style={{ padding: "0.25rem 1rem" }}>
<div className="flex h-8 items-center">{name}</div>
</td>
<td className="h-8 col-span-2 w-full">
<SecretEditRow
isVisible={isSecretVisible}
secretName={secretKey}
defaultValue={secret?.value}
isCreatable={isCreatable}
onSecretDelete={onSecretDelete}
onSecretCreate={onSecretCreate}
onSecretUpdate={onSecretUpdate}
environment={slug}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</TableContainer>
</div>
</Td>
</Tr>
)}
</>
);
};
@@ -0,0 +1 @@
export { SecretOverviewTableRow } from "./SecretOverviewTableRow";
@@ -0,0 +1 @@
export { SecretOverviewPage } from "./SecretOverviewPage";
@@ -10,90 +10,94 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
} from "@app/components/v2";
import timeSince from "@app/ee/utilities/timeSince"; import timeSince from "@app/ee/utilities/timeSince";
import getRisksByOrganization, { GitRisks } from "@app/pages/api/secret-scanning/getRisksByOrganization"; import getRisksByOrganization, {
GitRisks
} from "@app/pages/api/secret-scanning/getRisksByOrganization";
import { RiskStatusSelection } from "./RiskStatusSelection"; import { RiskStatusSelection } from "./RiskStatusSelection";
export const SecretScanningLogsTable = () => { export const SecretScanningLogsTable = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [gitRisks, setGitRisks] = useState<GitRisks[]>([]); const [gitRisks, setGitRisks] = useState<GitRisks[]>([]);
useEffect(() => { useEffect(() => {
const fetchRisks = async () => { const fetchRisks = async () => {
setIsLoading(true); setIsLoading(true);
const risks = await getRisksByOrganization(String(localStorage.getItem("orgData.id"))) const risks = await getRisksByOrganization(String(localStorage.getItem("orgData.id")));
setGitRisks(risks); setGitRisks(risks);
setIsLoading(false); setIsLoading(false);
} };
fetchRisks(); fetchRisks();
},[]) }, []);
return (
return ( <TableContainer className="mt-8">
<TableContainer className="mt-8"> <Table>
<Table> <THead>
<THead> <Tr>
<Tr> <Th className="flex-1">Date</Th>
<Th className="flex-1">Date</Th> <Th className="flex-1">Secret Type</Th>
<Th className="flex-1">Secret Type</Th> <Th className="flex-1">View Risk</Th>
<Th className="flex-1">View Risk</Th> <Th className="flex-1">Info</Th>
<Th className="flex-1">Info</Th> <Th className="flex-1">Status</Th>
<Th className="flex-1">Status</Th> <Th className="flex-1">Action</Th>
<Th className="flex-1">Action</Th> <Th className="w-5" />
<Th className="w-5" /> </Tr>
</THead>
<TBody>
{!isLoading &&
gitRisks &&
gitRisks?.map((risk) => {
return (
<Tr key={risk.ruleID} className="h-10">
<Td>{timeSince(new Date(risk.createdAt))}</Td>
<Td>{risk.ruleID}</Td>
<Td>
<a
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`}
target="_blank"
className="text-red-500"
rel="noreferrer"
>
View Exposed Secret
</a>
</Td>
<Td>
<div className="font-bold">
<a href={`https://github.com/${risk.repositoryFullName}`}>
{risk.repositoryFullName}
</a>
</div>
<div className="text-xs">
<span>{risk.file}</span>
<br />
<br />
<span className="font-bold">{risk.author}</span>
<br />
<span>{risk.email}</span>
</div>
</Td>
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
<Td>
<RiskStatusSelection riskId={risk._id} currentSelection={risk.status} />
</Td>
</Tr> </Tr>
</THead> );
<TBody> })}
{!isLoading && gitRisks && gitRisks?.map((risk) => { {isLoading && <TableSkeleton columns={7} innerKey="gitRisks" />}
return ( {!isLoading && gitRisks && gitRisks?.length === 0 && (
<Tr key={risk.ruleID} className="h-10"> <Tr>
<Td>{timeSince(new Date(risk.createdAt))}</Td> <Td colSpan={7}>
<Td>{risk.ruleID}</Td> <EmptyState title="No risks detected." icon={faCheck} />
<Td> </Td>
<a </Tr>
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`} )}
target="_blank" </TBody>
className="text-red-500" rel="noreferrer" </Table>
> </TableContainer>
View Exposed Secret );
</a> };
</Td>
<Td>
<div className="font-bold">
<a href={`https://github.com/${risk.repositoryFullName}`}>
{risk.repositoryFullName}
</a>
</div>
<div className="text-xs">
<span>{risk.file}</span><br/>
<br/>
<span className="font-bold">{risk.author}</span><br/>
<span>{risk.email}</span>
</div>
</Td>
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
<Td>
<RiskStatusSelection riskId={risk._id} currentSelection={risk.status}/>
</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={7} key="gitRisks" />}
{!isLoading && gitRisks && gitRisks?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState
title="No risks detected."
icon={faCheck}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,4 +1,4 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons"; import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -10,78 +10,63 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useGetOrgPlanTable } from "@app/hooks/api";
useGetOrgPlanTable
} from "@app/hooks/api";
export const CurrentPlanSection = () => { export const CurrentPlanSection = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPlanTable(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgPlanTable(currentOrg?._id ?? "");
const displayCell = (value: null | number | string | boolean) => { const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-"; if (value === null) return "-";
if (typeof value === "boolean") { if (typeof value === "boolean") {
if (value) return ( if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return ( return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
<FontAwesomeIcon
icon={faCircleXmark}
color='#e74c3c'
/>
);
}
return value;
} }
return ( return value;
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"> };
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
<TableContainer className="mt-4"> return (
<Table> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<THead> <h2 className="mb-8 flex-1 text-xl font-semibold text-white">Current Usage</h2>
<Tr> <TableContainer className="mt-4">
<Th className="w-1/3">Feature</Th> <Table>
<Th className="w-1/3">Allowed</Th> <THead>
<Th className="w-1/3">Used</Th> <Tr>
</Tr> <Th className="w-1/3">Feature</Th>
</THead> <Th className="w-1/3">Allowed</Th>
<TBody> <Th className="w-1/3">Used</Th>
{!isLoading && data && data?.rows?.length > 0 && data.rows.map(({ </Tr>
name, </THead>
allowed, <TBody>
used {!isLoading &&
}) => { data &&
return ( data?.rows?.length > 0 &&
<Tr key={`current-plan-row-${name}`} className="h-12"> data.rows.map(({ name, allowed, used }) => {
<Td>{name}</Td> return (
<Td>{displayCell(allowed)}</Td> <Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{used}</Td> <Td>{name}</Td>
</Tr> <Td>{displayCell(allowed)}</Td>
); <Td>{used}</Td>
})} </Tr>
{isLoading && <TableSkeleton columns={5} key="invoices" />} );
{!isLoading && data && data?.rows?.length === 0 && ( })}
<Tr> {isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
<Td colSpan={3}> {!isLoading && data && data?.rows?.length === 0 && (
<EmptyState <Tr>
title="No plan details found" <Td colSpan={3}>
icon={faFileInvoice} <EmptyState title="No plan details found" icon={faFileInvoice} />
/> </Td>
</Td> </Tr>
</Tr> )}
)} </TBody>
</TBody> </Table>
</Table> </TableContainer>
</TableContainer> </div>
</div> );
); };
}
@@ -1,4 +1,4 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons"; import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -11,166 +11,129 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization,useSubscription } from "@app/context"; import { useOrganization, useSubscription } from "@app/context";
import { import { useCreateCustomerPortalSession, useGetOrgPlansTable } from "@app/hooks/api";
useCreateCustomerPortalSession,
useGetOrgPlansTable} from "@app/hooks/api";
type Props = { type Props = {
billingCycle: "monthly" | "yearly" billingCycle: "monthly" | "yearly";
} };
export const ManagePlansTable = ({ export const ManagePlansTable = ({ billingCycle }: Props) => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?._id ?? "",
billingCycle billingCycle
}: Props) => { });
const { currentOrg } = useOrganization(); const createCustomerPortalSession = useCreateCustomerPortalSession();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?._id ?? "",
billingCycle
});
const createCustomerPortalSession = useCreateCustomerPortalSession();
const displayCell = (value: null | number | string | boolean) => { const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "Unlimited"; if (value === null) return "Unlimited";
if (typeof value === "boolean") { if (typeof value === "boolean") {
if (value) return ( if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return ( return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
<FontAwesomeIcon
icon={faCircleXmark}
color='#e74c3c'
/>
);
}
return value;
} }
return ( return value;
<TableContainer> };
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({
name,
priceLine
}) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="text-center flex-1"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription && !isTableDataLoading && tableData && tableData.rows.map(({
name,
starter,
team,
pro,
enterprise
}) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">
{displayCell(starter)}
</Td>
<Td className="text-center">
{displayCell(team)}
</Td>
<Td className="text-center">
{displayCell(pro)}
</Td>
<Td className="text-center">
{displayCell(enterprise)}
</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} key="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No cloud product details found"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({
slug,
tier
}) => {
const isCurrentPlan = slug === subscription.slug; return (
let subscriptionText = "Upgrade"; <TableContainer>
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({ name, priceLine }) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="flex-1 text-center"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription &&
!isTableDataLoading &&
tableData &&
tableData.rows.map(({ name, starter, team, pro, enterprise }) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">{displayCell(starter)}</Td>
<Td className="text-center">{displayCell(team)}</Td>
<Td className="text-center">{displayCell(pro)}</Td>
<Td className="text-center">{displayCell(enterprise)}</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} innerKey="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No cloud product details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({ slug, tier }) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) { if (subscription.tier > tier) {
subscriptionText = "Downgrade" subscriptionText = "Downgrade";
} }
if (tier === 3) { if (tier === 3) {
subscriptionText = "Contact sales" subscriptionText = "Contact sales";
} }
return isCurrentPlan ? ( return isCurrentPlan ? (
<Td> <Td>
<Button <Button colorSchema="secondary" className="w-full" isDisabled>
colorSchema="secondary" Current
className="w-full" </Button>
isDisabled </Td>
> ) : (
Current <Td>
</Button> <Button
</Td> onClick={async () => {
) : ( if (!currentOrg?._id) return;
<Td>
<Button
onClick={async () => {
if (!currentOrg?._id) return;
if (tier !== 3) { if (tier !== 3) {
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg._id); const { url } = await createCustomerPortalSession.mutateAsync(
window.location.href = url; currentOrg._id
return; );
} window.location.href = url;
return;
}
window.location.href = "https://infisical.com/scheduledemo"; window.location.href = "https://infisical.com/scheduledemo";
}} }}
color="mineshaft" color="mineshaft"
className="w-full" className="w-full"
> >
{subscriptionText} {subscriptionText}
</Button> </Button>
</Td> </Td>
); );
})} })}
</Tr> </Tr>
)} )}
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
); );
} };
@@ -14,78 +14,68 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
useDeleteOrgPmtMethod,
useGetOrgPmtMethods
} from "@app/hooks/api";
export const PmtMethodsTable = () => { export const PmtMethodsTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? "");
const deleteOrgPmtMethod = useDeleteOrgPmtMethod(); const deleteOrgPmtMethod = useDeleteOrgPmtMethod();
const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => { const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => {
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
await deleteOrgPmtMethod.mutateAsync({ await deleteOrgPmtMethod.mutateAsync({
organizationId: currentOrg._id, organizationId: currentOrg._id,
pmtMethodId pmtMethodId
}); });
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Brand</Th> <Th className="flex-1">Brand</Th>
<Th className="flex-1">Type</Th> <Th className="flex-1">Type</Th>
<Th className="flex-1">Last 4 Digits</Th> <Th className="flex-1">Last 4 Digits</Th>
<Th className="flex-1">Expiration</Th> <Th className="flex-1">Expiration</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{!isLoading && data && data?.length > 0 && data.map(({ {!isLoading &&
_id, data &&
brand, data?.length > 0 &&
exp_month, data.map(({ _id, brand, exp_month, exp_year, funding, last4 }) => (
exp_year, <Tr key={`pmt-method-${_id}`} className="h-10">
funding, <Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td>
last4 <Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td>
}) => ( <Td>{last4}</Td>
<Tr key={`pmt-method-${_id}`} className="h-10"> <Td>{`${exp_month}/${exp_year}`}</Td>
<Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td> <Td>
<Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td> <IconButton
<Td>{last4}</Td> onClick={async () => {
<Td>{`${exp_month}/${exp_year}`}</Td> await handleDeletePmtMethodBtnClick(_id);
<Td> }}
<IconButton size="lg"
onClick={async () => { colorSchema="danger"
await handleDeletePmtMethodBtnClick(_id); variant="plain"
}} ariaLabel="update"
size="lg" >
colorSchema="danger" <FontAwesomeIcon icon={faXmark} />
variant="plain" </IconButton>
ariaLabel="update" </Td>
> </Tr>
<FontAwesomeIcon icon={faXmark} /> ))}
</IconButton> {isLoading && <TableSkeleton columns={5} innerKey="pmt-methods" />}
</Td> {!isLoading && data && data?.length === 0 && (
</Tr> <Tr>
))} <Td colSpan={5}>
{isLoading && <TableSkeleton columns={5} key="pmt-methods" />} <EmptyState title="No payment methods on file" icon={faCreditCard} />
{!isLoading && data && data?.length === 0 && ( </Td>
<Tr> </Tr>
<Td colSpan={5}> )}
<EmptyState </TBody>
title="No payment methods on file" </Table>
icon={faCreditCard} </TableContainer>
/> );
</Td> };
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,4 +1,4 @@
import { faFileInvoice,faXmark } from "@fortawesome/free-solid-svg-icons"; import { faFileInvoice, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -11,127 +11,120 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
useDeleteOrgTaxId,
useGetOrgTaxIds
} from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = { const taxIDTypeLabelMap: { [key: string]: string } = {
"au_abn": "Australia ABN", au_abn: "Australia ABN",
"au_arn": "Australia ARN", au_arn: "Australia ARN",
"bg_uic": "Bulgaria UIC", bg_uic: "Bulgaria UIC",
"br_cnpj": "Brazil CNPJ", br_cnpj: "Brazil CNPJ",
"br_cpf": "Brazil CPF", br_cpf: "Brazil CPF",
"ca_bn": "Canada BN", ca_bn: "Canada BN",
"ca_gst_hst": "Canada GST/HST", ca_gst_hst: "Canada GST/HST",
"ca_pst_bc": "Canada PST BC", ca_pst_bc: "Canada PST BC",
"ca_pst_mb": "Canada PST MB", ca_pst_mb: "Canada PST MB",
"ca_pst_sk": "Canada PST SK", ca_pst_sk: "Canada PST SK",
"ca_qst": "Canada QST", ca_qst: "Canada QST",
"ch_vat": "Switzerland VAT", ch_vat: "Switzerland VAT",
"cl_tin": "Chile TIN", cl_tin: "Chile TIN",
"eg_tin": "Egypt TIN", eg_tin: "Egypt TIN",
"es_cif": "Spain CIF", es_cif: "Spain CIF",
"eu_oss_vat": "EU OSS VAT", eu_oss_vat: "EU OSS VAT",
"eu_vat": "EU VAT", eu_vat: "EU VAT",
"gb_vat": "GB VAT", gb_vat: "GB VAT",
"ge_vat": "Georgia VAT", ge_vat: "Georgia VAT",
"hk_br": "Hong Kong BR", hk_br: "Hong Kong BR",
"hu_tin": "Hungary TIN", hu_tin: "Hungary TIN",
"id_npwp": "Indonesia NPWP", id_npwp: "Indonesia NPWP",
"il_vat": "Israel VAT", il_vat: "Israel VAT",
"in_gst": "India GST", in_gst: "India GST",
"is_vat": "Iceland VAT", is_vat: "Iceland VAT",
"jp_cn": "Japan CN", jp_cn: "Japan CN",
"jp_rn": "Japan RN", jp_rn: "Japan RN",
"jp_trn": "Japan TRN", jp_trn: "Japan TRN",
"ke_pin": "Kenya PIN", ke_pin: "Kenya PIN",
"kr_brn": "South Korea BRN", kr_brn: "South Korea BRN",
"li_uid": "Liechtenstein UID", li_uid: "Liechtenstein UID",
"mx_rfc": "Mexico RFC", mx_rfc: "Mexico RFC",
"my_frp": "Malaysia FRP", my_frp: "Malaysia FRP",
"my_itn": "Malaysia ITN", my_itn: "Malaysia ITN",
"my_sst": "Malaysia SST", my_sst: "Malaysia SST",
"no_vat": "Norway VAT", no_vat: "Norway VAT",
"nz_gst": "New Zealand GST", nz_gst: "New Zealand GST",
"ph_tin": "Philippines TIN", ph_tin: "Philippines TIN",
"ru_inn": "Russia INN", ru_inn: "Russia INN",
"ru_kpp": "Russia KPP", ru_kpp: "Russia KPP",
"sa_vat": "Saudi Arabia VAT", sa_vat: "Saudi Arabia VAT",
"sg_gst": "Singapore GST", sg_gst: "Singapore GST",
"sg_uen": "Singapore UEN", sg_uen: "Singapore UEN",
"si_tin": "Slovenia TIN", si_tin: "Slovenia TIN",
"th_vat": "Thailand VAT", th_vat: "Thailand VAT",
"tr_tin": "Turkey TIN", tr_tin: "Turkey TIN",
"tw_vat": "Taiwan VAT", tw_vat: "Taiwan VAT",
"ua_vat": "Ukraine VAT", ua_vat: "Ukraine VAT",
"us_ein": "US EIN", us_ein: "US EIN",
"za_vat": "South Africa VAT" za_vat: "South Africa VAT"
}; };
export const TaxIDTable = () => { export const TaxIDTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? "");
const deleteOrgTaxId = useDeleteOrgTaxId(); const deleteOrgTaxId = useDeleteOrgTaxId();
const handleDeleteTaxIdBtnClick = async (taxId: string) => { const handleDeleteTaxIdBtnClick = async (taxId: string) => {
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
await deleteOrgTaxId.mutateAsync({ await deleteOrgTaxId.mutateAsync({
organizationId: currentOrg._id, organizationId: currentOrg._id,
taxId taxId
}); });
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Type</Th> <Th className="flex-1">Type</Th>
<Th className="flex-1">Value</Th> <Th className="flex-1">Value</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{!isLoading && data && data?.length > 0 && data.map(({ {!isLoading &&
_id, data &&
type, data?.length > 0 &&
value data.map(({ _id, type, value }) => (
}) => ( <Tr key={`tax-id-${_id}`} className="h-10">
<Tr key={`tax-id-${_id}`} className="h-10"> <Td>{taxIDTypeLabelMap[type]}</Td>
<Td>{taxIDTypeLabelMap[type]}</Td> <Td>{value}</Td>
<Td>{value}</Td> <Td>
<Td> <IconButton
<IconButton onClick={async () => {
onClick={async () => { await handleDeleteTaxIdBtnClick(_id);
await handleDeleteTaxIdBtnClick(_id); }}
}} size="lg"
size="lg" colorSchema="danger"
colorSchema="danger" variant="plain"
variant="plain" ariaLabel="update"
ariaLabel="update" >
> <FontAwesomeIcon icon={faXmark} />
<FontAwesomeIcon icon={faXmark} /> </IconButton>
</IconButton> </Td>
</Td> </Tr>
</Tr> ))}
))} {isLoading && <TableSkeleton columns={3} innerKey="tax-ids" />}
{isLoading && <TableSkeleton columns={3} key="tax-ids" />} {!isLoading && data && data?.length === 0 && (
{!isLoading && data && data?.length === 0 && ( <Tr>
<Tr> <Td colSpan={5}>
<Td colSpan={5}> <EmptyState title="No Tax IDs on file" icon={faFileInvoice} />
<EmptyState </Td>
title="No Tax IDs on file" </Tr>
icon={faFileInvoice} )}
/> </TBody>
</Td> </Table>
</Tr> </TableContainer>
)} );
</TBody> };
</Table>
</TableContainer>
);
}
@@ -14,76 +14,67 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useGetOrgInvoices } from "@app/hooks/api";
useGetOrgInvoices
} from "@app/hooks/api";
export const InvoicesTable = () => { export const InvoicesTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? "");
return ( return (
<TableContainer className="mt-8"> <TableContainer className="mt-8">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Invoice #</Th> <Th className="flex-1">Invoice #</Th>
<Th className="flex-1">Date</Th> <Th className="flex-1">Date</Th>
<Th className="flex-1">Status</Th> <Th className="flex-1">Status</Th>
<Th className="flex-1">Amount</Th> <Th className="flex-1">Amount</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{!isLoading && data && data?.length > 0 && data.map(({ {!isLoading &&
_id, data &&
created, data?.length > 0 &&
paid, data.map(({ _id, created, paid, number, total, invoice_pdf }) => {
number, const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", {
total, style: "currency",
invoice_pdf currency: "USD"
}) => { });
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", { const createdDate = new Date(created * 1000);
style: "currency", const day: number = createdDate.getDate();
currency: "USD", const month: number = createdDate.getMonth() + 1;
}); const year: number = createdDate.getFullYear();
const createdDate = new Date(created * 1000); const formattedDate: string = `${day}/${month}/${year}`;
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return ( return (
<Tr key={`invoice-${_id}`} className="h-10"> <Tr key={`invoice-${_id}`} className="h-10">
<Td>{number}</Td> <Td>{number}</Td>
<Td>{formattedDate}</Td> <Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td> <Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td> <Td>{formattedTotal}</Td>
<Td> <Td>
<IconButton <IconButton
onClick={async () => window.open(invoice_pdf)} onClick={async () => window.open(invoice_pdf)}
size="lg" size="lg"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
> >
<FontAwesomeIcon icon={faDownload} /> <FontAwesomeIcon icon={faDownload} />
</IconButton> </IconButton>
</Td> </Td>
</Tr> </Tr>
); );
})} })}
{isLoading && <TableSkeleton columns={5} key="invoices" />} {isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
{!isLoading && data && data?.length === 0 && ( {!isLoading && data && data?.length === 0 && (
<Tr> <Tr>
<Td colSpan={5}> <Td colSpan={5}>
<EmptyState <EmptyState title="No invoices on file" icon={faFileInvoice} />
title="No invoices on file" </Td>
icon={faFileInvoice} </Tr>
/> )}
</Td> </TBody>
</Tr> </Table>
)} </TableContainer>
</TBody> );
</Table> };
</TableContainer>
);
}
@@ -1,407 +1,412 @@
import { useState } from "react"; import { useState } from "react";
import { Controller,useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import { faKey, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
faKey,
faMagnifyingGlass,
faPlus,
faTrash} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { import {
decryptAssymmetric, decryptAssymmetric,
encryptAssymmetric, encryptAssymmetric,
verifyPrivateKey} from "@app/components/utilities/cryptography/crypto"; verifyPrivateKey
} from "@app/components/utilities/cryptography/crypto";
import { import {
Button, Button,
Checkbox, Checkbox,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Modal, Modal,
ModalClose, ModalClose,
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
TBody, TBody,
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
} from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useCreateServiceAccountProjectLevelPermission, useCreateServiceAccountProjectLevelPermission,
useDeleteServiceAccountProjectLevelPermission, useDeleteServiceAccountProjectLevelPermission,
useGetServiceAccountById, useGetServiceAccountById,
useGetServiceAccountProjectLevelPermissions, useGetServiceAccountProjectLevelPermissions,
useGetUserWorkspaces useGetUserWorkspaces
} from "@app/hooks/api"; } from "@app/hooks/api";
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey"; import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
const createProjectLevelPermissionSchema = yup.object({ const createProjectLevelPermissionSchema = yup.object({
privateKey: yup.string().required().label("Private Key"), privateKey: yup.string().required().label("Private Key"),
workspace: yup.string().required().label("Workspace"), workspace: yup.string().required().label("Workspace"),
environment: yup.string().required().label("Environment"), environment: yup.string().required().label("Environment"),
permissions: yup.object().shape({ permissions: yup
read: yup.boolean().required(), .object()
write: yup.boolean().required() .shape({
}).defined().required() read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
}); });
type CreateProjectLevelPermissionForm = yup.InferType<typeof createProjectLevelPermissionSchema>; type CreateProjectLevelPermissionForm = yup.InferType<typeof createProjectLevelPermissionSchema>;
type Props = { type Props = {
serviceAccountId: string; serviceAccountId: string;
} };
export const SAProjectLevelPermissionsTable = ({ export const SAProjectLevelPermissionsTable = ({ serviceAccountId }: Props): JSX.Element => {
serviceAccountId const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId);
}: Props): JSX.Element => { const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId); const [searchPermissions, setSearchPermissions] = useState("");
const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
const [searchPermissions, setSearchPermissions] = useState("");
const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } =
useGetServiceAccountProjectLevelPermissions(serviceAccountId);
const createServiceAccountProjectLevelPermission = useCreateServiceAccountProjectLevelPermission(); const createServiceAccountProjectLevelPermission =
const deleteServiceAccountProjectLevelPermission = useDeleteServiceAccountProjectLevelPermission(); useCreateServiceAccountProjectLevelPermission();
const deleteServiceAccountProjectLevelPermission =
useDeleteServiceAccountProjectLevelPermission();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addProjectLevelPermission", "addProjectLevelPermission",
"removeProjectLevelPermission", "removeProjectLevelPermission"
] as const); ] as const);
const [, setSelectedWorkspace] = useState<undefined | string>(undefined); const [, setSelectedWorkspace] = useState<undefined | string>(undefined);
const { const {
control, control,
handleSubmit, handleSubmit,
reset, reset,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<CreateProjectLevelPermissionForm>({ resolver: yupResolver(createProjectLevelPermissionSchema) }) } = useForm<CreateProjectLevelPermissionForm>({
resolver: yupResolver(createProjectLevelPermissionSchema)
});
const onAddProjectLevelPermission = async ({ const onAddProjectLevelPermission = async ({
privateKey, privateKey,
workspace, workspace,
environment, environment,
permissions: { read, write } permissions: { read, write }
}: CreateProjectLevelPermissionForm) => { }: CreateProjectLevelPermissionForm) => {
// TODO: clean up / modularize this function
// TODO: clean up / modularize this function if (!serviceAccount) return;
if (!serviceAccount) return; const { latestKey } = await getLatestFileKey({
workspaceId: workspace
});
const { latestKey } = await getLatestFileKey({ verifyPrivateKey({
workspaceId: workspace privateKey,
}); publicKey: serviceAccount.publicKey
});
verifyPrivateKey({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
privateKey,
publicKey: serviceAccount.publicKey
});
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const key = decryptAssymmetric({
ciphertext: latestKey.encryptedKey,
nonce: latestKey.nonce,
publicKey: latestKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const key = decryptAssymmetric({ const { ciphertext, nonce } = encryptAssymmetric({
ciphertext: latestKey.encryptedKey, plaintext: key,
nonce: latestKey.nonce, publicKey: serviceAccount.publicKey,
publicKey: latestKey.sender.publicKey, privateKey
privateKey: PRIVATE_KEY });
});
const { ciphertext, nonce } = encryptAssymmetric({ await createServiceAccountProjectLevelPermission.mutateAsync({
plaintext: key, serviceAccountId,
publicKey: serviceAccount.publicKey, workspaceId: workspace,
privateKey environment,
}); read,
write,
encryptedKey: ciphertext,
nonce
});
handlePopUpClose("addProjectLevelPermission");
};
await createServiceAccountProjectLevelPermission.mutateAsync({ const onRemoveProjectLevelPermission = async () => {
serviceAccountId, const serviceAccountWorkspacePermissionId = (
workspaceId: workspace, popUp?.removeProjectLevelPermission?.data as { _id: string }
environment, )?._id;
read, await deleteServiceAccountProjectLevelPermission.mutateAsync({
write, serviceAccountId,
encryptedKey: ciphertext, serviceAccountWorkspacePermissionId
nonce });
}); handlePopUpClose("removeProjectLevelPermission");
handlePopUpClose("addProjectLevelPermission"); };
}
const onRemoveProjectLevelPermission = async () => { return (
const serviceAccountWorkspacePermissionId = (popUp?.removeProjectLevelPermission?.data as { _id: string })?._id; <div className="w-full bg-white/5 p-6">
await deleteServiceAccountProjectLevelPermission.mutateAsync({ <p className="mb-4 text-xl font-semibold">Project-Level Permissions</p>
serviceAccountId, <div className="mb-4 flex">
serviceAccountWorkspacePermissionId <div className="mr-4 flex-1">
}); <Input
handlePopUpClose("removeProjectLevelPermission"); value={searchPermissions}
} onChange={(e) => setSearchPermissions(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
return ( placeholder="Search service account project-level permissions..."
<div className="w-full bg-white/5 p-6"> />
<p className="mb-4 text-xl font-semibold">Project-Level Permissions</p>
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchPermissions}
onChange={(e) => setSearchPermissions(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service account project-level permissions..."
/>
</div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addProjectLevelPermission")
reset();
}}
>
Add Permission
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Project</Th>
<Th>Environment</Th>
<Th>Read</Th>
<Th>Write</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isPermissionsLoading && <TableSkeleton columns={6} key="service-account-project-level-permissions" />}
{!isPermissionsLoading && serviceAccountWorkspacePermissions && (
serviceAccountWorkspacePermissions.map(({
_id,
workspace,
environment,
read,
write
}) => {
const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name;
return (
<Tr key={`service-account-project-level-permission-${_id}`} className="w-full">
<Td>{workspace.name}</Td>
<Td>{environmentName}</Td>
<Td>
<Checkbox
id="isReadPermissionEnabled"
isChecked={read}
isDisabled
>{/**/}</Checkbox>
</Td>
<Td>
<Checkbox
id="isWritePermissionEnabled"
isChecked={write}
isDisabled
>{/**/}</Checkbox>
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeProjectLevelPermission", { _id })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
})
)}
{!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && (
<Tr>
<Td colSpan={7} className="py-6 text-center text-bunker-400">
<EmptyState title="No permissions found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addProjectLevelPermission?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addProjectLevelPermission", isOpen);
}}
>
<ModalContent
title="Add a Project-Level Permission"
subTitle="The service account will be granted scoped access to the specified project and environment"
>
<form onSubmit={handleSubmit(onAddProjectLevelPermission)}>
{!isUserWorkspacesLoading && userWorkspaces && (
<>
<Controller
control={control}
defaultValue=""
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Private Key"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="workspace"
defaultValue={String(userWorkspaces?.[0]?._id)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
onChange(e);
setSelectedWorkspace(e);
}}
className="w-full border border-mine-shaft-500"
>
{userWorkspaces && userWorkspaces.length > 0 ? (
userWorkspaces.map((userWorkspace) => {
return (
<SelectItem value={userWorkspace._id} key={`project-${userWorkspace._id}`}>
{userWorkspace.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={String(userWorkspaces?.[0]?.environments?.[0]?.slug)}
render={({ field: { onChange, ...field } }) => {
/* eslint-disable-next-line no-underscore-dangle */
const environments = userWorkspaces?.find((userWorkspace) => userWorkspace._id === control?._formValues?.workspace)?.environments ?? [];
return (
<FormControl
label="Environment"
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mine-shaft-500"
>
{environments.length > 0 ? (
environments.map((environment) => {
return (
<SelectItem value={environment.slug} key={`environment-${environment.slug}`}>
{environment.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No environments found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
</>
)}
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error }}) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeProjectLevelPermission.isOpen}
deleteKey="remove"
title="Do you want to remove this permission from the service account?"
onChange={(isOpen) => handlePopUpToggle("removeProjectLevelPermission", isOpen)}
onDeleteApproved={onRemoveProjectLevelPermission}
/>
</div> </div>
); <Button
} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addProjectLevelPermission");
reset();
}}
>
Add Permission
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Project</Th>
<Th>Environment</Th>
<Th>Read</Th>
<Th>Write</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isPermissionsLoading && (
<TableSkeleton columns={6} innerKey="service-account-project-level-permissions" />
)}
{!isPermissionsLoading &&
serviceAccountWorkspacePermissions &&
serviceAccountWorkspacePermissions.map(
({ _id, workspace, environment, read, write }) => {
const environmentName = workspace.environments.find(
(env) => env.slug === environment
)?.name;
return (
<Tr key={`service-account-project-level-permission-${_id}`} className="w-full">
<Td>{workspace.name}</Td>
<Td>{environmentName}</Td>
<Td>
<Checkbox id="isReadPermissionEnabled" isChecked={read} isDisabled>
{/**/}
</Checkbox>
</Td>
<Td>
<Checkbox id="isWritePermissionEnabled" isChecked={write} isDisabled>
{/**/}
</Checkbox>
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeProjectLevelPermission", { _id })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && (
<Tr>
<Td colSpan={7} className="py-6 text-center text-bunker-400">
<EmptyState title="No permissions found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addProjectLevelPermission?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addProjectLevelPermission", isOpen);
}}
>
<ModalContent
title="Add a Project-Level Permission"
subTitle="The service account will be granted scoped access to the specified project and environment"
>
<form onSubmit={handleSubmit(onAddProjectLevelPermission)}>
{!isUserWorkspacesLoading && userWorkspaces && (
<>
<Controller
control={control}
defaultValue=""
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Private Key"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="workspace"
defaultValue={String(userWorkspaces?.[0]?._id)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Project" errorText={error?.message}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
onChange(e);
setSelectedWorkspace(e);
}}
className="border-mine-shaft-500 w-full border"
>
{userWorkspaces && userWorkspaces.length > 0 ? (
userWorkspaces.map((userWorkspace) => {
return (
<SelectItem
value={userWorkspace._id}
key={`project-${userWorkspace._id}`}
>
{userWorkspace.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={String(userWorkspaces?.[0]?.environments?.[0]?.slug)}
render={({ field: { onChange, ...field } }) => {
const environments =
userWorkspaces?.find(
/* eslint-disable-next-line no-underscore-dangle */
(userWorkspace) => userWorkspace._id === control?._formValues?.workspace
)?.environments ?? [];
return (
<FormControl label="Environment" className="mt-4">
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="border-mine-shaft-500 w-full border"
>
{environments.length > 0 ? (
environments.map((environment) => {
return (
<SelectItem
value={environment.slug}
key={`environment-${environment.slug}`}
>
{environment.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No environments found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
</>
)}
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeProjectLevelPermission.isOpen}
deleteKey="remove"
title="Do you want to remove this permission from the service account?"
onChange={(isOpen) => handlePopUpToggle("removeProjectLevelPermission", isOpen)}
onDeleteApproved={onRemoveProjectLevelPermission}
/>
</div>
);
};
@@ -1,9 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import { faContactBook, faMagnifyingGlass, faTrash } from "@fortawesome/free-solid-svg-icons";
faContactBook,
faMagnifyingGlass,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -23,13 +19,11 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import { useDeleteIncidentContact, useGetOrgIncidentContact } from "@app/hooks/api";
useDeleteIncidentContact,
useGetOrgIncidentContact} from "@app/hooks/api";
export const OrgIncidentContactsTable = () => { export const OrgIncidentContactsTable = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? ""); const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? "");
const [searchContact, setSearchContact] = useState(""); const [searchContact, setSearchContact] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -40,71 +34,71 @@ export const OrgIncidentContactsTable = () => {
const onRemoveIncidentContact = async () => { const onRemoveIncidentContact = async () => {
try { try {
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
await mutateAsync({ await mutateAsync({
orgId: currentOrg._id, orgId: currentOrg._id,
email: incidentContactEmail email: incidentContactEmail
}); });
createNotification({ createNotification({
text: "Successfully removed incident contact", text: "Successfully removed incident contact",
type: "success" type: "success"
}); });
handlePopUpClose("removeContact"); handlePopUpClose("removeContact");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to remove incident contact", text: "Failed to remove incident contact",
type: "error" type: "error"
}); });
} }
}; };
const filteredContacts = contacts ? contacts.filter(({ email }) => const filteredContacts = contacts
email.toLocaleLowerCase().includes(searchContact) ? contacts.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact))
) : []; : [];
return ( return (
<div> <div>
<Input <Input
value={searchContact} value={searchContact}
onChange={(e) => setSearchContact(e.target.value)} onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..." placeholder="Search incident contact by email..."
/> />
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Email</Th> <Th>Email</Th>
<Th aria-label="actions" /> <Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email }) => (
<Tr key={email}>
<Td className="w-full">{email}</Td>
<Td className="mr-4">
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr> </Tr>
</THead> ))}
<TBody> </TBody>
{isLoading && <TableSkeleton columns={2} key="incident-contact" />} </Table>
{filteredContacts?.map(({ email }) => ( {filteredContacts?.length === 0 && !isLoading && (
<Tr key={email}> <EmptyState title="No incident contacts found" icon={faContactBook} />
<Td className="w-full">{email}</Td> )}
<Td className="mr-4"> </TableContainer>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
))}
</TBody>
</Table>
{filteredContacts?.length === 0 && !isLoading && (
<EmptyState title="No incident contacts found" icon={faContactBook} />
)}
</TableContainer>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeContact.isOpen} isOpen={popUp.removeContact.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -1,17 +1,14 @@
import { useEffect, useMemo,useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {
// Controller,
// useForm
} from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
faCheck, faCheck,
faCopy, faCopy,
faMagnifyingGlass, faMagnifyingGlass,
faPencil, faPencil,
faPlus, faPlus,
faServer, faServer,
faTrash} from "@fortawesome/free-solid-svg-icons"; faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// import { yupResolver } from "@hookform/resolvers/yup"; // import { yupResolver } from "@hookform/resolvers/yup";
@@ -21,13 +18,13 @@ import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
// FormControl, // FormControl,
IconButton, IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
// Select, // Select,
// SelectItem, // SelectItem,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -40,11 +37,15 @@ import {
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { import {
// useCreateServiceAccount, // useCreateServiceAccount,
useDeleteServiceAccount, useDeleteServiceAccount,
useGetServiceAccounts useGetServiceAccounts
} from "@app/hooks/api"; } from "@app/hooks/api";
import // Controller,
// useForm
"react-hook-form";
// const serviceAccountExpiration = [ // const serviceAccountExpiration = [
// { label: "1 Day", value: 86400 }, // { label: "1 Day", value: 86400 },
// { label: "7 Days", value: 604800 }, // { label: "7 Days", value: 604800 },
@@ -62,314 +63,312 @@ import {
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>; // type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => { export const OrgServiceAccountsTable = () => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || ""; const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false); const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey] = useState(""); const [accessKey] = useState("");
const [publicKey] = useState(""); const [publicKey] = useState("");
const [privateKey] = useState(""); const [privateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(""); const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount", "addServiceAccount",
"removeServiceAccount", "removeServiceAccount"
] as const); ] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
useGetServiceAccounts(orgId);
// const createServiceAccount = useCreateServiceAccount(); // const createServiceAccount = useCreateServiceAccount();
const removeServiceAccount = useDeleteServiceAccount(); const removeServiceAccount = useDeleteServiceAccount();
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (isAccessKeyCopied) { if (isAccessKeyCopied) {
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
}
if (isPublicKeyCopied) {
timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
}
if (isPrivateKeyCopied) {
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
// const {
// control,
// handleSubmit,
// reset,
// formState: { isSubmitting }
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
// if (!currentOrg?._id) return;
// const keyPair = generateKeyPair();
// setPublicKey(keyPair.publicKey);
// setPrivateKey(keyPair.privateKey);
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
// name,
// organizationId: currentOrg?._id,
// publicKey: keyPair.publicKey,
// expiresIn: Number(expiresIn)
// });
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
// setStep(1);
// reset();
// }
const onRemoveServiceAccount = async () => {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose("removeServiceAccount");
} }
const filteredServiceAccounts = useMemo( if (isPublicKeyCopied) {
() => timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
serviceAccounts.filter(
({ name }) =>
name.toLowerCase().includes(searchServiceAccountFilter)
),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<div>
We are currently revising the service account mechanism. In the meantime,
please use service tokens or API key to fetch secrets via API request.
</div>
// <form onSubmit={handleSubmit(onAddServiceAccount)}>
// <Controller
// control={control}
// defaultValue=""
// name="name"
// render={({ field, fieldState: { error } }) => (
// <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
// <Input {...field} />
// </FormControl>
// )}
// />
// <Controller
// control={control}
// name="expiresIn"
// defaultValue={String(serviceAccountExpiration?.[0]?.value)}
// render={({ field: { onChange, ...field }, fieldState: { error } }) => {
// return (
// <FormControl
// label="Expiration"
// errorText={error?.message}
// isError={Boolean(error)}
// >
// <Select
// defaultValue={field.value}
// {...field}
// onValueChange={(e) => onChange(e)}
// className="w-full"
// >
// {serviceAccountExpiration.map(({ label, value }) => (
// <SelectItem value={String(value)} key={label}>
// {label}
// </SelectItem>
// ))}
// </Select>
// </FormControl>
// );
// }}
// />
// <div className="mt-8 flex items-center">
// <Button
// className="mr-4"
// size="sm"
// type="submit"
// isLoading={isSubmitting}
// isDisabled={isSubmitting}
// >
// Create Service Account
// </Button>
// <Button
// colorSchema="secondary"
// variant="plain"
// onClick={() => handlePopUpClose("addServiceAccount")}
// >
// Cancel
// </Button>
// </div>
// </form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />
}
} }
return ( if (isPrivateKeyCopied) {
<div className="w-full"> timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
<div className="flex justify-between mb-4"> }
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button return () => clearTimeout(timer);
colorSchema="secondary" }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { // const {
setStep(0); // control,
// reset(); // handleSubmit,
handlePopUpOpen("addServiceAccount"); // reset,
}} // formState: { isSubmitting }
> // } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
Add Service Account
</Button> // const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
</div> // if (!currentOrg?._id) return;
<Input
value={searchServiceAccountFilter} // const keyPair = generateKeyPair();
onChange={(e) => setSearchServiceAccountFilter(e.target.value)} // setPublicKey(keyPair.publicKey);
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} // setPrivateKey(keyPair.privateKey);
placeholder="Search service accounts..."
/> // const serviceAccountDetails = await createServiceAccount.mutateAsync({
<TableContainer className="mt-4"> // name,
<Table> // organizationId: currentOrg?._id,
<THead> // publicKey: keyPair.publicKey,
<Th>Name</Th> // expiresIn: Number(expiresIn)
<Th className="w-full">Valid Until</Th> // });
<Th aria-label="actions" />
</THead> // setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
<TBody>
{isServiceAccountsLoading && <TableSkeleton columns={5} key="org-service-accounts" />} // setStep(1);
{!isServiceAccountsLoading && ( // reset();
filteredServiceAccounts.map(({ // }
name,
expiresAt, const onRemoveServiceAccount = async () => {
_id: serviceAccountId const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
}) => { await removeServiceAccount.mutateAsync(serviceAccountId);
return ( handlePopUpClose("removeServiceAccount");
<Tr key={`org-service-account-${serviceAccountId}`}> };
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td> const filteredServiceAccounts = useMemo(
<Td> () =>
<div className="flex"> serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
<IconButton [serviceAccounts, searchServiceAccountFilter]
ariaLabel="edit" );
colorSchema="secondary"
onClick={() => { const renderStep = (stepToRender: number) => {
if (currentWorkspace?._id) { switch (stepToRender) {
router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); case 0:
} return (
}} <div>
className="mr-2" We are currently revising the service account mechanism. In the meantime, please use
> service tokens or API key to fetch secrets via API request.
<FontAwesomeIcon icon={faPencil} /> </div>
</IconButton> // <form onSubmit={handleSubmit(onAddServiceAccount)}>
<IconButton // <Controller
ariaLabel="delete" // control={control}
colorSchema="danger" // defaultValue=""
onClick={() => handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })} // name="name"
> // render={({ field, fieldState: { error } }) => (
<FontAwesomeIcon icon={faTrash} /> // <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
</IconButton> // <Input {...field} />
</div> // </FormControl>
</Td> // )}
</Tr> // />
); // <Controller
}) // control={control}
)} // name="expiresIn"
</TBody> // defaultValue={String(serviceAccountExpiration?.[0]?.value)}
</Table> // render={({ field: { onChange, ...field }, fieldState: { error } }) => {
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( // return (
<EmptyState title="No service accounts found" icon={faServer} /> // <FormControl
)} // label="Expiration"
</TableContainer> // errorText={error?.message}
<Modal // isError={Boolean(error)}
isOpen={popUp?.addServiceAccount?.isOpen} // >
onOpenChange={(isOpen) => { // <Select
handlePopUpToggle("addServiceAccount", isOpen); // defaultValue={field.value}
// reset(); // {...field}
// onValueChange={(e) => onChange(e)}
// className="w-full"
// >
// {serviceAccountExpiration.map(({ label, value }) => (
// <SelectItem value={String(value)} key={label}>
// {label}
// </SelectItem>
// ))}
// </Select>
// </FormControl>
// );
// }}
// />
// <div className="mt-8 flex items-center">
// <Button
// className="mr-4"
// size="sm"
// type="submit"
// isLoading={isSubmitting}
// isDisabled={isSubmitting}
// >
// Create Service Account
// </Button>
// <Button
// colorSchema="secondary"
// variant="plain"
// onClick={() => handlePopUpClose("addServiceAccount")}
// >
// Cancel
// </Button>
// </div>
// </form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}} }}
> >
<ModalContent <FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
title="Add Service Account" <span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
subTitle="A service account represents a machine identity such as a VM or application client." Copy
> </span>
{renderStep(step)} </IconButton>
</ModalContent> </div>
</Modal> <p className="mt-4">Public Key</p>
<DeleteActionModal <div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
isOpen={popUp.removeServiceAccount.isOpen} <p className="mr-4 break-all">{publicKey}</p>
deleteKey="remove" <IconButton
title="Do you want to remove this service account from the org?" ariaLabel="copy icon"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)} colorSchema="secondary"
onDeleteApproved={onRemoveServiceAccount} className="group relative"
/> onClick={() => {
</div> navigator.clipboard.writeText(publicKey);
); setIsPublicKeyCopied.on();
} }}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />;
}
};
return (
<div className="w-full">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
// reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && (
<TableSkeleton columns={5} innerKey="org-service-accounts" />
)}
{!isServiceAccountsLoading &&
filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(
`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() =>
handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
// reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
};
@@ -1,4 +1,4 @@
import { faKey,faXmark } from "@fortawesome/free-solid-svg-icons"; import { faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -14,99 +14,91 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { import { useDeleteAPIKey, useGetMyAPIKeys } from "@app/hooks/api";
useDeleteAPIKey,
useGetMyAPIKeys} from "@app/hooks/api";
export const APIKeyTable = () => { export const APIKeyTable = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { data, isLoading } = useGetMyAPIKeys(); const { data, isLoading } = useGetMyAPIKeys();
const { mutateAsync } = useDeleteAPIKey(); const { mutateAsync } = useDeleteAPIKey();
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => { const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
try { try {
await mutateAsync(apiKeyDataId); await mutateAsync(apiKeyDataId);
createNotification({ createNotification({
text: "Successfully deleted API key", text: "Successfully deleted API key",
type: "success" type: "success"
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete API key", text: "Failed to delete API key",
type: "error" type: "error"
}); });
}
} }
};
const formatDate = (dateToFormat: string) => { const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat); const date = new Date(dateToFormat);
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`; const formattedDate = `${day}/${month}/${year}`;
return formattedDate; return formattedDate;
} };
return ( return (
<div> <div>
<TableContainer className=""> <TableContainer className="">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Name</Th> <Th className="flex-1">Name</Th>
<Th className="flex-1">Last active</Th> <Th className="flex-1">Last active</Th>
<Th className="flex-1">Created</Th> <Th className="flex-1">Created</Th>
<Th className="flex-1">Expiration</Th> <Th className="flex-1">Expiration</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} key="api-keys" />} {isLoading && <TableSkeleton columns={4} innerKey="api-keys" />}
{!isLoading && data && data.length > 0 && data.map(({ {!isLoading &&
_id, data &&
name, data.length > 0 &&
createdAt, data.map(({ _id, name, createdAt, expiresAt, lastUsed }) => {
expiresAt, return (
lastUsed <Tr className="h-10" key={`api-key-${_id}`}>
}) => { <Td>{name}</Td>
return ( <Td>{formatDate(lastUsed)}</Td>
<Tr className="h-10" key={`api-key-${_id}`}> <Td>{formatDate(createdAt)}</Td>
<Td>{name}</Td> <Td>{formatDate(expiresAt)}</Td>
<Td>{formatDate(lastUsed)}</Td> <Td>
<Td>{formatDate(createdAt)}</Td> <IconButton
<Td>{formatDate(expiresAt)}</Td> onClick={async () => {
<Td> await handleDeleteAPIKeyDataClick(_id);
<IconButton }}
onClick={async () => { size="lg"
await handleDeleteAPIKeyDataClick(_id); colorSchema="danger"
}} variant="plain"
size="lg" ariaLabel="update"
colorSchema="danger" >
variant="plain" <FontAwesomeIcon icon={faXmark} />
ariaLabel="update" </IconButton>
> </Td>
<FontAwesomeIcon icon={faXmark} /> </Tr>
</IconButton> );
</Td> })}
</Tr> {!isLoading && data && data?.length === 0 && (
); <Tr>
})} <Td colSpan={5}>
{!isLoading && data && data?.length === 0 && ( <EmptyState title="No API Keys on file" icon={faKey} />
<Tr> </Td>
<Td colSpan={5}> </Tr>
<EmptyState )}
title="No API Keys on file" </TBody>
icon={faKey} </Table>
/> </TableContainer>
</Td> </div>
</Tr> );
)} };
</TBody>
</Table>
</TableContainer>
</div>
);
}
@@ -14,61 +14,54 @@ import {
import { useGetMySessions } from "@app/hooks/api"; import { useGetMySessions } from "@app/hooks/api";
export const SessionsTable = () => { export const SessionsTable = () => {
const { data, isLoading } = useGetMySessions(); const { data, isLoading } = useGetMySessions();
const formatDate = (dateToFormat: string) => { const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat); const date = new Date(dateToFormat);
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`; const formattedDate = `${day}/${month}/${year}`;
return formattedDate; return formattedDate;
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Created</Th> <Th>Created</Th>
<Th>Last active</Th> <Th>Last active</Th>
<Th>IP address</Th> <Th>IP address</Th>
<Th>Device</Th> <Th>Device</Th>
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} key="sesssions" />} {isLoading && <TableSkeleton columns={4} innerKey="sesssions" />}
{!isLoading && data && data.length > 0 && data.map(({ {!isLoading &&
_id, data &&
createdAt, data.length > 0 &&
lastUsed, data.map(({ _id, createdAt, lastUsed, ip, userAgent }) => {
ip, return (
userAgent <Tr className="h-10" key={`session-${_id}`}>
}) => { <Td>{formatDate(createdAt)}</Td>
return ( <Td>{formatDate(lastUsed)}</Td>
<Tr className="h-10" key={`session-${_id}`}> <Td>{ip}</Td>
<Td>{formatDate(createdAt)}</Td> <Td>{userAgent}</Td>
<Td>{formatDate(lastUsed)}</Td> </Tr>
<Td>{ip}</Td> );
<Td>{userAgent}</Td> })}
</Tr> {!isLoading && data && data?.length === 0 && (
); <Tr>
})} <Td colSpan={4}>
{!isLoading && data && data?.length === 0 && ( <EmptyState title="No sessions on file" icon={faServer} />
<Tr> </Td>
<Td colSpan={4}> </Tr>
<EmptyState )}
title="No sessions on file" </TBody>
icon={faServer} </Table>
/> </TableContainer>
</Td> );
</Tr> };
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -11,79 +11,79 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>, popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>,
{ {
name, name,
slug slug
}: { }: {
name: string; name: string;
slug: string; slug: string;
} }
) => void; ) => void;
}; };
export const EnvironmentTable = ({ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
handlePopUpOpen const { currentWorkspace, isLoading } = useWorkspace();
}: Props) => { return (
const { currentWorkspace, isLoading } = useWorkspace(); <TableContainer>
return ( <Table>
<TableContainer> <THead>
<Table> <Tr>
<THead> <Th>Name</Th>
<Tr> <Th>Slug</Th>
<Th>Name</Th> <Th aria-label="button" />
<Th>Slug</Th> </Tr>
<Th aria-label="button" /> </THead>
</Tr> <TBody>
</THead> {isLoading && <TableSkeleton columns={3} innerKey="project-envs" />}
<TBody> {!isLoading &&
{isLoading && <TableSkeleton columns={3} key="project-envs" />} currentWorkspace &&
{!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => ( currentWorkspace.environments.map(({ name, slug }) => (
<Tr key={name}> <Tr key={name}>
<Td>{name}</Td> <Td>{name}</Td>
<Td>{slug}</Td> <Td>{slug}</Td>
<Td className="flex items-center justify-end"> <Td className="flex items-center justify-end">
<IconButton <IconButton
className="mr-3 py-2" className="mr-3 py-2"
onClick={() => { onClick={() => {
handlePopUpOpen("updateEnv", { name, slug }); handlePopUpOpen("updateEnv", { name, slug });
}} }}
colorSchema="primary" colorSchema="primary"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
> >
<FontAwesomeIcon icon={faPencil} /> <FontAwesomeIcon icon={faPencil} />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={() => { onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug }); handlePopUpOpen("deleteEnv", { name, slug });
}} }}
size="lg" size="lg"
colorSchema="danger" colorSchema="danger"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
> >
<FontAwesomeIcon icon={faXmark} /> <FontAwesomeIcon icon={faXmark} />
</IconButton> </IconButton>
</Td> </Td>
</Tr> </Tr>
))} ))}
{!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && ( {!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && (
<Tr> <Tr>
<Td colSpan={3}> <Td colSpan={3}>
<EmptyState title="No environments found" /> <EmptyState title="No environments found" />
</Td> </Td>
</Tr> </Tr>
)} )}
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
); );
} };
@@ -18,65 +18,65 @@ import { useGetWsTags } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>, popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>,
{ {
name, name,
id id
}: { }: {
name: string; name: string;
id: string; id: string;
} }
) => void; ) => void;
}; };
export const SecretTagsTable = ({ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
handlePopUpOpen const { currentWorkspace } = useWorkspace();
}: Props) => { const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? "");
const { currentWorkspace }= useWorkspace();
const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? "");
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Tag</Th> <Th>Tag</Th>
<Th>Slug</Th> <Th>Slug</Th>
<Th aria-label="button" /> <Th aria-label="button" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={3} key="secret-tags" />} {isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
{!isLoading && data && data.map(({ _id, name, slug }) => ( {!isLoading &&
<Tr key={name}> data &&
<Td>{name}</Td> data.map(({ _id, name, slug }) => (
<Td>{slug}</Td> <Tr key={name}>
<Td className="flex items-center justify-end"> <Td>{name}</Td>
<IconButton <Td>{slug}</Td>
onClick={() => <Td className="flex items-center justify-end">
handlePopUpOpen("deleteTagConfirmation", { <IconButton
name, onClick={() =>
id: _id handlePopUpOpen("deleteTagConfirmation", {
}) name,
} id: _id
colorSchema="danger" })
ariaLabel="update" }
> colorSchema="danger"
<FontAwesomeIcon icon={faTrashCan} /> ariaLabel="update"
</IconButton> >
</Td> <FontAwesomeIcon icon={faTrashCan} />
</Tr> </IconButton>
))}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No secret tags found" icon={faTags} />
</Td> </Td>
</Tr> </Tr>
)} ))}
</TBody> {!isLoading && data && data?.length === 0 && (
</Table> <Tr>
</TableContainer> <Td colSpan={3}>
); <EmptyState title="No secret tags found" icon={faTags} />
} </Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -48,7 +48,7 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} key="project-service-tokens" />} {isLoading && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isLoading && {!isLoading &&
data && data &&
data.map((row) => ( data.map((row) => (
@@ -160,7 +160,7 @@ export const WebhooksTab = () => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isWebhooksLoading && <TableSkeleton columns={5} key="webhooks-loading" />} {isWebhooksLoading && <TableSkeleton columns={5} innerKey="webhooks-loading" />}
{!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( {!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
<Tr> <Tr>
<Td colSpan={5}> <Td colSpan={5}>