mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(ui): implemented the new dashboard page
This commit is contained in:
442
frontend/package-lock.json
generated
442
frontend/package-lock.json
generated
@@ -24,11 +24,13 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.3",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.3",
|
||||
"@radix-ui/react-popper": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.0.1",
|
||||
"@radix-ui/react-select": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"@radix-ui/react-tabs": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.4",
|
||||
"@reduxjs/toolkit": "^1.8.3",
|
||||
"@stripe/react-stripe-js": "^1.16.3",
|
||||
"@stripe/stripe-js": "^1.46.0",
|
||||
@@ -3310,12 +3312,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.2.tgz",
|
||||
"integrity": "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
"@radix-ui/react-primitive": "1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-slot": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
@@ -3520,6 +3535,41 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz",
|
||||
@@ -3575,6 +3625,41 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.3.tgz",
|
||||
@@ -3602,7 +3687,20 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
@@ -3624,6 +3722,41 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.1.tgz",
|
||||
"integrity": "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.2",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.2",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-slot": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.1.tgz",
|
||||
@@ -3732,6 +3865,41 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
|
||||
@@ -3807,6 +3975,65 @@
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.4.tgz",
|
||||
"integrity": "sha512-2h0BvJipRSv7nszzOCdAqb8fH7+Igz24GV1M4aCb/3UPfeMQ+clb1UM5/z2msW3Khir795OCQw2G74YGxjueWw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/primitive": "1.0.0",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.0.2",
|
||||
"@radix-ui/react-id": "1.0.0",
|
||||
"@radix-ui/react-popper": "1.1.0",
|
||||
"@radix-ui/react-portal": "1.0.1",
|
||||
"@radix-ui/react-presence": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-slot": "1.0.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.0",
|
||||
"@radix-ui/react-visually-hidden": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
|
||||
@@ -24530,12 +24757,23 @@
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.2.tgz",
|
||||
"integrity": "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
"@radix-ui/react-primitive": "1.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-slot": "1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-checkbox": {
|
||||
@@ -24690,6 +24928,35 @@
|
||||
"@radix-ui/react-presence": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-id": {
|
||||
@@ -24734,6 +25001,35 @@
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "2.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popover": {
|
||||
@@ -24757,24 +25053,64 @@
|
||||
"@radix-ui/react-use-controllable-state": "1.0.0",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "2.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.1.tgz",
|
||||
"integrity": "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-arrow": "1.0.2",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-primitive": "1.0.2",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
|
||||
"integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-slot": "1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-portal": {
|
||||
@@ -24859,6 +25195,35 @@
|
||||
"@radix-ui/react-visually-hidden": "1.0.1",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "2.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-slot": {
|
||||
@@ -24921,6 +25286,55 @@
|
||||
"@radix-ui/react-visually-hidden": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-tooltip": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.4.tgz",
|
||||
"integrity": "sha512-2h0BvJipRSv7nszzOCdAqb8fH7+Igz24GV1M4aCb/3UPfeMQ+clb1UM5/z2msW3Khir795OCQw2G74YGxjueWw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/primitive": "1.0.0",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.0.2",
|
||||
"@radix-ui/react-id": "1.0.0",
|
||||
"@radix-ui/react-popper": "1.1.0",
|
||||
"@radix-ui/react-portal": "1.0.1",
|
||||
"@radix-ui/react-presence": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-slot": "1.0.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.0.0",
|
||||
"@radix-ui/react-visually-hidden": "1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-arrow": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.1.tgz",
|
||||
"integrity": "sha512-1yientwXqXcErDHEv8av9ZVNEBldH8L9scVR3is20lL+jOCfcJyMFZFEY5cgIrgexsq1qggSXqiEL/d/4f+QXA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@radix-ui/react-primitive": "1.0.1"
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-popper": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.0.tgz",
|
||||
"integrity": "sha512-07U7jpI0dZcLRAxT7L9qs6HecSoPhDSJybF7mEGHJDBDv+ZoGCvIlva0s+WxMXwJEav+ckX3hAlXBtnHmuvlCQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@floating-ui/react-dom": "0.7.2",
|
||||
"@radix-ui/react-arrow": "1.0.1",
|
||||
"@radix-ui/react-compose-refs": "1.0.0",
|
||||
"@radix-ui/react-context": "1.0.0",
|
||||
"@radix-ui/react-primitive": "1.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.0.0",
|
||||
"@radix-ui/react-use-rect": "1.0.0",
|
||||
"@radix-ui/react-use-size": "1.0.0",
|
||||
"@radix-ui/rect": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
|
||||
|
||||
@@ -31,11 +31,13 @@
|
||||
"@radix-ui/react-hover-card": "^1.0.3",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.3",
|
||||
"@radix-ui/react-popper": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.0.1",
|
||||
"@radix-ui/react-select": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"@radix-ui/react-tabs": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.4",
|
||||
"@reduxjs/toolkit": "^1.8.3",
|
||||
"@stripe/react-stripe-js": "^1.16.3",
|
||||
"@stripe/stripe-js": "^1.46.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import NotificationProvider from '@app/components/context/Notifications/NotificationProvider';
|
||||
import Telemetry from '@app/components/utilities/telemetry/Telemetry';
|
||||
import { TooltipProvider } from '@app/components/v2';
|
||||
import { publicPaths } from '@app/const';
|
||||
import {
|
||||
AuthProvider,
|
||||
@@ -73,21 +74,23 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<WorkspaceProvider>
|
||||
<OrgProvider>
|
||||
<SubscriptionProvider>
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<AppLayout>
|
||||
<Component {...pageProps} />
|
||||
</AppLayout>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
</SubscriptionProvider>
|
||||
</OrgProvider>
|
||||
</WorkspaceProvider>
|
||||
</AuthProvider>
|
||||
<TooltipProvider>
|
||||
<AuthProvider>
|
||||
<WorkspaceProvider>
|
||||
<OrgProvider>
|
||||
<SubscriptionProvider>
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<AppLayout>
|
||||
<Component {...pageProps} />
|
||||
</AppLayout>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
</SubscriptionProvider>
|
||||
</OrgProvider>
|
||||
</WorkspaceProvider>
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,42 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.flex-0 {
|
||||
flex:0;
|
||||
}
|
||||
.flex-2 {
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.flex-3 {
|
||||
flex-grow: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.secret-table {
|
||||
@apply bg-mineshaft-800 text-left text-bunker-300 w-full;
|
||||
}
|
||||
|
||||
/* padding except for comment column */
|
||||
.secret-table th {
|
||||
@apply py-2 px-4 font-medium;
|
||||
}
|
||||
|
||||
.secret-table td {
|
||||
@apply py-1 px-1 pr-2 text-sm;
|
||||
}
|
||||
|
||||
.secret-table th:not(:last-child),.secret-table td:not(:last-child) {
|
||||
@apply border-r border-mineshaft-600;
|
||||
}
|
||||
|
||||
.secret-table tr {
|
||||
@apply border-b border-mineshaft-600;
|
||||
}
|
||||
}
|
||||
|
||||
@import '@fontsource/inter/400.css';
|
||||
@import '@fontsource/inter/500.css';
|
||||
@import '@fontsource/inter/700.css';
|
||||
@import '@fontsource/inter/700.css';
|
||||
|
||||
673
frontend/src/views/DashboardPage/DashboardPage.tsx
Normal file
673
frontend/src/views/DashboardPage/DashboardPage.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
faArrowLeft,
|
||||
faCheck,
|
||||
faClockRotateLeft,
|
||||
faCodeCommit,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
|
||||
import NavHeader from '@app/components/navigation/NavHeader';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
TableContainer,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from '@app/components/v2';
|
||||
import { leaveConfirmDefaultMessage } from '@app/const';
|
||||
import { useWorkspace } from '@app/context';
|
||||
import { useLeaveConfirm, usePersistentState, usePopUp, useToggle } from '@app/hooks';
|
||||
import {
|
||||
useBatchSecretsOp,
|
||||
useCreateWsTag,
|
||||
useGetProjectSecrets,
|
||||
useGetSecretVersion,
|
||||
useGetSnapshotSecrets,
|
||||
useGetUserAction,
|
||||
useGetUserWsEnvironments,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceSecretSnapshots,
|
||||
useGetWsSnapshotCount,
|
||||
useGetWsTags,
|
||||
usePerformSecretRollback,
|
||||
useRegisterUserAction
|
||||
} from '@app/hooks/api';
|
||||
import { secretKeys } from '@app/hooks/api/secrets/queries';
|
||||
import { WorkspaceEnv } from '@app/hooks/api/types';
|
||||
|
||||
import { CompareSecret } from './components/CompareSecret';
|
||||
import { CreateTagModal } from './components/CreateTagModal';
|
||||
import { PitDrawer } from './components/PitDrawer';
|
||||
import { SecretDetailDrawer } from './components/SecretDetailDrawer';
|
||||
import { SecretDropzone } from './components/SecretDropzone';
|
||||
import { SecretInputRow } from './components/SecretInputRow';
|
||||
import { SecretTableHeader } from './components/SecretTableHeader';
|
||||
import {
|
||||
DEFAULT_SECRET_VALUE,
|
||||
downloadSecret,
|
||||
FormData,
|
||||
schema,
|
||||
transformSecretsToBatchSecretReq,
|
||||
TSecOverwriteOpt,
|
||||
TSecretDetailsOpen
|
||||
} from './DashboardPage.utils';
|
||||
|
||||
const USER_ACTION_PUSH = 'first_time_secrets_pushed';
|
||||
|
||||
/*
|
||||
* Some imp aspects to consider. Here there are multiple stats changing
|
||||
* Thus ideally we need to use a context. But instead we rely on react hook form
|
||||
* React hook form provides context and high performance proxy based rendering
|
||||
* It also handles error handling and transferring states between inputs
|
||||
*
|
||||
* Another thing is the purpose of overrideAction
|
||||
* Before we would remove the value for personal secret when user toggle and user couldn't get it back
|
||||
* They have to reload the browser or go back all over again
|
||||
* 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
|
||||
*/
|
||||
export const DashboardPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
'secretDetails',
|
||||
'addTag',
|
||||
'secretSnapshots',
|
||||
'uploadedSecOpts',
|
||||
'compareSecrets'
|
||||
] as const);
|
||||
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle();
|
||||
const [isCommentColumnCollapsed, setIsCommentCollapsed] = usePersistentState(
|
||||
false,
|
||||
'infisical-comment-box'
|
||||
);
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [snapshotId, setSnaphotId] = useState<string | null>(null);
|
||||
const [selectedEnv, setSelectedEnv] = useState<WorkspaceEnv | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [deletedSecretIds, setDeletedSecretIds] = useState<string[]>([]);
|
||||
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
|
||||
|
||||
const { currentWorkspace, isLoading } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?._id as string;
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !workspaceId && router.isReady) {
|
||||
router.push('/noprojects');
|
||||
}
|
||||
}, [isLoading, workspaceId, router.isReady]);
|
||||
|
||||
// fetching data
|
||||
const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
|
||||
const hasUserPushed = Boolean(userAction);
|
||||
|
||||
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
|
||||
workspaceId,
|
||||
onSuccess: (data) => {
|
||||
// get an env with one of the access available
|
||||
const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
|
||||
if (env) {
|
||||
setSelectedEnv(env);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { data: secretVersion } = useGetSecretVersion({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env: selectedEnv?.slug || '',
|
||||
decryptFileKey: latestFileKey!,
|
||||
isPaused: Boolean(snapshotId)
|
||||
});
|
||||
|
||||
const {
|
||||
data: secretSnaphots,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useGetWorkspaceSecretSnapshots({
|
||||
workspaceId,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const {
|
||||
data: snapshotSecret,
|
||||
isLoading: isSnapshotSecretsLoading,
|
||||
isFetching: isSnapshotChanging
|
||||
} = useGetSnapshotSecrets({
|
||||
snapshotId: snapshotId || '',
|
||||
env: selectedEnv?.slug || '',
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } =
|
||||
useGetWsSnapshotCount(workspaceId);
|
||||
|
||||
const { data: wsTags } = useGetWsTags(workspaceId);
|
||||
// mutation calls
|
||||
const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
|
||||
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
|
||||
const { mutateAsync: registerUserAction } = useRegisterUserAction();
|
||||
const { mutateAsync: createWsTag } = useCreateWsTag();
|
||||
|
||||
const method = useForm<FormData>({
|
||||
// why any: well yup inferred ts expects other keys to defined as undefined
|
||||
values: secrets as any,
|
||||
mode: 'onBlur',
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isDirty, isSubmitting, dirtyFields },
|
||||
reset
|
||||
} = method;
|
||||
const formSecrets = useWatch({ control, name: 'secrets' });
|
||||
const { fields, prepend, append, remove, update } = useFieldArray({ control, name: 'secrets' });
|
||||
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
const isReadOnly = selectedEnv?.isWriteDenied;
|
||||
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
|
||||
const canDoRollback = !isReadOnly && !isAddOnly;
|
||||
const isSubmitDisabled =
|
||||
isReadOnly ||
|
||||
// on add only mode the formstate becomes dirty due to secrets missing some items
|
||||
// to avoid this we check dirtyFields in isAddOnly Mode
|
||||
(isAddOnly && Object.keys(dirtyFields).length === 0) ||
|
||||
(!isRollbackMode && !isAddOnly && !isDirty) ||
|
||||
isSubmitting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSnapshotChanging && Boolean(snapshotId)) {
|
||||
reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true });
|
||||
}
|
||||
}, [isSnapshotChanging]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedChanges(!isSubmitDisabled);
|
||||
}, [isSubmitDisabled]);
|
||||
|
||||
const onSortSecrets = () => {
|
||||
const dir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
const sec = getValues('secrets') || [];
|
||||
const sortedSec = sec.sort((a, b) =>
|
||||
dir === 'asc' ? a?.key?.localeCompare(b?.key || '') : b?.key?.localeCompare(a?.key || '')
|
||||
);
|
||||
setValue('secrets', sortedSec);
|
||||
setSortDir(dir);
|
||||
};
|
||||
|
||||
const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt['secrets']) => {
|
||||
const sec = getValues('secrets') || [];
|
||||
const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key]));
|
||||
const conflictingSecIds = conflictingSec.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.key]: true
|
||||
}),
|
||||
{}
|
||||
);
|
||||
// filter to get all conflicting ones
|
||||
const conflictingUploadedSec = { ...uploadedSec };
|
||||
// append non conflicting ones
|
||||
Object.keys(uploadedSec).forEach((key) => {
|
||||
if (!conflictingSecIds?.[key]) {
|
||||
append({
|
||||
...DEFAULT_SECRET_VALUE,
|
||||
key,
|
||||
value: uploadedSec[key].value,
|
||||
comment: uploadedSec[key].comments.join(',')
|
||||
});
|
||||
delete conflictingUploadedSec[key];
|
||||
}
|
||||
});
|
||||
if (conflictingSec.length > 0) {
|
||||
handlePopUpOpen('uploadedSecOpts', { secrets: conflictingUploadedSec });
|
||||
}
|
||||
};
|
||||
|
||||
const onOverwriteSecrets = () => {
|
||||
const sec = getValues('secrets') || [];
|
||||
const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets;
|
||||
const data: Array<{ key: string; index: number }> = [];
|
||||
sec.forEach(({ key }, index) => {
|
||||
if (uploadedSec?.[key]) data.push({ key, index });
|
||||
});
|
||||
data.forEach(({ key, index }) => {
|
||||
const { value, comments } = uploadedSec[key];
|
||||
const comment = comments.join(', ');
|
||||
update(index, {
|
||||
...DEFAULT_SECRET_VALUE,
|
||||
key,
|
||||
value,
|
||||
comment,
|
||||
tags: sec[index].tags
|
||||
});
|
||||
});
|
||||
handlePopUpClose('uploadedSecOpts');
|
||||
};
|
||||
|
||||
const onSecretRollback = async () => {
|
||||
if (!snapshotSecret?.version) {
|
||||
createNotification({
|
||||
text: 'Failed to find secret version',
|
||||
type: 'success'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await performSecretRollback({
|
||||
workspaceId,
|
||||
version: snapshotSecret.version
|
||||
});
|
||||
setValue('isSnapshotMode', false);
|
||||
setSnaphotId(null);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || '')
|
||||
);
|
||||
createNotification({
|
||||
text: 'Successfully rollback secrets',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
text: 'Failed to rollback secrets',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAppendSecret = () => append(DEFAULT_SECRET_VALUE);
|
||||
|
||||
const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => {
|
||||
if (isSnapshotMode) {
|
||||
await onSecretRollback();
|
||||
return;
|
||||
}
|
||||
// just closing this if save is triggered from drawer
|
||||
handlePopUpClose('secretDetails');
|
||||
// when add only mode remove rest of things not created
|
||||
const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec;
|
||||
// encrypt and format the secrets to batch api format
|
||||
// requests = [ {method:"", secret:""} ]
|
||||
const batchedSecret = transformSecretsToBatchSecretReq(deletedSecretIds, latestFileKey, sec);
|
||||
// type check
|
||||
if (!selectedEnv?.slug) return;
|
||||
try {
|
||||
await batchSecretOp({
|
||||
requests: batchedSecret,
|
||||
workspaceId,
|
||||
environment: selectedEnv?.slug
|
||||
});
|
||||
createNotification({
|
||||
text: 'Successfully saved changes',
|
||||
type: 'success'
|
||||
});
|
||||
if (!hasUserPushed) {
|
||||
await registerUserAction(USER_ACTION_PUSH);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
text: 'Failed to save changes',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDrawerOpen = (dto: TSecretDetailsOpen) => {
|
||||
handlePopUpOpen('secretDetails', dto);
|
||||
};
|
||||
|
||||
const onEnvChange = (slug: string) => {
|
||||
if (hasUnsavedChanges) {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(leaveConfirmDefaultMessage)) return;
|
||||
}
|
||||
const env = wsEnv?.find((el) => el.slug === slug);
|
||||
if (env) setSelectedEnv(env);
|
||||
};
|
||||
|
||||
// record all deleted ids
|
||||
// This will make final deletion easier
|
||||
const onSecretDelete = (index: number, id?: string, overrideId?: string) => {
|
||||
const ids: string[] = [];
|
||||
if (id) ids.push(id);
|
||||
if (overrideId) ids.push(overrideId);
|
||||
setDeletedSecretIds((state) => [...state, ...ids]);
|
||||
remove(index);
|
||||
// just the case if this is called from drawer
|
||||
handlePopUpClose('secretDetails');
|
||||
};
|
||||
|
||||
const onCreateWsTag = async (tagName: string) => {
|
||||
try {
|
||||
await createWsTag({
|
||||
workspaceID: workspaceId,
|
||||
tagName,
|
||||
tagSlug: tagName.replace(' ', '_')
|
||||
});
|
||||
handlePopUpClose('addTag');
|
||||
createNotification({
|
||||
text: 'Successfully created a tag',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: 'Failed to create a tag',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isSecretsLoading || isEnvListLoading) {
|
||||
return (
|
||||
<div className="container mx-auto flex h-1/2 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>
|
||||
);
|
||||
}
|
||||
|
||||
// when secrets is not loading and secrets list is empty
|
||||
const isDashboardSecretEmpty = !isSecretsLoading && !formSecrets?.length;
|
||||
// when using snapshot mode and snapshot is loading and snapshot list is empty
|
||||
const isSnapshotSecretEmtpy =
|
||||
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
|
||||
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
|
||||
|
||||
const userAvailableEnvs = wsEnv?.filter(
|
||||
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto w-full px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<FormProvider {...method}>
|
||||
<form autoComplete="off">
|
||||
{/* breadcrumb row */}
|
||||
<div className="relative right-5">
|
||||
<NavHeader pageName={t('dashboard:title')} isProjectRelated />
|
||||
</div>
|
||||
{/* Secrets, commit and save button section */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{isRollbackMode ? 'Secret Snapshot' : 'Secrets'}
|
||||
</h1>
|
||||
{isRollbackMode && Boolean(snapshotSecret) && (
|
||||
<Tag colorSchema="green">
|
||||
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isRollbackMode && (
|
||||
<Button
|
||||
variant="star"
|
||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
||||
onClick={() => {
|
||||
setSnaphotId(null);
|
||||
reset({ ...secrets, isSnapshotMode: false });
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => handlePopUpOpen('secretSnapshots')}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isLoadingSnapshotCount}
|
||||
isDisabled={!canDoRollback}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={isSubmitDisabled}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />}
|
||||
onClick={handleSubmit(onSaveSecret)}
|
||||
>
|
||||
{isRollbackMode ? 'Rollback' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Environment, search and other action row */}
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<div>
|
||||
<Tooltip content="Select environment">
|
||||
<Select
|
||||
value={selectedEnv?.slug}
|
||||
onValueChange={onEnvChange}
|
||||
position="popper"
|
||||
className="min-w-[180px] bg-mineshaft-600"
|
||||
>
|
||||
{userAvailableEnvs?.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Input
|
||||
className="bg-mineshaft-600 placeholder-mineshaft-50"
|
||||
placeholder="Search keys..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton ariaLabel="download" variant="star">
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
onClick={() => downloadSecret(getValues('secrets'), selectedEnv?.slug)}
|
||||
variant="star"
|
||||
className="bg-bunker-800"
|
||||
>
|
||||
Download as .env
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Tooltip content={isSecretValueHidden ? 'Hide Secrets' : 'Reveal secrets'}>
|
||||
<IconButton
|
||||
ariaLabel="reveal"
|
||||
variant="star"
|
||||
onClick={() => setIsSecretValueHidden.toggle()}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecretValueHidden ? faEyeSlash : faEye} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false })}
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
variant="star"
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{!isSecretEmpty && (
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<SecretTableHeader
|
||||
isCommentColumnCollapsed={isCommentColumnCollapsed}
|
||||
sortDir={sortDir}
|
||||
onCommentColToggle={() => setIsCommentCollapsed(!isCommentColumnCollapsed)}
|
||||
onSort={onSortSecrets}
|
||||
/>
|
||||
<tbody>
|
||||
{fields.map(({ id, _id }, index) => (
|
||||
<SecretInputRow
|
||||
key={id}
|
||||
isReadOnly={isReadOnly}
|
||||
isRollbackMode={isRollbackMode}
|
||||
isAddOnly={isAddOnly}
|
||||
index={index}
|
||||
searchTerm={searchFilter}
|
||||
onSecretDelete={onSecretDelete}
|
||||
onRowExpand={() => onDrawerOpen({ id: _id as string, index })}
|
||||
isSecretValueHidden={isSecretValueHidden}
|
||||
isCommentColumnCollapsed={isCommentColumnCollapsed}
|
||||
wsTags={wsTags}
|
||||
onCreateTagOpen={() => handlePopUpOpen('addTag')}
|
||||
/>
|
||||
))}
|
||||
{!isReadOnly && !isRollbackMode && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<Button
|
||||
variant="plain"
|
||||
className="my-1"
|
||||
onClick={onAppendSecret}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
)}
|
||||
<PitDrawer
|
||||
isDrawerOpen={popUp?.secretSnapshots?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle('secretSnapshots', isOpen)}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
snapshotId={snapshotId}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
secretSnaphots={secretSnaphots}
|
||||
onSelectSnapshot={setSnaphotId}
|
||||
/>
|
||||
<SecretDetailDrawer
|
||||
onSave={handleSubmit(onSaveSecret)}
|
||||
isReadOnly={isReadOnly || isRollbackMode}
|
||||
onSecretDelete={onSecretDelete}
|
||||
isDrawerOpen={popUp?.secretDetails?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle('secretDetails', isOpen)}
|
||||
secretVersion={secretVersion}
|
||||
index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index}
|
||||
onEnvCompare={(key) => handlePopUpOpen('compareSecrets', key)}
|
||||
/>
|
||||
<SecretDropzone
|
||||
isSmaller={!isSecretEmpty}
|
||||
onParsedEnv={handleUploadedEnv}
|
||||
onAddNewSecret={onAppendSecret}
|
||||
/>
|
||||
</div>
|
||||
{/* secrets table and drawers, modals */}
|
||||
</form>
|
||||
{/* Create a new tag modal */}
|
||||
<Modal
|
||||
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."
|
||||
>
|
||||
<CreateTagModal onCreateTag={onCreateWsTag} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{/* Uploaded env override or not confirmation modal */}
|
||||
<Modal
|
||||
isOpen={popUp?.uploadedSecOpts?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle('uploadedSecOpts', open)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Duplicate Secrets"
|
||||
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>
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={popUp?.compareSecrets?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle('compareSecrets', open)}
|
||||
>
|
||||
<ModalContent
|
||||
title={popUp?.compareSecrets?.data as string}
|
||||
subTitle="Secret value in available environments"
|
||||
overlayClassName="z-[90]"
|
||||
>
|
||||
<CompareSecret
|
||||
workspaceId={workspaceId}
|
||||
envs={userAvailableEnvs || []}
|
||||
secretKey={popUp?.compareSecrets?.data as string}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
231
frontend/src/views/DashboardPage/DashboardPage.utils.ts
Normal file
231
frontend/src/views/DashboardPage/DashboardPage.utils.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as yup from 'yup';
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
} from '@app/components/utilities/cryptography/crypto';
|
||||
import { BatchSecretDTO } from '@app/hooks/api/secrets/types';
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = 'created',
|
||||
Modified = 'modified',
|
||||
Deleted = 'deleted'
|
||||
}
|
||||
|
||||
export const DEFAULT_SECRET_VALUE = {
|
||||
_id: undefined,
|
||||
overrideAction: undefined,
|
||||
idOverride: undefined,
|
||||
valueOverride: undefined,
|
||||
comment: '',
|
||||
key: '',
|
||||
value: '',
|
||||
tags: []
|
||||
};
|
||||
|
||||
const secretSchema = yup.object({
|
||||
_id: yup.string(),
|
||||
key: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required()
|
||||
.label('Secret key')
|
||||
.test('starts-with-number', 'Should start with an alphabet', (val) =>
|
||||
Boolean(val?.charAt(0)?.match(/[a-zA-Z]/i))
|
||||
)
|
||||
.test({
|
||||
name: 'duplicate-keys',
|
||||
// TODO:(akhilmhdh) ts keeps throwing from not found need to see how to resolve this
|
||||
test: (val, ctx: any) => {
|
||||
const secrets: Array<{ key: string }> = ctx?.from?.[1]?.value?.secrets || [];
|
||||
const duplicateKeys: Record<number, boolean> = {};
|
||||
secrets?.forEach(({ key }, index) => {
|
||||
if (key === val) duplicateKeys[index + 1] = true;
|
||||
});
|
||||
const pos = Object.keys(duplicateKeys);
|
||||
if (pos.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
return ctx.createError({ message: `Same key in row ${pos.join(', ')}` });
|
||||
}
|
||||
}),
|
||||
value: yup.string().trim(),
|
||||
comment: yup.string().trim(),
|
||||
tags: yup.array(
|
||||
yup.object({
|
||||
_id: yup.string().required(),
|
||||
name: yup.string().required(),
|
||||
slug: yup.string().required()
|
||||
})
|
||||
),
|
||||
overrideAction: yup.string().notRequired().oneOf(Object.values(SecretActionType)),
|
||||
idOverride: yup.string().notRequired(),
|
||||
valueOverride: yup.string().trim().notRequired()
|
||||
});
|
||||
|
||||
export const schema = yup.object({
|
||||
isSnapshotMode: yup.bool().notRequired(),
|
||||
secrets: yup.array(secretSchema)
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
export type TSecretDetailsOpen = { index: number; id: string };
|
||||
export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> };
|
||||
|
||||
export const downloadSecret = (secrets: FormData['secrets'] = [], env: string = 'unknown') => {
|
||||
const finalSecret = secrets.map(({ key, value, valueOverride, overrideAction, comment }) => ({
|
||||
key,
|
||||
value: overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value,
|
||||
comment
|
||||
}));
|
||||
|
||||
let file = '';
|
||||
finalSecret.forEach(({ key, value, comment }) => {
|
||||
if (comment) {
|
||||
file += `# ${comment}\n${key}=${value}\n`;
|
||||
return;
|
||||
}
|
||||
file += `${key}=${value}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([file]);
|
||||
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||
const alink = document.createElement('a');
|
||||
alink.href = fileDownloadUrl;
|
||||
alink.download = `${env}.env`;
|
||||
alink.click();
|
||||
};
|
||||
|
||||
/*
|
||||
* Below functions are used convert the dashboard secrets to the bulk secret creation request format
|
||||
* They are encrypted back
|
||||
* Formatted to [ { request: "", secret:{} } ]
|
||||
*/
|
||||
const encryptASecret = (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 transformSecretsToBatchSecretReq = (
|
||||
deletedSecretIds: string[],
|
||||
latestFileKey: any,
|
||||
secrets: FormData['secrets']
|
||||
) => {
|
||||
// deleted secrets
|
||||
const secretsToBeDeleted: BatchSecretDTO['requests'] = deletedSecretIds.map((id) => ({
|
||||
method: 'DELETE',
|
||||
secret: { _id: id }
|
||||
}));
|
||||
|
||||
const secretsToBeUpdated: BatchSecretDTO['requests'] = [];
|
||||
const secretsToBeCreated: BatchSecretDTO['requests'] = [];
|
||||
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');
|
||||
|
||||
secrets?.forEach(
|
||||
({ _id, idOverride, value, valueOverride, overrideAction, tags = [], comment, key }) => {
|
||||
if (!idOverride && overrideAction === SecretActionType.Created) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'personal',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
// to be created ones as they don't have server generated id
|
||||
if (!_id) {
|
||||
secretsToBeCreated.push({
|
||||
method: 'POST',
|
||||
secret: {
|
||||
type: 'shared',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
return; // exit as updated and delete case won't happen when created
|
||||
}
|
||||
// has an id means this is updated one
|
||||
if (_id) {
|
||||
secretsToBeUpdated.push({
|
||||
method: 'PATCH',
|
||||
secret: {
|
||||
_id,
|
||||
type: 'shared',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
if (idOverride) {
|
||||
// if action is deleted meaning override has been removed but id is kept to collect at this point
|
||||
if (overrideAction === SecretActionType.Deleted) {
|
||||
secretsToBeDeleted.push({ method: 'DELETE', secret: { _id: idOverride } });
|
||||
} else {
|
||||
// if not deleted action then as id is there its an updated
|
||||
secretsToBeUpdated.push({
|
||||
method: 'PATCH',
|
||||
secret: {
|
||||
_id: idOverride,
|
||||
type: 'personal',
|
||||
tags,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormControl, Input, Spinner } from '@app/components/v2';
|
||||
import { useGetProjectSecrets, useGetUserWsKey } from '@app/hooks/api';
|
||||
|
||||
type SecretValueProps = {
|
||||
workspaceId: string;
|
||||
envName: string;
|
||||
env: string;
|
||||
secretKey: string;
|
||||
};
|
||||
|
||||
const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => {
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const getValue = useCallback(
|
||||
(data: typeof secret) => {
|
||||
const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey);
|
||||
return sec?.value || 'Not found';
|
||||
},
|
||||
[secretKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl label={envName}>
|
||||
<Input
|
||||
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
|
||||
value={getValue(secret)}
|
||||
isReadOnly
|
||||
rightIcon={isSecretsLoading ? <Spinner /> : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
secretKey: string;
|
||||
envs: Array<{ name: string; slug: string }>;
|
||||
};
|
||||
|
||||
export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => {
|
||||
// should not do anything until secretKey is available
|
||||
if (!secretKey) return <div />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{envs.map(({ name, slug }) => (
|
||||
<SecretValue
|
||||
workspaceId={workspaceId}
|
||||
key={`secret-comparison-${slug}`}
|
||||
envName={name}
|
||||
env={slug}
|
||||
secretKey={secretKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CompareSecret } from './CompareSecret';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { Button, FormControl, Input, ModalClose } from '@app/components/v2';
|
||||
|
||||
type Props = {
|
||||
onCreateTag: (tagName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const createTagSchema = yup.object({
|
||||
name: yup.string().required().trim().label('Tag Name')
|
||||
});
|
||||
type FormData = yup.InferType<typeof createTagSchema>;
|
||||
|
||||
export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(createTagSchema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
await onCreateTag(name);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Tag Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="Type your tag name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {CreateTagModal} from './CreateTagModal'
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Fragment, useCallback } from 'react';
|
||||
import { InfiniteData } from '@tanstack/react-query';
|
||||
|
||||
import { Button, Drawer, DrawerContent } from '@app/components/v2';
|
||||
import timeSince from '@app/ee/utilities/timeSince';
|
||||
import { TWorkspaceSecretSnapshot } from '@app/hooks/api/secretSnapshots/types';
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
secretSnaphots?: InfiniteData<TWorkspaceSecretSnapshot[]>;
|
||||
onSelectSnapshot: (id: string) => void;
|
||||
snapshotId: string | null;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage: () => void;
|
||||
};
|
||||
|
||||
export const PitDrawer = ({
|
||||
isDrawerOpen,
|
||||
onOpenChange,
|
||||
secretSnaphots,
|
||||
onSelectSnapshot,
|
||||
snapshotId,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage
|
||||
}: Props): JSX.Element => {
|
||||
const getButtonLabel = useCallback((isFirstChild: boolean, isSelectedSnapshot: boolean) => {
|
||||
if (isFirstChild) return 'Current Version';
|
||||
if (isSelectedSnapshot) return 'Currently Viewing';
|
||||
return 'Explore';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isDrawerOpen} onOpenChange={onOpenChange}>
|
||||
<DrawerContent
|
||||
title="Point-in-time Recovery"
|
||||
subTitle="Note: This will recover secrets for all environments in this project"
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{secretSnaphots?.pages?.map((group, i) => (
|
||||
<Fragment key={`snapshot-item-${i + 1}`}>
|
||||
{group.map(({ _id, createdAt }, index) => (
|
||||
<Button
|
||||
key={_id}
|
||||
className="py-3 px-4 text-sm"
|
||||
variant={
|
||||
(i === 0 && index === 0 && snapshotId === null) || snapshotId === _id
|
||||
? 'solid'
|
||||
: 'star'
|
||||
}
|
||||
onClick={() => onSelectSnapshot(_id)}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<div>{timeSince(new Date(createdAt))}</div>
|
||||
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === _id)}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-8 py-3 px-4 text-sm"
|
||||
isFullWidth
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={fetchNextPage}
|
||||
>
|
||||
{hasNextPage ? 'Load More' : 'End of history'}
|
||||
</Button>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PitDrawer } from './PitDrawer';
|
||||
@@ -0,0 +1,52 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { faMinus, faPlus, faShuffle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import { Button, Input } from '@app/components/v2';
|
||||
|
||||
type Props = {
|
||||
onGenerate: (val: string) => void;
|
||||
};
|
||||
|
||||
export const GenRandomNumber = ({ onGenerate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(32);
|
||||
|
||||
const onGenerateRandomHex = () => {
|
||||
const rand = crypto.randomBytes(value).toString('hex');
|
||||
onGenerate(rand);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<Button
|
||||
className="rounded-r-none"
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faShuffle} />}
|
||||
onClick={onGenerateRandomHex}
|
||||
>
|
||||
{t('dashboard:sidebar.generate-random-hex')}
|
||||
</Button>
|
||||
<div className="rounded-r-md bg-mineshaft p-2">
|
||||
<div className="flex rounded-md border-mineshaft-400 bg-bunker-800 p-1 px-2">
|
||||
<button type="button" onClick={() => setValue((val) => val - 1)}>
|
||||
<FontAwesomeIcon icon={faMinus} size="xs" />
|
||||
</button>
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-8 p-0 text-center"
|
||||
value={value}
|
||||
variant="plain"
|
||||
onChange={(e) => setValue(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
<button type="button" onClick={() => setValue((val) => val + 1)}>
|
||||
<FontAwesomeIcon icon={faPlus} size="xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { faCircle, faCircleDot, faShuffle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
FormControl,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Switch,
|
||||
TextArea
|
||||
} from '@app/components/v2';
|
||||
import { useToggle } from '@app/hooks';
|
||||
|
||||
import { FormData, SecretActionType } from '../../DashboardPage.utils';
|
||||
import { GenRandomNumber } from './GenRandomNumber';
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
index: number;
|
||||
isReadOnly?: boolean;
|
||||
onEnvCompare: (secretKey: string) => void;
|
||||
secretVersion?: Array<{ id: string; createdAt: string; value: string }>;
|
||||
// to record the ids of deleted ones
|
||||
onSecretDelete: (index: number, id?: string, overrideId?: string) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const SecretDetailDrawer = ({
|
||||
isDrawerOpen,
|
||||
onOpenChange,
|
||||
index,
|
||||
secretVersion = [],
|
||||
isReadOnly,
|
||||
onSecretDelete,
|
||||
onSave,
|
||||
onEnvCompare
|
||||
}: Props): JSX.Element => {
|
||||
const [canRevealSecVal, setCanRevealSecVal] = useToggle();
|
||||
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
|
||||
|
||||
const { register, setValue, watch } = useFormContext<FormData>();
|
||||
const secret = watch(`secrets.${index}`);
|
||||
|
||||
const isOverridden =
|
||||
secret?.overrideAction === SecretActionType.Created ||
|
||||
secret?.overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (SecretActionType.Created) {
|
||||
setValue(`secrets.${index}.valueOverride`, '', { shouldDirty: true });
|
||||
}
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!secret) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer onOpenChange={onOpenChange} isOpen={isDrawerOpen}>
|
||||
<DrawerContent
|
||||
className="border-l border-mineshaft-500 bg-bunker"
|
||||
title="Secret"
|
||||
footerContent={
|
||||
<div className="flex flex-col space-y-4 pt-4 shadow-md">
|
||||
<div>
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => onEnvCompare(secret?.key)}
|
||||
isFullWidth
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Compare secret across environments
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
isDisabled={isReadOnly}
|
||||
onClick={() => onSecretDelete(index, secret._id, secret.idOverride)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="">
|
||||
<FormControl label="Key">
|
||||
<Input isDisabled {...register(`secrets.${index}.key`)} />
|
||||
</FormControl>
|
||||
<FormControl label="Value">
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.value`)}
|
||||
placeholder="EMPTY"
|
||||
onBlur={setCanRevealSecVal.off}
|
||||
onFocus={setCanRevealSecVal.on}
|
||||
type={canRevealSecVal ? 'text' : 'password'}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.value`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="mb-2 border-t border-mineshaft-600 pt-4">
|
||||
<Switch
|
||||
id="personal-override"
|
||||
onCheckedChange={onSecretOverride}
|
||||
isChecked={isOverridden}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Override with a personal value
|
||||
</Switch>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={!isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
placeholder="EMPTY"
|
||||
type={canRevealSecOverride ? 'text' : 'password'}
|
||||
onBlur={setCanRevealSecOverride.off}
|
||||
onFocus={setCanRevealSecOverride.on}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={!isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="mb-4 text-sm text-bunker-300">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-52 flex-col space-y-4 overflow-y-auto overflow-x-hidden rounded-md bg-bunker-800 p-2">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
{new Date(createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea isDisabled={isReadOnly} {...register(`secrets.${index}.comment`)} rows={5} />
|
||||
</FormControl>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {SecretDetailDrawer} from './SecretDetailDrawer'
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ChangeEvent, DragEvent } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { faUpload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
|
||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionalityj
|
||||
import { parseDotEnv } from '@app/components/utilities/parseDotEnv';
|
||||
import { Button } from '@app/components/v2';
|
||||
import { useToggle } from '@app/hooks/useToggle';
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
|
||||
onAddNewSecret?: () => void;
|
||||
};
|
||||
|
||||
export const SecretDropzone = ({ isSmaller, onParsedEnv, onAddNewSecret }: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive.on();
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive.off();
|
||||
}
|
||||
};
|
||||
|
||||
const parseFile = (file?: File) => {
|
||||
const reader = new FileReader();
|
||||
if (!file) {
|
||||
createNotification({
|
||||
text: `You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.`,
|
||||
type: 'error',
|
||||
timeoutMs: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
// const fileType = file.name.split('.')[1];
|
||||
setIsLoading.on();
|
||||
reader.onload = (event) => {
|
||||
if (!event?.target?.result) return;
|
||||
// parse function's argument looks like to be ArrayBuffer
|
||||
const env = parseDotEnv(event.target.result as ArrayBuffer);
|
||||
setIsLoading.off();
|
||||
onParsedEnv(env);
|
||||
};
|
||||
|
||||
// If something is wrong show an error
|
||||
try {
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDragActive.off();
|
||||
parseFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
parseFile(e.target?.files?.[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
'relative mb-4 mt-4 flex w-full cursor-pointer items-center justify-center space-x-2 rounded-md bg-mineshaft-900 py-8 px-2 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100',
|
||||
isDragActive && 'opacity-100',
|
||||
!isSmaller && 'flex-col space-y-4',
|
||||
isLoading && 'bg-bunker-800'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img src="/images/loading/loading.gif" height={70} width={120} alt="google logo" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? '2x' : '5x'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? 'common:drop-zone-keys' : 'common:drop-zone')}</p>
|
||||
</div>
|
||||
<input
|
||||
id="fileSelect"
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
{!isSmaller && (
|
||||
<>
|
||||
<div className="flex w-full flex-row items-center justify-center py-4">
|
||||
<div className="w-1/5 border-t border-gray-700" />
|
||||
<p className="mx-4 text-xs text-gray-400">OR</p>
|
||||
<div className="w-1/5 border-t border-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="star" onClick={onAddNewSecret}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}{' '}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretDropzone } from './SecretDropzone';
|
||||
@@ -0,0 +1,343 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
import {
|
||||
faCodeBranch,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faPlus,
|
||||
faSquare,
|
||||
faSquareCheck,
|
||||
faTrash
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { cx } from 'cva';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tag,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from '@app/components/v2';
|
||||
import { useToggle } from '@app/hooks';
|
||||
import { WsTag } from '@app/hooks/api/types';
|
||||
|
||||
import { FormData, SecretActionType } from '../../DashboardPage.utils';
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
// permission and external state's that decided to hide or show
|
||||
isReadOnly?: boolean;
|
||||
isAddOnly?: boolean;
|
||||
isRollbackMode?: boolean;
|
||||
isCommentColumnCollapsed: 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 = [
|
||||
{ bg: 'bg-[#f1c40f]/40', text: 'text-[#fcf0c3]/70' },
|
||||
{ bg: 'bg-[#cb1c8d]/40', text: 'text-[#f2c6e3]/70' },
|
||||
{ bg: 'bg-[#badc58]/40', text: 'text-[#eef6d5]/70' },
|
||||
{ bg: 'bg-[#ff5400]/40', text: 'text-[#ffddcc]/70' },
|
||||
{ bg: 'bg-[#3AB0FF]/40', text: 'text-[#f0fffd]/70' },
|
||||
{ bg: 'bg-[#6F1AB6]/40', text: 'text-[#FFE5F1]/70' },
|
||||
{ bg: 'bg-[#C40B13]/40', text: 'text-[#FFDEDE]/70' },
|
||||
{ bg: 'bg-[#332FD0]/40', text: 'text-[#DFF6FF]/70' }
|
||||
];
|
||||
|
||||
export const SecretInputRow = ({
|
||||
index,
|
||||
isCommentColumnCollapsed,
|
||||
isSecretValueHidden,
|
||||
onRowExpand,
|
||||
isReadOnly,
|
||||
isRollbackMode,
|
||||
isAddOnly,
|
||||
wsTags,
|
||||
onCreateTagOpen,
|
||||
onSecretDelete,
|
||||
searchTerm
|
||||
}: Props): JSX.Element => {
|
||||
const { register, setValue, control } = useFormContext<FormData>();
|
||||
const [canRevealSecret, setCanRevealSecret] = useToggle();
|
||||
// comment management in a row
|
||||
const {
|
||||
fields: secretTags,
|
||||
remove,
|
||||
append
|
||||
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
||||
|
||||
// to get details on a secret
|
||||
const secret = useWatch({ name: `secrets.${index}`, control });
|
||||
|
||||
const hasComment = Boolean(secret.comment);
|
||||
const tags = secret.tags || [];
|
||||
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
secret.overrideAction === SecretActionType.Created ||
|
||||
secret.overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (secret?.overrideAction === SecretActionType.Created)
|
||||
setValue(`secrets.${index}.valueOverride`, '');
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTag = (selectedTag: WsTag) => {
|
||||
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
||||
if (shouldAppend) {
|
||||
append(selectedTag);
|
||||
} else {
|
||||
const pos = tags.findIndex(({ slug }) => selectedTag.slug === slug);
|
||||
remove(pos);
|
||||
}
|
||||
};
|
||||
|
||||
const isCreatedSecret = !secret?._id;
|
||||
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
||||
|
||||
// Why this instead of filter in parent
|
||||
// Because rhf field.map has default values so basically
|
||||
// keys are not updated there and index needs to kept so that we can monitor
|
||||
// values individually here
|
||||
if (
|
||||
!(
|
||||
secret.key?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
||||
tags
|
||||
?.map((tag) => tag.name)
|
||||
.join(' ')
|
||||
?.toUpperCase()
|
||||
.includes(searchTerm?.toUpperCase()) ||
|
||||
secret.comment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
||||
)
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group">
|
||||
<td className="px-4 text-center text-xs">{index + 1}</td>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={`secrets.${index}.key`}
|
||||
render={({ fieldState: { error }, field }) => (
|
||||
<HoverCard openDelay={0} open={error?.message ? undefined : false}>
|
||||
<HoverCardTrigger asChild>
|
||||
<td className={cx(error?.message ? 'rounded ring ring-red/50' : null)}>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
variant="plain"
|
||||
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
|
||||
{...field}
|
||||
/>
|
||||
{!isAddOnly && (
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={twMerge(
|
||||
'w-0 overflow-hidden p-0 group-hover:w-6',
|
||||
isOverridden && 'w-6 text-primary'
|
||||
)}
|
||||
onClick={onSecretOverride}
|
||||
isDisabled={isRollbackMode || isReadOnly}
|
||||
ariaLabel="info"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
||||
</div>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto py-2 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
||||
</div>
|
||||
<div className="text-sm">{error?.message}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<td className="w-auto max-w-[180px] cursor-pointer text-sm hover:outline hover:outline-mineshaft-400">
|
||||
{isCommentColumnCollapsed ? (
|
||||
<div className="flex justify-center">
|
||||
{hasComment && <FontAwesomeIcon icon={faInfoCircle} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap px-2">
|
||||
{secret.comment || '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto">
|
||||
<FormControl label="Comment" className="mb-0">
|
||||
<TextArea
|
||||
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={8}
|
||||
cols={30}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<td className="flex items-center">
|
||||
<div className="flex-grow">
|
||||
{!isOverridden && (
|
||||
<Input
|
||||
variant="plain"
|
||||
isReadOnly={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
|
||||
type={canRevealSecret || isSecretValueHidden ? 'text' : 'password'}
|
||||
{...register(`secrets.${index}.value`)}
|
||||
placeholder="EMPTY"
|
||||
onBlur={setCanRevealSecret.off}
|
||||
onFocus={setCanRevealSecret.on}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
{isOverridden && (
|
||||
<Input
|
||||
variant="plain"
|
||||
isReadOnly={isReadOnly || isAddOnly || isRollbackMode}
|
||||
className="w-full text-ellipsis font-mono focus:text-bunker-100 focus:ring-transparent"
|
||||
type={canRevealSecret || isSecretValueHidden ? 'text' : 'password'}
|
||||
placeholder="EMPTY"
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
onBlur={setCanRevealSecret.off}
|
||||
onFocus={setCanRevealSecret.on}
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center group-hover:mr-2 group-hover:border-r group-hover:border-mineshaft-600">
|
||||
{secretTags.map(({ id, slug }, i) => (
|
||||
<Tag
|
||||
className={cx(
|
||||
tagColors[i % tagColors.length].bg,
|
||||
tagColors[i % tagColors.length].text
|
||||
)}
|
||||
isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
||||
onClose={() => remove(i)}
|
||||
key={id}
|
||||
>
|
||||
{slug}
|
||||
</Tag>
|
||||
))}
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="w-0 overflow-hidden group-hover:w-8">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Tooltip content="Add tag">
|
||||
<IconButton variant="star" size="xs" ariaLabel="add-tag">
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
className="max-h-96 w-auto min-w-[240px] overflow-y-auto overflow-x-hidden p-2 text-bunker-100"
|
||||
hideCloseBtn
|
||||
>
|
||||
<div className="mb-2 text-sm font-medium">Add tags to this secret</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{wsTags?.map((wsTag) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className={twMerge(
|
||||
'justify-start bg-mineshaft-800 text-bunker-100',
|
||||
selectedTagIds?.[wsTag.slug] && 'text-primary'
|
||||
)}
|
||||
onClick={() => onSelectTag(wsTag)}
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={selectedTagIds?.[wsTag.slug] ? faSquareCheck : faSquare}
|
||||
/>
|
||||
}
|
||||
key={wsTag._id}
|
||||
>
|
||||
{wsTag.slug}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="plain"
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="mt-4 justify-start bg-mineshaft-800 text-bunker-400 hover:text-primary"
|
||||
onClick={onCreateTagOpen}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add new tag
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-0 items-center justify-end space-x-2 overflow-hidden transition-all group-hover:w-16">
|
||||
<Tooltip content="delete">
|
||||
<IconButton
|
||||
size="xs"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode}
|
||||
onClick={() => onSecretDelete(index, secret._id, secret?.idOverride)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!isAddOnly && (
|
||||
<Tooltip content="more">
|
||||
<IconButton size="xs" variant="solid" onClick={onRowExpand} ariaLabel="expand">
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretInputRow } from './SecretInputRow';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { faArrowDown, faArrowUp, faComments } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { IconButton, Tooltip } from '@app/components/v2';
|
||||
|
||||
type Props = {
|
||||
isCommentColumnCollapsed?: boolean;
|
||||
sortDir: 'asc' | 'desc';
|
||||
onSort: () => void;
|
||||
onCommentColToggle: () => void;
|
||||
};
|
||||
|
||||
export const SecretTableHeader = ({
|
||||
sortDir,
|
||||
isCommentColumnCollapsed,
|
||||
onSort,
|
||||
onCommentColToggle
|
||||
}: Props): JSX.Element => (
|
||||
<thead>
|
||||
<tr className="">
|
||||
<th className="w-16 text-center">#</th>
|
||||
<th className={twMerge('w-1/5 min-w-[220px]', isCommentColumnCollapsed && 'w-1/4')}>
|
||||
<div className="inline-flex items-end">
|
||||
Key
|
||||
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
|
||||
<FontAwesomeIcon icon={sortDir === 'asc' ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className={twMerge(
|
||||
'w-1/5 cursor-pointer py-2 px-4 font-medium hover:outline hover:outline-mineshaft-400',
|
||||
isCommentColumnCollapsed && 'w-12'
|
||||
)}
|
||||
onClick={onCommentColToggle}
|
||||
>
|
||||
<Tooltip content={isCommentColumnCollapsed ? 'Expand' : 'Minimise'}>
|
||||
<div
|
||||
className={twMerge('flex items-center', isCommentColumnCollapsed && 'justify-center')}
|
||||
>
|
||||
{isCommentColumnCollapsed ? <FontAwesomeIcon icon={faComments} /> : 'Comment'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</th>
|
||||
<th className={isCommentColumnCollapsed ? 'w-3/4' : '3/5'}>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretTableHeader } from './SecretTableHeader';
|
||||
1
frontend/src/views/DashboardPage/index.tsx
Normal file
1
frontend/src/views/DashboardPage/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { DashboardPage } from './DashboardPage';
|
||||
@@ -1471,6 +1471,22 @@ module.exports = {
|
||||
opacity: 1
|
||||
// transform: "translateY(100%)",
|
||||
}
|
||||
},
|
||||
drawerRightIn: {
|
||||
'0%': {
|
||||
transform: 'translateX(100%)'
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0)'
|
||||
}
|
||||
},
|
||||
drawerRightOut: {
|
||||
'0%': {
|
||||
transform: 'translateX(0)'
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(100%)'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
@@ -1478,6 +1494,9 @@ module.exports = {
|
||||
// MODAL
|
||||
fadeIn: 'fadeIn 100ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
popIn: 'popIn 150ms cubic-bezier(0.16, 1, 0.3, 1);',
|
||||
// drawer
|
||||
drawerRightIn: 'drawerRightIn 150ms ease-in-out',
|
||||
drawerRightOut: 'drawerRightOut 150ms ease-in-out',
|
||||
// Dropdown
|
||||
slideDownAndFade: 'slideDownAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
slideLeftAndFade: 'slideLeftAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
|
||||
Reference in New Issue
Block a user