mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 15:27:27 +00:00
Merge branch 'main' into org-name-constraint
This commit is contained in:
Generated
+245
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -47,8 +48,10 @@
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
"@tanstack/virtual-file-routes": "^1.87.6",
|
||||
"@tanstack/zod-adapter": "^1.91.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
@@ -507,6 +510,24 @@
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/dagre": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
|
||||
"integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dagrejs/graphlib": "2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@dagrejs/graphlib": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
|
||||
"integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
|
||||
@@ -3955,6 +3976,61 @@
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dagre": {
|
||||
"version": "0.7.52",
|
||||
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.52.tgz",
|
||||
"integrity": "sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -4382,6 +4458,64 @@
|
||||
"vite": "^4 || ^5 || ^6"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz",
|
||||
"integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.52",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.6",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz",
|
||||
"integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz",
|
||||
"integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
@@ -5456,6 +5590,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
@@ -5808,6 +5948,111 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.2",
|
||||
"@casl/react": "^4.0.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -51,8 +52,10 @@
|
||||
"@tanstack/react-router": "^1.95.1",
|
||||
"@tanstack/virtual-file-routes": "^1.87.6",
|
||||
"@tanstack/zod-adapter": "^1.91.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"argon2-browser": "^1.18.0",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { generateUserBackupKey } from "@app/lib/crypto";
|
||||
|
||||
import { createNotification } from "../notifications";
|
||||
import { generateBackupPDFAsync } from "../utilities/generateBackupPDF";
|
||||
import { Button } from "../v2";
|
||||
|
||||
interface DownloadBackupPDFStepProps {
|
||||
incrementStep: () => void;
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the step of the signup flow where the user downloads the backup pdf
|
||||
* @param {object} obj
|
||||
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
|
||||
* @param {string} obj.email - user's email
|
||||
* @param {string} obj.password - user's password
|
||||
* @param {string} obj.name - user's name
|
||||
* @returns
|
||||
*/
|
||||
export default function DonwloadBackupPDFStep({
|
||||
incrementStep,
|
||||
email,
|
||||
password,
|
||||
name
|
||||
}: DownloadBackupPDFStepProps): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
|
||||
const handleBackupKeyGenerate = async () => {
|
||||
try {
|
||||
setIsLoading.on();
|
||||
const generatedKey = await generateUserBackupKey(email, password);
|
||||
await generateBackupPDFAsync({
|
||||
generatedKey,
|
||||
personalEmail: email,
|
||||
personalName: name
|
||||
});
|
||||
incrementStep();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to generate backup key"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
|
||||
/>
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={handleBackupKeyGenerate}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||
import {
|
||||
faArrowUpRightFromSquare,
|
||||
faUpRightAndDownLeftFromCenter,
|
||||
faWindowRestore
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
ConnectionLineType,
|
||||
Controls,
|
||||
Node,
|
||||
NodeMouseHandler,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow
|
||||
} from "@xyflow/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
|
||||
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
|
||||
import { BasePermissionEdge } from "./edges";
|
||||
import { useAccessTree } from "./hooks";
|
||||
import { FolderNode, RoleNode } from "./nodes";
|
||||
import { ViewMode } from "./types";
|
||||
|
||||
export type AccessTreeProps = {
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
};
|
||||
|
||||
const EdgeTypes = { base: BasePermissionEdge };
|
||||
|
||||
const NodeTypes = { role: RoleNode, folder: FolderNode };
|
||||
|
||||
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
const accessTreeData = useAccessTree(permissions);
|
||||
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData;
|
||||
|
||||
const { fitView, getViewport, setCenter } = useReactFlow();
|
||||
|
||||
const onNodeClick: NodeMouseHandler<Node> = useCallback(
|
||||
(_, node) => {
|
||||
setCenter(
|
||||
node.position.x + (node.width ? node.width / 2 : 0),
|
||||
node.position.y + (node.height ? node.height / 2 + 50 : 50),
|
||||
{ duration: 1000, zoom: 1 }
|
||||
);
|
||||
},
|
||||
[setCenter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
padding: 0.2,
|
||||
duration: 1000,
|
||||
maxZoom: 1
|
||||
});
|
||||
}, 1);
|
||||
}, [fitView, nodes, edges, getViewport()]);
|
||||
|
||||
const handleToggleModalView = () =>
|
||||
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
|
||||
|
||||
const handleToggleUndockedView = () =>
|
||||
setViewMode((prev) => (prev === ViewMode.Undocked ? ViewMode.Docked : ViewMode.Undocked));
|
||||
|
||||
const undockButtonLabel = `${viewMode === ViewMode.Undocked ? "Dock" : "Undock"} View`;
|
||||
const windowButtonLabel = `${viewMode === ViewMode.Modal ? "Dock" : "Expand"} View`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full",
|
||||
viewMode === ViewMode.Modal && "fixed inset-0 z-50 p-10",
|
||||
viewMode === ViewMode.Undocked &&
|
||||
"fixed bottom-4 left-20 z-50 h-[40%] w-[38%] min-w-[32rem] lg:w-[34%]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mb-4 h-full w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 transition-transform duration-500",
|
||||
viewMode === ViewMode.Docked ? "relative p-4" : "relative p-0"
|
||||
)}
|
||||
>
|
||||
{viewMode === ViewMode.Docked && (
|
||||
<div className="mb-4 flex items-start justify-between border-b border-mineshaft-400 pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Access Tree</h3>
|
||||
<p className="text-sm leading-3 text-mineshaft-400">
|
||||
Visual access policies for the configured role.
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
className="h-10 rounded-r-none bg-mineshaft-700"
|
||||
leftIcon={<FontAwesomeIcon icon={faWindowRestore} />}
|
||||
onClick={handleToggleUndockedView}
|
||||
>
|
||||
Undock
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
className="h-10 rounded-l-none bg-mineshaft-600"
|
||||
leftIcon={<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} />}
|
||||
onClick={handleToggleModalView}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center space-x-4",
|
||||
viewMode === ViewMode.Docked ? "h-96" : "h-full"
|
||||
)}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ReactFlow
|
||||
className="rounded-md border border-mineshaft"
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
edgeTypes={EdgeTypes}
|
||||
nodeTypes={NodeTypes}
|
||||
fitView
|
||||
onNodeClick={onNodeClick}
|
||||
colorMode="dark"
|
||||
nodesDraggable={false}
|
||||
edgesReconnectable={false}
|
||||
nodesConnectable={false}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
proOptions={{
|
||||
hideAttribution: false // we need pro license if we want to hide
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<Panel className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</Panel>
|
||||
)}
|
||||
{viewMode !== ViewMode.Docked && (
|
||||
<Panel position="top-right" className="flex gap-1.5">
|
||||
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
|
||||
<IconButton
|
||||
className="mr-1 rounded"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={handleToggleUndockedView}
|
||||
ariaLabel={undockButtonLabel}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
viewMode === ViewMode.Undocked
|
||||
? faArrowUpRightFromSquare
|
||||
: faWindowRestore
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
|
||||
<IconButton
|
||||
className="rounded"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={handleToggleModalView}
|
||||
ariaLabel={windowButtonLabel}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
viewMode === ViewMode.Modal
|
||||
? faArrowUpRightFromSquare
|
||||
: faUpRightAndDownLeftFromCenter
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
)}
|
||||
<PermissionSimulation {...accessTreeData} />
|
||||
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
|
||||
<Controls position="bottom-left" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccessTree = (props: AccessTreeProps) => {
|
||||
return (
|
||||
<AccessTreeErrorBoundary {...props}>
|
||||
<AccessTreeProvider>
|
||||
<ReactFlowProvider>
|
||||
<AccessTreeContent {...props} />
|
||||
</ReactFlowProvider>
|
||||
</AccessTreeProvider>
|
||||
</AccessTreeErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
|
||||
import { ViewMode } from "../types";
|
||||
|
||||
export interface AccessTreeContextProps {
|
||||
secretName: string;
|
||||
setSecretName: Dispatch<SetStateAction<string>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
}
|
||||
|
||||
const AccessTreeContext = createContext<AccessTreeContextProps | undefined>(undefined);
|
||||
|
||||
interface AccessTreeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
|
||||
const [secretName, setSecretName] = useState("");
|
||||
const [viewMode, setViewMode] = useState(ViewMode.Docked);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
secretName,
|
||||
setSecretName,
|
||||
viewMode,
|
||||
setViewMode
|
||||
}),
|
||||
[secretName, setSecretName, viewMode, setViewMode]
|
||||
);
|
||||
|
||||
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAccessTreeContext = (): AccessTreeContextProps => {
|
||||
const context = useContext(AccessTreeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAccessTreeContext must be used within a AccessTreeProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import React, { ErrorInfo, ReactNode } from "react";
|
||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||
import { faCheck, faCopy, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const ErrorDisplay = ({
|
||||
error,
|
||||
permissions
|
||||
}: {
|
||||
error: Error | null;
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
}) => {
|
||||
const display = JSON.stringify({ errorMessage: error?.message, permissions }, null, 2);
|
||||
|
||||
const [isCopied, , setIsCopied] = useTimedReset<boolean>({
|
||||
initialState: false
|
||||
});
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(display);
|
||||
setIsCopied(true);
|
||||
sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-mineshaft-100">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red" />
|
||||
<p>
|
||||
Error displaying access tree. Please contact{" "}
|
||||
<a
|
||||
className="inline cursor-pointer text-mineshaft-200 underline decoration-primary-500 underline-offset-4 duration-200 hover:text-mineshaft-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="mailto:[email protected]"
|
||||
>
|
||||
support@infisical.com
|
||||
</a>{" "}
|
||||
with the following information.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
<pre className="thin-scrollbar w-full flex-1 overflow-y-auto whitespace-pre-wrap rounded bg-mineshaft-700 p-2 text-xs text-mineshaft-100">
|
||||
{display}
|
||||
</pre>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="absolute right-4 top-2"
|
||||
ariaLabel="Copy secret value"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("Error caught by ErrorBoundary:", error, errorInfo, this.props);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { hasError, error } = this.state;
|
||||
const { children, permissions } = this.props;
|
||||
|
||||
if (hasError) {
|
||||
return <ErrorDisplay error={error} permissions={permissions} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export const AccessTreeErrorBoundary = ({ children, permissions }: ErrorBoundaryProps) => {
|
||||
return <ErrorBoundary permissions={permissions}>{children}</ErrorBoundary>;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Panel } from "@xyflow/react";
|
||||
|
||||
import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
|
||||
import { ViewMode } from "../types";
|
||||
|
||||
type TProps = {
|
||||
secretName: string;
|
||||
setSecretName: Dispatch<SetStateAction<string>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
setEnvironment: Dispatch<SetStateAction<string>>;
|
||||
environment: string;
|
||||
subject: ProjectPermissionSub;
|
||||
setSubject: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||
environments: { name: string; slug: string }[];
|
||||
};
|
||||
|
||||
export const PermissionSimulation = ({
|
||||
setEnvironment,
|
||||
environment,
|
||||
subject,
|
||||
setSubject,
|
||||
environments,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
secretName,
|
||||
setSecretName
|
||||
}: TProps) => {
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
const handlePermissionSimulation = () => {
|
||||
setExpand(true);
|
||||
setViewMode(ViewMode.Modal);
|
||||
};
|
||||
|
||||
if (viewMode !== ViewMode.Modal)
|
||||
return (
|
||||
<Panel position="top-left">
|
||||
<Button
|
||||
size="xs"
|
||||
className="mr-1 rounded"
|
||||
colorSchema="secondary"
|
||||
onClick={handlePermissionSimulation}
|
||||
>
|
||||
Permission Simulation
|
||||
</Button>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
onClick={handlePermissionSimulation}
|
||||
position="top-left"
|
||||
className={`group flex flex-col gap-2 pb-4 pr-4 ${expand ? "" : "cursor-pointer"}`}
|
||||
>
|
||||
<div className="flex w-[20rem] flex-col gap-1.5 rounded border border-mineshaft-600 bg-mineshaft-800 p-2 font-inter text-gray-200">
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-sm">Permission Simulation</span>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
ariaLabel={expand ? "Collapse" : "Expand"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpand((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={expand ? faChevronUp : faChevronDown} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{expand && (
|
||||
<p className="mb-2 mt-1 text-xs text-mineshaft-400">
|
||||
Evaluate conditional policies to see what permissions will be granted given a secret
|
||||
name or tags
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{expand && (
|
||||
<>
|
||||
<div>
|
||||
<FormLabel label="Subject" />
|
||||
<Select
|
||||
value={subject}
|
||||
onValueChange={(value) => setSubject(value as ProjectPermissionSub)}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{[
|
||||
ProjectPermissionSub.Secrets,
|
||||
ProjectPermissionSub.SecretFolders,
|
||||
ProjectPermissionSub.DynamicSecrets,
|
||||
ProjectPermissionSub.SecretImports
|
||||
].map((sub) => {
|
||||
return (
|
||||
<SelectItem className="capitalize" value={sub} key={sub}>
|
||||
{sub.replace("-", " ")}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Environment" />
|
||||
<Select
|
||||
value={environment}
|
||||
onValueChange={setEnvironment}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-[19rem]"
|
||||
>
|
||||
{environments.map(({ name, slug }) => {
|
||||
return (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
{subject === ProjectPermissionSub.Secrets && (
|
||||
<div>
|
||||
<FormLabel label="Secret Name" />
|
||||
<Input
|
||||
placeholder="*"
|
||||
value={secretName}
|
||||
onChange={(e) => setSecretName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./AccessTreeContext";
|
||||
export * from "./AccessTreeErrorBoundary";
|
||||
export * from "./PermissionSimulation";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEdge, BaseEdgeProps, EdgeProps, getSmoothStepPath } from "@xyflow/react";
|
||||
|
||||
export const BasePermissionEdge = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerStart,
|
||||
markerEnd,
|
||||
style
|
||||
}: Omit<BaseEdgeProps, "path"> & EdgeProps) => {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
markerStart={markerStart}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
strokeDasharray: "5",
|
||||
strokeWidth: 1,
|
||||
stroke: "#707174",
|
||||
...style
|
||||
}}
|
||||
path={edgePath}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BasePermissionEdge";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||
import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
|
||||
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
|
||||
|
||||
import { useAccessTreeContext } from "../components";
|
||||
import { PermissionAccess } from "../types";
|
||||
import {
|
||||
createBaseEdge,
|
||||
createFolderNode,
|
||||
createRoleNode,
|
||||
getSubjectActionRuleMap,
|
||||
positionElements
|
||||
} from "../utils";
|
||||
|
||||
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
|
||||
const [nodes, setNodes] = useNodesState<Node>([]);
|
||||
const [edges, setEdges] = useEdgesState<Edge>([]);
|
||||
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
|
||||
const [environment, setEnvironment] = useState(currentWorkspace.environments[0]?.slug ?? "");
|
||||
const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders(
|
||||
currentWorkspace.id
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
|
||||
|
||||
const { folders, name } = environmentsFolders[environment];
|
||||
|
||||
const roleNode = createRoleNode({
|
||||
subject,
|
||||
environment: name
|
||||
});
|
||||
|
||||
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
|
||||
|
||||
const folderNodes = folders.map((folder) =>
|
||||
createFolderNode({
|
||||
folder,
|
||||
permissions,
|
||||
environment,
|
||||
subject,
|
||||
secretName,
|
||||
actionRuleMap
|
||||
})
|
||||
);
|
||||
|
||||
const folderEdges = folderNodes.map(({ data: folder }) => {
|
||||
const actions = Object.values(folder.actions);
|
||||
|
||||
let access: PermissionAccess;
|
||||
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
|
||||
access = PermissionAccess.Full;
|
||||
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
|
||||
access = PermissionAccess.Partial;
|
||||
} else {
|
||||
access = PermissionAccess.None;
|
||||
}
|
||||
|
||||
return createBaseEdge({
|
||||
source: folder.parentId ?? roleNode.id,
|
||||
target: folder.id,
|
||||
access
|
||||
});
|
||||
});
|
||||
|
||||
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
|
||||
setNodes(init.nodes);
|
||||
setEdges(init.edges);
|
||||
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
subject,
|
||||
environment,
|
||||
setEnvironment,
|
||||
setSubject,
|
||||
isLoading: isPending,
|
||||
environments: currentWorkspace.environments,
|
||||
secretName,
|
||||
setSecretName,
|
||||
viewMode,
|
||||
setViewMode
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AccessTree";
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
faCheckCircle,
|
||||
faCircleMinus,
|
||||
faCircleXmark,
|
||||
faFolder
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
|
||||
import { PermissionAccess } from "../../types";
|
||||
import { createFolderNode, formatActionName } from "../../utils";
|
||||
import { FolderNodeTooltipContent } from "./components";
|
||||
|
||||
const AccessMap = {
|
||||
[PermissionAccess.Full]: { className: "text-green", icon: faCheckCircle },
|
||||
[PermissionAccess.Partial]: { className: "text-yellow", icon: faCircleMinus },
|
||||
[PermissionAccess.None]: { className: "text-red", icon: faCircleXmark }
|
||||
};
|
||||
|
||||
export const FolderNode = ({
|
||||
data
|
||||
}: NodeProps & { data: ReturnType<typeof createFolderNode>["data"] }) => {
|
||||
const { name, actions, actionRuleMap, parentId, subject } = data;
|
||||
|
||||
const hasMinimalAccess = Object.values(actions).some(
|
||||
(action) => action === PermissionAccess.Full || action === PermissionAccess.Partial
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Top}
|
||||
/>
|
||||
<div
|
||||
className={`flex ${hasMinimalAccess ? "" : "opacity-40"} h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-2 py-3 font-inter shadow-lg transition-opacity duration-500`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 text-xs text-mineshaft-100">
|
||||
<FontAwesomeIcon className="mb-0.5 font-medium text-yellow" icon={faFolder} />
|
||||
<span>{parentId ? `/${name}` : "/"}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex w-full flex-wrap items-center justify-center gap-x-2 gap-y-1 rounded bg-mineshaft-600 px-2 py-1 text-xs">
|
||||
{Object.entries(actions).map(([action, access]) => {
|
||||
const { className, icon } = AccessMap[access];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={action}
|
||||
className="hidden" // just using the tooltip to trigger node toolbar
|
||||
content={
|
||||
<FolderNodeTooltipContent
|
||||
action={action}
|
||||
access={access}
|
||||
subject={subject}
|
||||
actionRuleMap={actionRuleMap}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={icon} className={className} size="xs" />
|
||||
<span className="capitalize">{formatActionName(action)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Bottom}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { ReactElement } from "react";
|
||||
import { faCheckCircle, faCircleMinus, faCircleXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
|
||||
import {
|
||||
formatedConditionsOperatorNames,
|
||||
PermissionConditionOperators
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
import { PermissionAccess } from "../../../types";
|
||||
import { createFolderNode, formatActionName } from "../../../utils";
|
||||
|
||||
type Props = {
|
||||
action: string;
|
||||
access: PermissionAccess;
|
||||
} & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
|
||||
|
||||
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
|
||||
let component: ReactElement;
|
||||
|
||||
switch (access) {
|
||||
case PermissionAccess.Full:
|
||||
component = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 capitalize text-green">
|
||||
<FontAwesomeIcon icon={faCheckCircle} size="xs" />
|
||||
<span>Full {formatActionName(action)} Permissions</span>
|
||||
</div>
|
||||
<p className="text-mineshaft-200">
|
||||
Policy grants unconditional{" "}
|
||||
<span className="font-medium text-mineshaft-100">
|
||||
{formatActionName(action).toLowerCase()}
|
||||
</span>{" "}
|
||||
permission for {subject.replaceAll("-", " ")} in this folder.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case PermissionAccess.Partial:
|
||||
component = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 capitalize text-yellow">
|
||||
<FontAwesomeIcon icon={faCircleMinus} className="text-yellow" size="xs" />
|
||||
<span>Conditional {formatActionName(action)} Permissions</span>
|
||||
</div>
|
||||
<p className="mb-1 text-mineshaft-200">
|
||||
Policy conditionally allows{" "}
|
||||
<span className="font-medium text-mineshaft-100">
|
||||
{formatActionName(action).toLowerCase()}
|
||||
</span>{" "}
|
||||
permission for {subject.replaceAll("-", " ")} in this folder.
|
||||
</p>
|
||||
<ul className="flex list-disc flex-col gap-2 pl-4">
|
||||
{actionRuleMap.map((ruleMap, index) => {
|
||||
const rule = ruleMap[action];
|
||||
|
||||
if (
|
||||
!rule ||
|
||||
!rule.conditions ||
|
||||
(!rule.conditions.secretName && !rule.conditions.secretTags)
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<li key={`${action}_${index + 1}`}>
|
||||
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}>
|
||||
{rule.inverted ? "Forbids" : "Allows"}
|
||||
</span>
|
||||
<span> when:</span>
|
||||
{Object.entries(rule.conditions).map(([key, condition]) => (
|
||||
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
|
||||
{Object.entries(condition as object).map(([operator, value]) => (
|
||||
<li key={`${action}_${index + 1}_${key}_${operator}`}>
|
||||
<span className="font-medium capitalize text-mineshaft-100">
|
||||
{camelCaseToSpaces(key)}
|
||||
</span>{" "}
|
||||
<span className="text-mineshaft-200">
|
||||
{
|
||||
formatedConditionsOperatorNames[
|
||||
operator as PermissionConditionOperators
|
||||
]
|
||||
}
|
||||
</span>{" "}
|
||||
<span className={rule.inverted ? "text-red" : "text-green"}>
|
||||
{typeof value === "string" ? value : value.join(", ")}
|
||||
</span>
|
||||
.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case PermissionAccess.None:
|
||||
component = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 capitalize text-red">
|
||||
<FontAwesomeIcon icon={faCircleXmark} size="xs" />
|
||||
<span>No {formatActionName(action)} Permissions</span>
|
||||
</div>
|
||||
<p className="text-mineshaft-200">
|
||||
Policy always forbids{" "}
|
||||
<span className="font-medium text-mineshaft-100">
|
||||
{formatActionName(action).toLowerCase()}
|
||||
</span>{" "}
|
||||
permission for {subject.replaceAll("-", " ")} in this folder.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled access type: ${access}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeToolbar
|
||||
className="rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-light text-bunker-100"
|
||||
isVisible
|
||||
position={Position.Bottom}
|
||||
>
|
||||
{component}
|
||||
</NodeToolbar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FolderNodeTooltipContent";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FolderNode";
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import { createRoleNode } from "../utils";
|
||||
|
||||
export const RoleNode = ({
|
||||
data: { subject, environment }
|
||||
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Top}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
|
||||
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
|
||||
<span className="capitalize">{subject.replace("-", " ")} Access</span>
|
||||
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
|
||||
<p className="truncate capitalize">{environment}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Bottom}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./FolderNode/FolderNode";
|
||||
export * from "./RoleNode";
|
||||
@@ -0,0 +1,21 @@
|
||||
export enum PermissionAccess {
|
||||
Full = "full",
|
||||
Partial = "partial",
|
||||
None = "None"
|
||||
}
|
||||
|
||||
export enum PermissionNode {
|
||||
Role = "role",
|
||||
Folder = "folder",
|
||||
Environment = "environment"
|
||||
}
|
||||
|
||||
export enum PermissionEdge {
|
||||
Base = "base"
|
||||
}
|
||||
|
||||
export enum ViewMode {
|
||||
Docked = "docked",
|
||||
Modal = "modal",
|
||||
Undocked = "undocked"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
|
||||
import { PermissionAccess, PermissionEdge } from "../types";
|
||||
|
||||
export const createBaseEdge = ({
|
||||
source,
|
||||
target,
|
||||
access
|
||||
}: {
|
||||
source: string;
|
||||
target: string;
|
||||
access: PermissionAccess;
|
||||
}) => {
|
||||
const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
|
||||
return {
|
||||
id: `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
type: PermissionEdge.Base,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color
|
||||
},
|
||||
style: { stroke: color }
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { MongoAbility, MongoQuery, subject as abilitySubject } from "@casl/ability";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionSecretActions
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
|
||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||
|
||||
import { PermissionAccess, PermissionNode } from "../types";
|
||||
import { TActionRuleMap } from "./getActionRuleMap";
|
||||
|
||||
const ACTION_MAP: Record<string, string[] | undefined> = {
|
||||
[ProjectPermissionSub.Secrets]: [
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
ProjectPermissionSecretActions.Create,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
ProjectPermissionSecretActions.Delete
|
||||
],
|
||||
[ProjectPermissionSub.DynamicSecrets]: Object.values(ProjectPermissionDynamicSecretActions),
|
||||
[ProjectPermissionSub.SecretFolders]: [
|
||||
ProjectPermissionSecretActions.Create,
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
ProjectPermissionSecretActions.Delete
|
||||
]
|
||||
};
|
||||
|
||||
const evaluateCondition = (
|
||||
value: string,
|
||||
operator: PermissionConditionOperators,
|
||||
comparison: string | string[]
|
||||
) => {
|
||||
switch (operator) {
|
||||
case PermissionConditionOperators.$EQ:
|
||||
return value === comparison;
|
||||
case PermissionConditionOperators.$NEQ:
|
||||
return value !== comparison;
|
||||
case PermissionConditionOperators.$GLOB:
|
||||
return picomatch.isMatch(value, comparison);
|
||||
case PermissionConditionOperators.$IN:
|
||||
return (comparison as string[]).map((v: string) => v.trim()).includes(value);
|
||||
default:
|
||||
throw new Error(`Unhandled operator: ${operator}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const createFolderNode = ({
|
||||
folder,
|
||||
permissions,
|
||||
environment,
|
||||
subject,
|
||||
secretName,
|
||||
actionRuleMap
|
||||
}: {
|
||||
folder: TSecretFolderWithPath;
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
|
||||
environment: string;
|
||||
subject: ProjectPermissionSub;
|
||||
secretName: string;
|
||||
actionRuleMap: TActionRuleMap;
|
||||
}) => {
|
||||
const actions = Object.fromEntries(
|
||||
Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => {
|
||||
let access: PermissionAccess;
|
||||
|
||||
// wrapped in try because while editing certain conditions, if their values are empty it throws an error
|
||||
try {
|
||||
let hasPermission: boolean;
|
||||
|
||||
const subjectFields = {
|
||||
secretPath: folder.path,
|
||||
environment,
|
||||
secretName: secretName || "*",
|
||||
secretTags: ["*"]
|
||||
};
|
||||
|
||||
if (
|
||||
subject === ProjectPermissionSub.Secrets &&
|
||||
(action === ProjectPermissionSecretActions.ReadValue ||
|
||||
action === ProjectPermissionSecretActions.DescribeSecret)
|
||||
) {
|
||||
hasPermission = hasSecretReadValueOrDescribePermission(
|
||||
permissions,
|
||||
action,
|
||||
subjectFields
|
||||
);
|
||||
} else {
|
||||
hasPermission = permissions.can(
|
||||
// @ts-expect-error we are not specifying which so can't resolve if valid
|
||||
action,
|
||||
abilitySubject(subject, subjectFields)
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
// we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access
|
||||
if (
|
||||
!secretName &&
|
||||
actionRuleMap.some((el) => {
|
||||
// we only show conditional if secretName/secretTags are present - environment and path can be directly determined
|
||||
if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags)
|
||||
return false;
|
||||
|
||||
// make sure condition applies to env
|
||||
if (el[action]?.conditions?.environment) {
|
||||
if (
|
||||
!Object.entries(el[action]?.conditions?.environment).every(([operator, value]) =>
|
||||
evaluateCondition(environment, operator as PermissionConditionOperators, value)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// and applies to path
|
||||
if (el[action]?.conditions?.secretPath) {
|
||||
if (
|
||||
!Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) =>
|
||||
evaluateCondition(folder.path, operator as PermissionConditionOperators, value)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
) {
|
||||
access = PermissionAccess.Partial;
|
||||
} else {
|
||||
access = PermissionAccess.Full;
|
||||
}
|
||||
} else {
|
||||
access = PermissionAccess.None;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
access = PermissionAccess.None;
|
||||
}
|
||||
|
||||
return [action, access];
|
||||
})
|
||||
);
|
||||
|
||||
let height: number;
|
||||
|
||||
switch (subject) {
|
||||
case ProjectPermissionSub.DynamicSecrets:
|
||||
height = 130;
|
||||
break;
|
||||
case ProjectPermissionSub.Secrets:
|
||||
height = 85;
|
||||
break;
|
||||
default:
|
||||
height = 64;
|
||||
}
|
||||
|
||||
return {
|
||||
type: PermissionNode.Folder,
|
||||
id: folder.id,
|
||||
data: {
|
||||
...folder,
|
||||
actions,
|
||||
environment,
|
||||
actionRuleMap,
|
||||
subject
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
width: 264,
|
||||
height
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PermissionNode } from "../types";
|
||||
|
||||
export const createRoleNode = ({
|
||||
subject,
|
||||
environment
|
||||
}: {
|
||||
subject: string;
|
||||
environment: string;
|
||||
}) => ({
|
||||
id: `role-${subject}-${environment}`,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
subject,
|
||||
environment
|
||||
},
|
||||
type: PermissionNode.Role,
|
||||
height: 48,
|
||||
width: 264
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " "));
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||
|
||||
import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext";
|
||||
|
||||
export type TActionRuleMap = ReturnType<typeof getSubjectActionRuleMap>;
|
||||
|
||||
export const getSubjectActionRuleMap = (
|
||||
subject: ProjectPermissionSub,
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>
|
||||
) => {
|
||||
const rules = permissions.rules.filter((rule) => {
|
||||
const ruleSubject = typeof rule.subject === "string" ? rule.subject : rule.subject[0];
|
||||
|
||||
return ruleSubject === subject;
|
||||
});
|
||||
|
||||
const actionRuleMap: Record<string, (typeof rules)[number]>[] = [];
|
||||
rules.forEach((rule) => {
|
||||
if (typeof rule.action === "string") {
|
||||
actionRuleMap.push({ [rule.action]: rule });
|
||||
} else {
|
||||
actionRuleMap.push(Object.fromEntries(rule.action.map((action) => [action, rule])));
|
||||
}
|
||||
});
|
||||
|
||||
return actionRuleMap;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./createBaseEdge";
|
||||
export * from "./createFolderNode";
|
||||
export * from "./createRoleNode";
|
||||
export * from "./formatActionName";
|
||||
export * from "./getActionRuleMap";
|
||||
export * from "./positionElements";
|
||||
@@ -0,0 +1,28 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import { Edge, Node } from "@xyflow/react";
|
||||
|
||||
export const positionElements = (nodes: Node[], edges: Edge[]) => {
|
||||
const dagre = new Dagre.graphlib.Graph({ directed: true })
|
||||
.setDefaultEdgeLabel(() => ({}))
|
||||
.setGraph({ rankdir: "TB" });
|
||||
|
||||
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
|
||||
nodes.forEach((node) => dagre.setNode(node.id, node));
|
||||
|
||||
Dagre.layout(dagre, {});
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const { x, y } = dagre.node(node.id);
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: x - (node.width ? node.width / 2 : 0),
|
||||
y: y - (node.height ? node.height / 2 : 0)
|
||||
}
|
||||
};
|
||||
}),
|
||||
edges
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./AccessTree";
|
||||
export { GlobPermissionInfo } from "./GlobPermissionInfo";
|
||||
export { OrgPermissionCan } from "./OrgPermissionCan";
|
||||
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
|
||||
|
||||
+89
-21
@@ -9,9 +9,7 @@ import { Button, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
const formSchema = z.object({
|
||||
const baseFormSchema = z.object({
|
||||
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
|
||||
description: z
|
||||
.string()
|
||||
@@ -20,31 +18,55 @@ const formSchema = z.object({
|
||||
.optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
const formSchemaWithSlug = baseFormSchema.extend({
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, "Required")
|
||||
.max(64, "Too long, maximum length is 64 characters")
|
||||
.regex(
|
||||
/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/,
|
||||
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
||||
)
|
||||
});
|
||||
|
||||
export const ProjectOverviewChangeSection = () => {
|
||||
type BaseFormData = z.infer<typeof baseFormSchema>;
|
||||
type FormDataWithSlug = z.infer<typeof formSchemaWithSlug>;
|
||||
|
||||
type Props = {
|
||||
showSlugField?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync, isPending } = useUpdateProject();
|
||||
const { handleSubmit, control, reset, watch } = useForm<BaseFormData | FormDataWithSlug>({
|
||||
resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema)
|
||||
});
|
||||
|
||||
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
|
||||
const currentSlug = showSlugField ? watch("slug") : currentWorkspace?.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspace) {
|
||||
reset({
|
||||
name: currentWorkspace.name,
|
||||
description: currentWorkspace.description ?? ""
|
||||
description: currentWorkspace.description ?? "",
|
||||
...(showSlugField && { slug: currentWorkspace.slug })
|
||||
});
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
}, [currentWorkspace, showSlugField]);
|
||||
|
||||
const onFormSubmit = async ({ name, description }: FormData) => {
|
||||
const onFormSubmit = async (data: BaseFormData | FormDataWithSlug) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
await mutateAsync({
|
||||
projectID: currentWorkspace.id,
|
||||
newProjectName: name,
|
||||
newProjectDescription: description
|
||||
newProjectName: data.name,
|
||||
newProjectDescription: data.description,
|
||||
...(showSlugField &&
|
||||
"slug" in data && {
|
||||
newSlug: data.slug !== currentWorkspace.slug ? data.slug : undefined
|
||||
})
|
||||
});
|
||||
|
||||
createNotification({
|
||||
@@ -65,20 +87,34 @@ export const ProjectOverviewChangeSection = () => {
|
||||
<div className="justify-betweens flex">
|
||||
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
|
||||
<div className="space-x-2">
|
||||
<CopyButton
|
||||
value={currentWorkspace?.slug || ""}
|
||||
hoverText="Click to project slug"
|
||||
notificationText="Copied project slug to clipboard"
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentSlug || "");
|
||||
createNotification({
|
||||
text: "Copied project slug to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
}}
|
||||
title="Click to copy project slug"
|
||||
>
|
||||
Copy Project Slug
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
value={currentWorkspace?.id || ""}
|
||||
hoverText="Click to project ID"
|
||||
notificationText="Copied project ID to clipboard"
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentWorkspace?.id || "");
|
||||
createNotification({
|
||||
text: "Copied project ID to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
}}
|
||||
title="Click to copy project ID"
|
||||
>
|
||||
Copy Project ID
|
||||
</CopyButton>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -113,6 +149,38 @@ export const ProjectOverviewChangeSection = () => {
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
{showSlugField && (
|
||||
<div className="flex w-full flex-row items-end gap-4">
|
||||
<div className="w-full max-w-md">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Project slug"
|
||||
>
|
||||
<Input
|
||||
placeholder="Project slug"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="slug"
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-row items-end gap-4">
|
||||
<div className="w-full max-w-md">
|
||||
<ProjectPermissionCan
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -33,13 +32,7 @@ import {
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import {
|
||||
fetchOrgUsers,
|
||||
useAddUserToWsNonE2EE,
|
||||
useCreateWorkspace,
|
||||
useGetExternalKmsList,
|
||||
useGetUserWorkspaces
|
||||
} from "@app/hooks/api";
|
||||
import { useCreateWorkspace, useGetExternalKmsList, useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
||||
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
@@ -51,7 +44,6 @@ const formSchema = z.object({
|
||||
.trim()
|
||||
.max(256, "Description too long, max length is 256 characters")
|
||||
.optional(),
|
||||
addMembers: z.boolean(),
|
||||
kmsKeyId: z.string(),
|
||||
template: z.string()
|
||||
});
|
||||
@@ -73,7 +65,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
|
||||
const { user } = useUser();
|
||||
const createWs = useCreateWorkspace();
|
||||
const { refetch: refetchWorkspaces } = useGetUserWorkspaces();
|
||||
const addUsersToProject = useAddUserToWsNonE2EE();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const canReadProjectTemplates = permission.can(
|
||||
@@ -111,7 +102,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
|
||||
const onCreateProject = async ({
|
||||
name,
|
||||
description,
|
||||
addMembers,
|
||||
kmsKeyId,
|
||||
template
|
||||
}: TAddProjectFormData) => {
|
||||
@@ -128,21 +118,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
|
||||
template,
|
||||
type: projectType
|
||||
});
|
||||
const { id: newProjectId } = project;
|
||||
|
||||
if (addMembers) {
|
||||
const orgUsers = await fetchOrgUsers(currentOrg.id);
|
||||
await addUsersToProject.mutateAsync({
|
||||
usernames: orgUsers
|
||||
.filter(
|
||||
(member) => member.user.username !== user.username && member.status === "accepted"
|
||||
)
|
||||
.map((member) => member.user.username),
|
||||
projectId: newProjectId,
|
||||
orgId: currentOrg.id
|
||||
});
|
||||
}
|
||||
|
||||
await refetchWorkspaces();
|
||||
|
||||
createNotification({ text: "Project created", type: "success" });
|
||||
@@ -246,31 +221,7 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 pl-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="addMembers"
|
||||
defaultValue={false}
|
||||
render={({ field: { onBlur, value, onChange } }) => (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Member}>
|
||||
{(isAllowed) => (
|
||||
<div>
|
||||
<Checkbox
|
||||
id="add-project-layout"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
isDisabled={!isAllowed}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
Add all members of my organization to this project
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-14 flex">
|
||||
<div className="mt-4 flex">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
|
||||
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
|
||||
|
||||
+2
@@ -40,6 +40,8 @@ export const DatabricksSyncFields = () => {
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Secret Scope"
|
||||
tooltipClassName="max-w-md"
|
||||
tooltipText="Infisical recommends creating a designated Databricks secret scope for your sync to prevent removal of secrets not managed by Infisical."
|
||||
helperText={
|
||||
<Tooltip
|
||||
className="max-w-md"
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { faChevronRight, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
faChevronRight,
|
||||
faExclamationTriangle,
|
||||
faEye,
|
||||
faEyeSlash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AxiosError } from "axios";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { FormControl, FormLabel, SecretInput, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetSecretReferenceTree } from "@app/hooks/api";
|
||||
import { TSecretReferenceTraceNode } from "@app/hooks/api/types";
|
||||
import { ApiErrorTypes, TApiErrors, TSecretReferenceTraceNode } from "@app/hooks/api/types";
|
||||
|
||||
import style from "./SecretReferenceDetails.module.css";
|
||||
|
||||
@@ -84,7 +92,7 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
|
||||
const { data, isPending } = useGetSecretReferenceTree({
|
||||
const { data, isPending, isError, error } = useGetSecretReferenceTree({
|
||||
secretPath,
|
||||
environmentSlug: environment,
|
||||
projectId,
|
||||
@@ -94,6 +102,26 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
const tree = data?.tree;
|
||||
const secretValue = data?.value;
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof AxiosError) {
|
||||
const err = error?.response?.data as TApiErrors;
|
||||
|
||||
if (err?.error === ApiErrorTypes.CustomForbiddenError) {
|
||||
createNotification({
|
||||
title: "You don't have permission to view reference tree",
|
||||
text: "You don't have permission to view one or more of the referenced secrets.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
createNotification({
|
||||
title: "Error fetching secret reference tree",
|
||||
text: "Please try again later.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
@@ -114,11 +142,16 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
|
||||
</FormControl>
|
||||
<FormLabel className="mb-2" label="Reference Tree" />
|
||||
<div className="thin-scrollbar relative max-h-96 overflow-auto rounded-md border border-mineshaft-600 bg-bunker-700 py-6 text-sm text-mineshaft-200">
|
||||
{tree && (
|
||||
{isError ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2 text-red-500" />
|
||||
<p className="text-red-500">Error fetching secret reference tree</p>
|
||||
</div>
|
||||
) : tree ? (
|
||||
<ul className={style.tree}>
|
||||
<SecretReferenceNode node={tree} isRoot secretKey={secretKey} />
|
||||
</ul>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-mineshaft-400">
|
||||
Click a secret key to view its sub-references.
|
||||
|
||||
@@ -34,12 +34,12 @@ const passwordCheck = async ({
|
||||
const tests = [
|
||||
{
|
||||
name: "tooShort",
|
||||
validator: (pwd: string) => pwd.length >= 14,
|
||||
validator: (pwd: string) => pwd?.length >= 14,
|
||||
setError: setPasswordErrorTooShort
|
||||
},
|
||||
{
|
||||
name: "tooLong",
|
||||
validator: (pwd: string) => pwd.length < 101,
|
||||
validator: (pwd: string) => pwd?.length < 101,
|
||||
setError: setPasswordErrorTooLong
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Tooltip } from "../Tooltip/Tooltip";
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
tooltipText?: string;
|
||||
}
|
||||
|
||||
export const Blur = ({ className, tooltipText }: IProps) => {
|
||||
return (
|
||||
<Tooltip content={tooltipText} isDisabled={!tooltipText}>
|
||||
<div
|
||||
className={twMerge("flex w-80 flex-grow items-center py-1 pl-4 pr-2", className)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<span className="blur">********</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Blur } from "./Blur";
|
||||
@@ -120,6 +120,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
|
||||
const isPopupOpen = Boolean(suggestionSource.isOpen) && isFocused;
|
||||
const { data: secrets } = useGetProjectSecrets({
|
||||
viewSecretValue: false,
|
||||
environment: suggestionSource.environment || "",
|
||||
secretPath: suggestionSource.secretPath || "",
|
||||
workspaceId,
|
||||
|
||||
@@ -50,7 +50,9 @@ export const PopoverContent = ({
|
||||
</IconButton>
|
||||
</PopoverPrimitive.Close>
|
||||
)}
|
||||
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
|
||||
<div className="pointer-events-none">
|
||||
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
>
|
||||
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{props.icon && <FontAwesomeIcon icon={props.icon} />}
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<div className="flex-1 truncate">
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectPrimitive.Icon className="ml-3">
|
||||
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative mb-0.5 flex cursor-pointer select-none items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
|
||||
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
|
||||
isSelected && "bg-primary",
|
||||
isDisabled && "cursor-not-allowed text-gray-600 opacity-80 hover:!bg-transparent",
|
||||
className
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { unpackRules } from "@casl/ability/extra";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
conditionsMatcher,
|
||||
fetchUserProjectPermissions,
|
||||
roleQueryKeys
|
||||
} from "@app/hooks/api/roles/queries";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { omit } from "@app/lib/fn/object";
|
||||
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
|
||||
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
|
||||
|
||||
import { ProjectPermissionSet } from "./types";
|
||||
|
||||
@@ -31,33 +26,7 @@ export const useProjectPermission = () => {
|
||||
staleTime: Infinity,
|
||||
select: (data) => {
|
||||
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
|
||||
const negatedRules = groupBy(
|
||||
rule.filter((i) => i.inverted && i.conditions),
|
||||
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
|
||||
);
|
||||
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
|
||||
// this allows in frontend to skip some rules using *
|
||||
conditionsMatcher: (rules) => {
|
||||
return (entity) => {
|
||||
// skip validation if its negated rules
|
||||
const isNegatedRule =
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
|
||||
if (isNegatedRule) {
|
||||
const baseMatcher = conditionsMatcher(rules);
|
||||
return baseMatcher(entity);
|
||||
}
|
||||
|
||||
const rulesStrippedOfWildcard = omit(
|
||||
rules,
|
||||
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
|
||||
);
|
||||
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
|
||||
return baseMatcher(entity);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const ability = evaluatePermissionsAbility(rule);
|
||||
return {
|
||||
permission: ability,
|
||||
membership: {
|
||||
|
||||
@@ -7,6 +7,15 @@ export enum ProjectPermissionActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretActions {
|
||||
DescribeAndReadValue = "read",
|
||||
DescribeSecret = "describeSecret",
|
||||
ReadValue = "readValue",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionDynamicSecretActions {
|
||||
ReadRootCredential = "read-root-credential",
|
||||
CreateRootCredential = "create-root-credential",
|
||||
@@ -138,7 +147,7 @@ export type SecretImportSubjectFields = {
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSecretActions,
|
||||
(
|
||||
| ProjectPermissionSub.Secrets
|
||||
| (ForcedSubject<ProjectPermissionSub.Secrets> & SecretSubjectFields)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createMongoAbility, MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
|
||||
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import { conditionsMatcher } from "@app/hooks/api/roles/queries";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
import { omit } from "@app/lib/fn/object";
|
||||
|
||||
export const evaluatePermissionsAbility = (
|
||||
rule: RawRuleOf<MongoAbility<ProjectPermissionSet, MongoQuery>>[]
|
||||
) => {
|
||||
const negatedRules = groupBy(
|
||||
rule.filter((i) => i.inverted && i.conditions),
|
||||
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
|
||||
);
|
||||
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
|
||||
// this allows in frontend to skip some rules using *
|
||||
conditionsMatcher: (rules) => {
|
||||
return (entity) => {
|
||||
// skip validation if its negated rules
|
||||
const isNegatedRule =
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
|
||||
if (isNegatedRule) {
|
||||
const baseMatcher = conditionsMatcher(rules);
|
||||
return baseMatcher(entity);
|
||||
}
|
||||
|
||||
const rulesStrippedOfWildcard = omit(
|
||||
rules,
|
||||
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
|
||||
);
|
||||
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
|
||||
return baseMatcher(entity);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return ability;
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
export {
|
||||
useAdminDeleteUser,
|
||||
useAdminGrantServerAdminAccess,
|
||||
useCreateAdminUser,
|
||||
useUpdateAdminSlackConfig,
|
||||
useUpdateServerConfig,
|
||||
useUpdateServerEncryptionStrategy,
|
||||
useAdminGrantServerAdminAccess
|
||||
useUpdateServerEncryptionStrategy
|
||||
} from "./mutation";
|
||||
export {
|
||||
useAdminGetUsers,
|
||||
|
||||
@@ -4,19 +4,24 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { User } from "../types";
|
||||
import {
|
||||
AdminGetIdentitiesFilters,
|
||||
AdminGetUsersFilters,
|
||||
AdminSlackConfig,
|
||||
TGetServerRootKmsEncryptionDetails,
|
||||
TServerConfig
|
||||
} from "./types";
|
||||
import { Identity } from "@app/hooks/api/identities/types";
|
||||
|
||||
export const adminStandaloneKeys = {
|
||||
getUsers: "get-users"
|
||||
getUsers: "get-users",
|
||||
getIdentities: "get-identities"
|
||||
};
|
||||
|
||||
export const adminQueryKeys = {
|
||||
serverConfig: () => ["server-config"] as const,
|
||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||
getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const
|
||||
};
|
||||
@@ -68,6 +73,28 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
|
||||
return useInfiniteQuery({
|
||||
initialPageParam: 0,
|
||||
queryKey: adminQueryKeys.getIdentities(filters),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data } = await apiRequest.get<{ identities: Identity[] }>(
|
||||
"/api/v1/admin/identity-management/identities",
|
||||
{
|
||||
params: {
|
||||
...filters,
|
||||
offset: pageParam
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data.identities;
|
||||
},
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.length !== 0 ? pages.length * filters.limit : undefined
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAdminSlackConfig = () => {
|
||||
return useQuery({
|
||||
queryKey: adminQueryKeys.getAdminSlackConfig(),
|
||||
|
||||
@@ -50,6 +50,12 @@ export type TUpdateAdminSlackConfigDTO = {
|
||||
export type AdminGetUsersFilters = {
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
adminsOnly: boolean;
|
||||
};
|
||||
|
||||
export type AdminGetIdentitiesFilters = {
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export type AdminSlackConfig = {
|
||||
|
||||
@@ -2,6 +2,8 @@ export {
|
||||
useGetAuthToken,
|
||||
useOauthTokenExchange,
|
||||
useResetPassword,
|
||||
useResetPasswordV2,
|
||||
useResetUserPasswordV2,
|
||||
useSelectOrganization,
|
||||
useSendMfaToken,
|
||||
useSendPasswordResetEmail,
|
||||
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
LoginLDAPRes,
|
||||
MfaMethod,
|
||||
ResetPasswordDTO,
|
||||
ResetPasswordV2DTO,
|
||||
ResetUserPasswordV2DTO,
|
||||
SendMfaTokenDTO,
|
||||
SetupPasswordDTO,
|
||||
SRP1DTO,
|
||||
SRPR1Res,
|
||||
TOauthTokenExchangeDTO,
|
||||
UserAgentType,
|
||||
UserEncryptionVersion,
|
||||
VerifyMfaTokenDTO,
|
||||
VerifyMfaTokenRes,
|
||||
VerifySignupInviteDTO
|
||||
@@ -247,7 +250,10 @@ export const useSendPasswordResetEmail = () => {
|
||||
export const useVerifyPasswordResetCode = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ email, code }: { email: string; code: string }) => {
|
||||
const { data } = await apiRequest.post("/api/v1/password/email/password-reset-verify", {
|
||||
const { data } = await apiRequest.post<{
|
||||
token: string;
|
||||
userEncryptionVersion: UserEncryptionVersion;
|
||||
}>("/api/v1/password/email/password-reset-verify", {
|
||||
email,
|
||||
code
|
||||
});
|
||||
@@ -302,6 +308,26 @@ export const useResetPassword = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetPasswordV2 = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (details: ResetPasswordV2DTO) => {
|
||||
await apiRequest.post("/api/v2/password/password-reset", details, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${details.verificationToken}`
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetUserPasswordV2 = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (details: ResetUserPasswordV2DTO) => {
|
||||
await apiRequest.post("/api/v2/password/user/password-reset", details);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const changePassword = async (details: ChangePasswordDTO) => {
|
||||
const { data } = await apiRequest.post("/api/v1/password/change-password", details);
|
||||
return data;
|
||||
|
||||
@@ -3,6 +3,11 @@ export type GetAuthTokenAPI = {
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export enum UserEncryptionVersion {
|
||||
V1 = 1,
|
||||
V2 = 2
|
||||
}
|
||||
|
||||
export type SendMfaTokenDTO = {
|
||||
email: string;
|
||||
};
|
||||
@@ -136,6 +141,16 @@ export type ResetPasswordDTO = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type ResetPasswordV2DTO = {
|
||||
newPassword: string;
|
||||
verificationToken: string;
|
||||
};
|
||||
|
||||
export type ResetUserPasswordV2DTO = {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type SetupPasswordDTO = {
|
||||
protectedKey: string;
|
||||
protectedKeyIV: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
useGetAccessibleSecrets,
|
||||
useGetProjectSecretsDetails,
|
||||
useGetProjectSecretsOverview,
|
||||
useGetProjectSecretsQuickSearch
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DashboardSecretsOrderBy,
|
||||
TDashboardProjectSecretsQuickSearch,
|
||||
TDashboardProjectSecretsQuickSearchResponse,
|
||||
TGetAccessibleSecretsDTO,
|
||||
TGetDashboardProjectSecretsByKeys,
|
||||
TGetDashboardProjectSecretsDetailsDTO,
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
@@ -20,6 +21,8 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { groupBy, unique } from "@app/lib/fn/array";
|
||||
|
||||
import { SecretV3Raw } from "../types";
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: () => ["dashboard"] as const,
|
||||
getDashboardSecrets: ({
|
||||
@@ -58,6 +61,17 @@ export const dashboardKeys = {
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
"quick-search",
|
||||
params
|
||||
] as const,
|
||||
getAccessibleSecrets: ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}: TGetAccessibleSecretsDTO) =>
|
||||
[
|
||||
...dashboardKeys.all(),
|
||||
"accessible-secrets",
|
||||
{ projectId, secretPath, environment, filterByAction }
|
||||
] as const
|
||||
};
|
||||
|
||||
@@ -207,6 +221,7 @@ export const useGetProjectSecretsDetails = (
|
||||
search = "",
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
viewSecretValue,
|
||||
includeImports,
|
||||
includeDynamicSecrets,
|
||||
tags
|
||||
@@ -231,6 +246,7 @@ export const useGetProjectSecretsDetails = (
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
viewSecretValue,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
@@ -247,6 +263,7 @@ export const useGetProjectSecretsDetails = (
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
viewSecretValue,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
@@ -292,6 +309,22 @@ export const fetchProjectSecretsQuickSearch = async ({
|
||||
return data;
|
||||
};
|
||||
|
||||
const fetchAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
||||
"/api/v1/dashboard/accessible-secrets",
|
||||
{
|
||||
params: { projectId, secretPath, environment, filterByAction }
|
||||
}
|
||||
);
|
||||
|
||||
return data.secrets;
|
||||
};
|
||||
|
||||
export const useGetProjectSecretsQuickSearch = (
|
||||
{
|
||||
projectId,
|
||||
@@ -354,3 +387,32 @@ export const useGetProjectSecretsQuickSearch = (
|
||||
placeholderData: (previousData) => previousData
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAccessibleSecrets = ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
options
|
||||
}: TGetAccessibleSecretsDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
SecretV3Raw[],
|
||||
unknown,
|
||||
SecretV3Raw[],
|
||||
ReturnType<typeof dashboardKeys.getAccessibleSecrets>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
queryKey: dashboardKeys.getAccessibleSecrets({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
}),
|
||||
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
@@ -69,6 +70,7 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
"environments"
|
||||
> & {
|
||||
viewSecretValue: boolean;
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
tags: Record<string, boolean>;
|
||||
@@ -100,3 +102,12 @@ export type TGetDashboardProjectSecretsByKeys = {
|
||||
environment: string;
|
||||
keys: string[];
|
||||
};
|
||||
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
filterByAction:
|
||||
| ProjectPermissionSecretActions.DescribeSecret
|
||||
| ProjectPermissionSecretActions.ReadValue;
|
||||
};
|
||||
|
||||
@@ -10,18 +10,14 @@ import {
|
||||
formatedConditionsOperatorNames,
|
||||
PermissionConditionOperators
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { camelCaseToSpaces } from "@app/lib/fn/string";
|
||||
|
||||
import { ApiErrorTypes, TApiErrors } from "./types";
|
||||
|
||||
// this is saved in react-query cache
|
||||
export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
|
||||
export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"];
|
||||
export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"];
|
||||
|
||||
const camelCaseToSpaces = (input: string) => {
|
||||
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
};
|
||||
|
||||
export const onRequestError = (error: unknown) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as TApiErrors;
|
||||
|
||||
@@ -79,6 +79,7 @@ export const decryptSecrets = (
|
||||
id: encSecret.id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TDeleteFolderDTO,
|
||||
TGetFoldersByEnvDTO,
|
||||
TGetProjectFoldersDTO,
|
||||
TProjectEnvironmentsFolders,
|
||||
TSecretFolder,
|
||||
TUpdateFolderBatchDTO,
|
||||
TUpdateFolderDTO
|
||||
@@ -23,7 +24,9 @@ import {
|
||||
|
||||
export const folderQueryKeys = {
|
||||
getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) =>
|
||||
["secret-folders", { projectId, environment, path }] as const
|
||||
["secret-folders", { projectId, environment, path }] as const,
|
||||
getProjectEnvironmentsFolders: (projectId: string) =>
|
||||
["secret-folders", "environment", projectId] as const
|
||||
};
|
||||
|
||||
const fetchProjectFolders = async (workspaceId: string, environment: string, path = "/") => {
|
||||
@@ -37,6 +40,29 @@ const fetchProjectFolders = async (workspaceId: string, environment: string, pat
|
||||
return data.folders;
|
||||
};
|
||||
|
||||
export const useListProjectEnvironmentsFolders = (
|
||||
projectId: string,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TProjectEnvironmentsFolders,
|
||||
unknown,
|
||||
TProjectEnvironmentsFolders,
|
||||
ReturnType<typeof folderQueryKeys.getProjectEnvironmentsFolders>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: folderQueryKeys.getProjectEnvironmentsFolders(projectId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TProjectEnvironmentsFolders>(
|
||||
`/api/v1/workspace/${projectId}/environment-folder-tree`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
export const useGetProjectFolders = ({
|
||||
projectId,
|
||||
environment,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
|
||||
export enum ReservedFolders {
|
||||
SecretReplication = "__reserve_replication_"
|
||||
}
|
||||
@@ -6,6 +8,13 @@ export type TSecretFolder = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
export type TSecretFolderWithPath = TSecretFolder & { path: string };
|
||||
|
||||
export type TProjectEnvironmentsFolders = {
|
||||
[key: string]: WorkspaceEnv & { folders: TSecretFolderWithPath[] };
|
||||
};
|
||||
|
||||
export type TGetProjectFoldersDTO = {
|
||||
|
||||
@@ -137,6 +137,7 @@ export const useGetImportedSecretsSingleEnv = ({
|
||||
env: encSecret.environment,
|
||||
key: encSecret.secretKey,
|
||||
value: encSecret.secretValue,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
tags: encSecret.tags,
|
||||
comment: encSecret.secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
@@ -176,6 +177,7 @@ export const useGetImportedSecretsAllEnvs = ({
|
||||
env: encSecret.environment,
|
||||
key: encSecret.secretKey,
|
||||
value: encSecret.secretValue,
|
||||
secretValueHidden: encSecret.secretValueHidden,
|
||||
tags: encSecret.tags,
|
||||
comment: encSecret.secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
|
||||
@@ -75,6 +75,7 @@ export const useGetSnapshotSecrets = ({ snapshotId }: TSnapshotDataProps) =>
|
||||
id: secretVersion.secretId,
|
||||
env: data.environment.slug,
|
||||
key: secretVersion.secretKey,
|
||||
secretValueHidden: secretVersion.secretValueHidden,
|
||||
value: secretVersion.secretValue || "",
|
||||
tags: secretVersion.tags,
|
||||
comment: secretVersion.secretComment,
|
||||
|
||||
@@ -26,8 +26,13 @@ import {
|
||||
|
||||
export const secretKeys = {
|
||||
// this is also used in secretSnapshot part
|
||||
getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) =>
|
||||
[{ workspaceId, environment, secretPath }, "secrets"] as const,
|
||||
getProjectSecret: ({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue
|
||||
}: TGetProjectSecretsKey) =>
|
||||
[{ workspaceId, environment, secretPath, viewSecretValue }, "secrets"] as const,
|
||||
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const,
|
||||
getSecretAccessList: ({
|
||||
workspaceId,
|
||||
@@ -44,13 +49,15 @@ export const fetchProjectSecrets = async ({
|
||||
environment,
|
||||
secretPath,
|
||||
includeImports,
|
||||
expandSecretReferences
|
||||
expandSecretReferences,
|
||||
viewSecretValue
|
||||
}: TGetProjectSecretsKey) => {
|
||||
const { data } = await apiRequest.get<SecretV3RawResponse>("/api/v3/secrets/raw", {
|
||||
params: {
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
viewSecretValue,
|
||||
expandSecretReferences,
|
||||
include_imports: includeImports
|
||||
}
|
||||
@@ -68,6 +75,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
env: el.environment,
|
||||
key: el.secretKey,
|
||||
value: el.secretValue,
|
||||
secretValueHidden: el.secretValueHidden,
|
||||
tags: el.tags || [],
|
||||
comment: el.secretComment || "",
|
||||
reminderRepeatDays: el.secretReminderRepeatDays,
|
||||
@@ -107,6 +115,7 @@ export const useGetProjectSecrets = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue,
|
||||
options
|
||||
}: TGetProjectSecretsDTO & {
|
||||
options?: Omit<
|
||||
@@ -123,8 +132,13 @@ export const useGetProjectSecrets = ({
|
||||
...options,
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(workspaceId && environment) && (options?.enabled ?? true),
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
|
||||
queryKey: secretKeys.getProjectSecret({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
viewSecretValue
|
||||
}),
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath, viewSecretValue }),
|
||||
select: useCallback(
|
||||
(data: Awaited<ReturnType<typeof fetchProjectSecrets>>) => mergePersonalSecrets(data.secrets),
|
||||
[]
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EncryptedSecret = {
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretValueHidden: boolean;
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -37,6 +38,7 @@ export type SecretV3RawSanitized = {
|
||||
version: number;
|
||||
key: string;
|
||||
value?: string;
|
||||
secretValueHidden: boolean;
|
||||
comment?: string;
|
||||
reminderRepeatDays?: number | null;
|
||||
reminderNote?: string | null;
|
||||
@@ -61,6 +63,7 @@ export type SecretV3Raw = {
|
||||
environment: string;
|
||||
version: number;
|
||||
type: string;
|
||||
secretValueHidden: boolean;
|
||||
secretKey: string;
|
||||
secretPath: string;
|
||||
secretValue?: string;
|
||||
@@ -95,12 +98,19 @@ export type SecretVersions = {
|
||||
envId: string;
|
||||
secretKey: string;
|
||||
secretValue?: string;
|
||||
secretValueHidden: boolean;
|
||||
secretComment?: string;
|
||||
tags: WsTag[];
|
||||
__v: number;
|
||||
skipMultilineEncoding?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
actor?: {
|
||||
actorId?: string | null;
|
||||
actorType?: string | null;
|
||||
name?: string | null;
|
||||
membershipId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
// dto
|
||||
@@ -109,6 +119,7 @@ export type TGetProjectSecretsKey = {
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
includeImports?: boolean;
|
||||
viewSecretValue?: boolean;
|
||||
expandSecretReferences?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ export type {
|
||||
|
||||
export enum ApiErrorTypes {
|
||||
ValidationError = "ValidationFailure",
|
||||
PermissionBoundaryError = "PermissionBoundaryError",
|
||||
BadRequestError = "BadRequest",
|
||||
UnauthorizedError = "UnauthorizedError",
|
||||
ForbiddenError = "PermissionDenied"
|
||||
ForbiddenError = "PermissionDenied",
|
||||
CustomForbiddenError = "ForbiddenError"
|
||||
}
|
||||
|
||||
export type TApiErrors =
|
||||
@@ -69,9 +71,28 @@ export type TApiErrors =
|
||||
details: PureAbility["rules"];
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.CustomForbiddenError;
|
||||
message: string;
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
}
|
||||
| {
|
||||
reqId: string;
|
||||
statusCode: 403;
|
||||
message: string;
|
||||
error: ApiErrorTypes.PermissionBoundaryError;
|
||||
details: {
|
||||
missingPermissions: {
|
||||
action: string;
|
||||
subject: string;
|
||||
conditions: Record<string, Record<string, string>>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -251,12 +251,13 @@ export const useUpdateProject = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Workspace, object, UpdateProjectDTO>({
|
||||
mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => {
|
||||
mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
|
||||
const { data } = await apiRequest.patch<{ workspace: Workspace }>(
|
||||
`/api/v1/workspace/${projectID}`,
|
||||
{
|
||||
name: newProjectName,
|
||||
description: newProjectDescription
|
||||
description: newProjectDescription,
|
||||
slug: newSlug
|
||||
}
|
||||
);
|
||||
return data.workspace;
|
||||
|
||||
@@ -74,6 +74,7 @@ export type UpdateProjectDTO = {
|
||||
projectID: string;
|
||||
newProjectName: string;
|
||||
newProjectDescription?: string;
|
||||
newSlug?: string;
|
||||
};
|
||||
|
||||
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
|
||||
|
||||
@@ -10,13 +10,16 @@ type FolderNameAndDescription = {
|
||||
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
|
||||
const folderNamesAndDescriptions = useMemo(() => {
|
||||
const namesAndDescriptions = new Map<string, FolderNameAndDescription>();
|
||||
|
||||
|
||||
folders?.forEach((folder) => {
|
||||
if (!namesAndDescriptions.has(folder.name)) {
|
||||
namesAndDescriptions.set(folder.name, { name: folder.name, description: folder.description });
|
||||
namesAndDescriptions.set(folder.name, {
|
||||
name: folder.name,
|
||||
description: folder.description
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return Array.from(namesAndDescriptions.values());
|
||||
}, [folders]);
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ export const MenuIconButton = <T extends ElementType = "button">({
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={twMerge(
|
||||
"group relative flex w-full cursor-pointer flex-col items-center justify-center rounded my-1 p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700",
|
||||
isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none",
|
||||
"group relative my-1 flex w-full cursor-pointer flex-col items-center justify-center rounded p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700",
|
||||
isSelected && "rounded-none bg-bunker-800 hover:bg-mineshaft-600",
|
||||
isDisabled && "cursor-not-allowed hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
|
||||
+50
-15
@@ -30,10 +30,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { envConfig } from "@app/config/env";
|
||||
import { useOrganization, useSubscription, useUser } from "@app/context";
|
||||
import { isInfisicalCloud } from "@app/helpers/platform";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import {
|
||||
useGetOrganizations,
|
||||
@@ -50,6 +53,7 @@ import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
|
||||
|
||||
import { MenuIconButton } from "../MenuIconButton";
|
||||
import { ServerAdminsPanel } from "../ServerAdminsPanel/ServerAdminsPanel";
|
||||
|
||||
const getPlan = (subscription: SubscriptionPlan) => {
|
||||
if (subscription.dynamicSecret) return "Enterprise Plan";
|
||||
@@ -77,6 +81,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [
|
||||
<FontAwesomeIcon key={4} className="pr-4 text-sm" icon={faEnvelope} />,
|
||||
"Email Support",
|
||||
"mailto:[email protected]"
|
||||
],
|
||||
[
|
||||
<FontAwesomeIcon key={5} className="pr-4 text-sm" icon={faUsers} />,
|
||||
"Instance Admins",
|
||||
"server-admins"
|
||||
]
|
||||
];
|
||||
|
||||
@@ -89,6 +98,7 @@ export const MinimizedOrgSidebar = () => {
|
||||
const [openSupport, setOpenSupport] = useState(false);
|
||||
const [openUser, setOpenUser] = useState(false);
|
||||
const [openOrg, setOpenOrg] = useState(false);
|
||||
const [showAdminsModal, setShowAdminsModal] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { mutateAsync } = useGetOrgTrialUrl();
|
||||
@@ -410,21 +420,39 @@ export const MinimizedOrgSidebar = () => {
|
||||
side="right"
|
||||
className="p-1"
|
||||
>
|
||||
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => (
|
||||
<DropdownMenuItem key={url as string}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={String(url)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => {
|
||||
if (url === "server-admins" && isInfisicalCloud()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem key={url as string}>
|
||||
{url === "server-admins" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdminsModal(true)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={String(url)}
|
||||
className="flex w-full items-center rounded-md font-normal text-mineshaft-300 duration-200"
|
||||
>
|
||||
<div className="relative flex w-full cursor-pointer select-none items-center justify-start rounded-md">
|
||||
{icon}
|
||||
<div className="text-sm">{text}</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{envConfig.PLATFORM_VERSION && (
|
||||
<div className="mb-2 mt-2 w-full cursor-default pl-5 text-sm duration-200 hover:text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faInfo} className="mr-4 px-[0.1rem]" />
|
||||
@@ -540,6 +568,13 @@ export const MinimizedOrgSidebar = () => {
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
<Modal isOpen={showAdminsModal} onOpenChange={setShowAdminsModal}>
|
||||
<ModalContent title="Server Administrators" subTitle="View all server administrators">
|
||||
<div className="mb-2">
|
||||
<ServerAdminsPanel />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<CreateOrgModal
|
||||
isOpen={popUp?.createOrg?.isOpen}
|
||||
onClose={() => handlePopUpToggle("createOrg", false)}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetOrgUsers } from "@app/hooks/api";
|
||||
|
||||
export const ServerAdminsPanel = () => {
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: orgUsers, isPending } = useGetOrgUsers(currentOrg?.id || "");
|
||||
|
||||
const adminUsers = orgUsers?.filter((orgUser) => {
|
||||
const isSuperAdmin = orgUser.user.superAdmin;
|
||||
const matchesSearch = debouncedSearchTerm
|
||||
? orgUser.user.email?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.firstName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
|
||||
orgUser.user.lastName?.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
|
||||
: true;
|
||||
return isSuperAdmin && matchesSearch;
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && (!adminUsers || adminUsers.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-4 px-4">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search server admins..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 px-2">
|
||||
<TableContainer className="flex max-h-[30vh] flex-col overflow-auto">
|
||||
<Table className="w-full table-fixed">
|
||||
<THead className="sticky top-0 bg-bunker-800">
|
||||
<Tr>
|
||||
<Th className="w-1/2">Name</Th>
|
||||
<Th className="w-1/2">Email</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="admins" />}
|
||||
{!isPending &&
|
||||
adminUsers?.map(({ user }) => {
|
||||
const name =
|
||||
user.firstName || user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.username;
|
||||
return (
|
||||
<Tr key={`admin-${user.id}`}>
|
||||
<Td className="w-1/2 break-words">{name}</Td>
|
||||
<Td className="w-1/2 break-words">{user.email}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{isEmpty && (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-mineshaft-400">
|
||||
No server administrators found
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MongoAbility, subject } from "@casl/ability";
|
||||
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSub,
|
||||
SecretSubjectFields
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
export function hasSecretReadValueOrDescribePermission(
|
||||
permission: MongoAbility<ProjectPermissionSet>,
|
||||
action: Extract<
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue
|
||||
>,
|
||||
subjectFields?: SecretSubjectFields
|
||||
) {
|
||||
let canNewPermission = false;
|
||||
let canOldPermission = false;
|
||||
|
||||
if (subjectFields) {
|
||||
canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields));
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, subjectFields)
|
||||
);
|
||||
} else {
|
||||
canNewPermission = permission.can(action, ProjectPermissionSub.Secrets);
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
}
|
||||
|
||||
return canNewPermission || canOldPermission;
|
||||
}
|
||||
@@ -7,3 +7,7 @@ export const formatReservedPaths = (secretPath: string) => {
|
||||
}
|
||||
return secretPath;
|
||||
};
|
||||
|
||||
export const camelCaseToSpaces = (input: string) => {
|
||||
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NotFoundPage } from "./pages/public/NotFoundPage/NotFoundPage";
|
||||
// Import the generated route tree
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "nprogress/nprogress.css";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
|
||||
@@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel";
|
||||
import { IntegrationPanel } from "./components/IntegrationPanel";
|
||||
import { RateLimitPanel } from "./components/RateLimitPanel";
|
||||
import { UserPanel } from "./components/UserPanel";
|
||||
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
|
||||
|
||||
enum TabSections {
|
||||
Settings = "settings",
|
||||
@@ -42,6 +43,7 @@ enum TabSections {
|
||||
RateLimit = "rate-limit",
|
||||
Integrations = "integrations",
|
||||
Users = "users",
|
||||
Identities = "identities",
|
||||
Kmip = "kmip"
|
||||
}
|
||||
|
||||
@@ -164,6 +166,7 @@ export const OverviewPage = () => {
|
||||
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
|
||||
<Tab value={TabSections.Integrations}>Integrations</Tab>
|
||||
<Tab value={TabSections.Users}>Users</Tab>
|
||||
<Tab value={TabSections.Identities}>Identities</Tab>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanel value={TabSections.Settings}>
|
||||
@@ -409,6 +412,9 @@ export const OverviewPage = () => {
|
||||
<TabPanel value={TabSections.Users}>
|
||||
<UserPanel />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSections.Identities}>
|
||||
<IdentityPanel />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
|
||||
|
||||
const IdentityPanelTable = () => {
|
||||
const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
|
||||
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities(
|
||||
{
|
||||
limit: 20,
|
||||
searchTerm: debouncedSearchTerm
|
||||
}
|
||||
);
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchIdentityFilter}
|
||||
onChange={(e) => setSearchIdentityFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search identities by name..."
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={2} innerKey="identities" />}
|
||||
{!isPending &&
|
||||
data?.pages?.map((identities) =>
|
||||
identities.map(({ name, id }) => (
|
||||
<Tr key={`identity-${id}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
|
||||
</TableContainer>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
className="mt-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{hasNextPage ? "Load More" : "End of list"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const IdentityPanel = () => (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
</div>
|
||||
<IdentityPanelTable />
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { faMagnifyingGlass, faUsers, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faEllipsis,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faUsers,
|
||||
faUserShield
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -8,7 +16,13 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
TableContainer,
|
||||
@@ -17,11 +31,7 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useUser } from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
@@ -32,7 +42,6 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const deleteUserUpgradePlanMessage = "Deleting users via Admin UI";
|
||||
const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions";
|
||||
|
||||
const UserPanelTable = ({
|
||||
@@ -48,25 +57,63 @@ const UserPanelTable = ({
|
||||
) => void;
|
||||
}) => {
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const [adminsOnly, setAdminsOnly] = useState(false);
|
||||
const { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
|
||||
limit: 20,
|
||||
searchTerm: debounedSearchTerm
|
||||
searchTerm: debouncedSearchTerm,
|
||||
adminsOnly
|
||||
});
|
||||
|
||||
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||
const isTableFiltered = Boolean(adminsOnly);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchUserFilter}
|
||||
onChange={(e) => setSearchUserFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Users"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setAdminsOnly(!adminsOnly);
|
||||
}}
|
||||
icon={adminsOnly && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faUserShield} className="text-yellow-700" />
|
||||
<span>Server Admins</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -108,14 +155,6 @@ const UserPanelTable = ({
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!subscription?.instanceUserManagement) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
username,
|
||||
id,
|
||||
message: deleteUserUpgradePlanMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("removeUser", { username, id });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,16 +7,13 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF";
|
||||
// TODO(akhilmhdh): rewrite this into module functions in lib
|
||||
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
||||
import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto";
|
||||
|
||||
import { DownloadBackupKeys } from "./components/DownloadBackupKeys";
|
||||
import { generateUserPassKey } from "@app/lib/crypto";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
@@ -34,25 +30,17 @@ const formSchema = z
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
enum SignupSteps {
|
||||
DetailsForm = "details-form",
|
||||
BackupKey = "backup-key"
|
||||
}
|
||||
|
||||
export const SignUpPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const [step, setStep] = useState(SignupSteps.DetailsForm);
|
||||
|
||||
const { config } = useServerConfig();
|
||||
const { mutateAsync: createAdminUser } = useCreateAdminUser();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
@@ -84,7 +72,7 @@ export const SignUpPage = () => {
|
||||
// Will be refactored in next iteration to make it url based rather than local storage ones
|
||||
// Part of migration to nextjs 14
|
||||
localStorage.setItem("orgData.id", res.organization.id);
|
||||
setStep(SignupSteps.BackupKey);
|
||||
navigate({ to: "/admin" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
@@ -94,27 +82,7 @@ export const SignUpPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupKeyGenerate = async () => {
|
||||
try {
|
||||
const { email, password, firstName, lastName } = getValues();
|
||||
const generatedKey = await generateUserBackupKey(email, password);
|
||||
await generateBackupPDFAsync({
|
||||
generatedKey,
|
||||
personalEmail: email,
|
||||
personalName: `${firstName} ${lastName}`
|
||||
});
|
||||
navigate({ to: "/admin" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to generate backup"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.initialized && step === SignupSteps.DetailsForm)
|
||||
return <ContentLoader text="Redirecting to admin page..." />;
|
||||
if (config?.initialized) return <ContentLoader text="Redirecting to admin page..." />;
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
|
||||
@@ -127,56 +95,28 @@ export const SignUpPage = () => {
|
||||
</Helmet>
|
||||
<div className="flex items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === SignupSteps.DetailsForm && (
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
<div className="pt-4 text-4xl">Welcome to Infisical</div>
|
||||
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="First name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Last name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-1"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
<div className="pt-4 text-4xl">Welcome to Infisical</div>
|
||||
<div className="pb-4 text-bunker-300">Create your first Super Admin Account</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
name="firstName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Email"
|
||||
label="First name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
@@ -186,56 +126,66 @@ export const SignUpPage = () => {
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
name="lastName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
label="Last name"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Confirm password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="mt-4"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
{step === SignupSteps.BackupKey && (
|
||||
<motion.div
|
||||
className="text-mineshaft-200"
|
||||
key="panel-2"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<DownloadBackupKeys onGenerate={handleBackupKeyGenerate} />
|
||||
</motion.div>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input isFullWidth size="md" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmPassword"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Confirm password"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input isFullWidth size="md" type="password" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="mt-4"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onGenerate: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex flex-col items-center justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className="mb-6 ml-2 mr-3 pt-1 text-6xl text-bunker-200"
|
||||
/>
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading.on();
|
||||
await onGenerate();
|
||||
} finally {
|
||||
setIsLoading.off();
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { DownloadBackupKeys } from "./DownloadBackupKeys";
|
||||
@@ -1,396 +1,75 @@
|
||||
import crypto from "crypto";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
import InputField from "@app/components/basic/InputField";
|
||||
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useResetPassword, useVerifyPasswordResetCode } from "@app/hooks/api";
|
||||
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
|
||||
import { ConfirmEmailStep } from "./components/ConfirmEmailStep";
|
||||
import { EnterPasswordStep } from "./components/EnterPasswordStep";
|
||||
import { InputBackupKeyStep } from "./components/InputBackupKeyStep";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
enum Steps {
|
||||
ConfirmEmail = 1,
|
||||
InputBackupKey = 2,
|
||||
EnterNewPassword = 3
|
||||
}
|
||||
|
||||
const formData = z.object({
|
||||
verificationToken: z.string(),
|
||||
privateKey: z.string(),
|
||||
userEncryptionVersion: z.nativeEnum(UserEncryptionVersion)
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
export const PasswordResetPage = () => {
|
||||
const [verificationToken, setVerificationToken] = useState("");
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backupKey, setBackupKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [backupKeyError, setBackupKeyError] = useState(false);
|
||||
const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(false);
|
||||
const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false);
|
||||
const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(false);
|
||||
const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(false);
|
||||
const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false);
|
||||
const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false);
|
||||
const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false);
|
||||
const [passwordErrorBreached, setPasswordErrorBreached] = useState(false);
|
||||
const { watch, setValue } = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
|
||||
const verificationToken = watch("verificationToken");
|
||||
const encryptionVersion = watch("userEncryptionVersion");
|
||||
const privateKey = watch("privateKey");
|
||||
|
||||
const [step, setStep] = useState<Steps>(Steps.ConfirmEmail);
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
|
||||
const {
|
||||
mutateAsync: verifyPasswordResetCodeMutateAsync,
|
||||
isPending: isVerifyPasswordResetLoading
|
||||
} = useVerifyPasswordResetCode();
|
||||
const { mutateAsync: resetPasswordMutateAsync } = useResetPassword();
|
||||
|
||||
const parsedUrl = search;
|
||||
const token = parsedUrl.token as string;
|
||||
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
|
||||
|
||||
// Decrypt the private key with a backup key
|
||||
const getEncryptedKeyHandler = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const result = await getBackupEncryptedPrivateKey({ verificationToken });
|
||||
|
||||
setPrivateKey(
|
||||
Aes256Gcm.decrypt({
|
||||
ciphertext: result.encryptedPrivateKey,
|
||||
iv: result.iv,
|
||||
tag: result.tag,
|
||||
secret: backupKey
|
||||
})
|
||||
);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setBackupKeyError(true);
|
||||
}
|
||||
};
|
||||
|
||||
// If everything is correct, reset the password
|
||||
const resetPasswordHandler = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const errorCheck = await passwordCheck({
|
||||
password: newPassword,
|
||||
setPasswordErrorTooShort,
|
||||
setPasswordErrorTooLong,
|
||||
setPasswordErrorNoLetterChar,
|
||||
setPasswordErrorNoNumOrSpecialChar,
|
||||
setPasswordErrorRepeatedChar,
|
||||
setPasswordErrorEscapeChar,
|
||||
setPasswordErrorLowEntropy,
|
||||
setPasswordErrorBreached
|
||||
});
|
||||
|
||||
if (!errorCheck) {
|
||||
client.init(
|
||||
{
|
||||
username: email,
|
||||
password: newPassword
|
||||
},
|
||||
async () => {
|
||||
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password: newPassword,
|
||||
salt: result.salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
|
||||
// create encrypted private key by encrypting the private
|
||||
// key with the symmetric key [key]
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: privateKey,
|
||||
secret: key
|
||||
});
|
||||
|
||||
// create the protected key by encrypting the symmetric key
|
||||
// [key] with the derived key
|
||||
const {
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: key.toString("hex"),
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
await resetPasswordMutateAsync({
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier,
|
||||
verificationToken,
|
||||
password: newPassword
|
||||
});
|
||||
|
||||
navigate({ to: "/login" });
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Click a button to confirm email
|
||||
const stepConfirmEmail = (
|
||||
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
|
||||
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Confirm your email
|
||||
</p>
|
||||
<img
|
||||
src="/images/envelope.svg"
|
||||
style={{ height: "262px", width: "410px" }}
|
||||
alt="verify email"
|
||||
/>
|
||||
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await verifyPasswordResetCodeMutateAsync({
|
||||
email,
|
||||
code: token
|
||||
});
|
||||
|
||||
setVerificationToken(response.token);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
navigate({ to: "/email-not-verified" });
|
||||
}
|
||||
}}
|
||||
isLoading={isVerifyPasswordResetLoading}
|
||||
size="lg"
|
||||
>
|
||||
Confirm Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Input backup key
|
||||
const stepInputBackupKey = (
|
||||
<form
|
||||
onSubmit={getEncryptedKeyHandler}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter your backup key
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
You can find it in your emergency kit. You had to download the emergency kit during
|
||||
signup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<InputField
|
||||
label="Backup Key"
|
||||
onChangeHandler={setBackupKey}
|
||||
type="password"
|
||||
value={backupKey}
|
||||
placeholder=""
|
||||
isRequired
|
||||
error={backupKeyError}
|
||||
errorText="Something is wrong with the backup key"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" size="lg">
|
||||
Submit Backup Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
// Enter new password
|
||||
const stepEnterNewPassword = (
|
||||
<form
|
||||
onSubmit={resetPasswordHandler}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter new password
|
||||
</p>
|
||||
<div className="mt-1 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
Make sure you save it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<InputField
|
||||
label="New Password"
|
||||
onChangeHandler={(password) => {
|
||||
setNewPassword(password);
|
||||
passwordCheck({
|
||||
password,
|
||||
setPasswordErrorTooShort,
|
||||
setPasswordErrorTooLong,
|
||||
setPasswordErrorNoLetterChar,
|
||||
setPasswordErrorNoNumOrSpecialChar,
|
||||
setPasswordErrorRepeatedChar,
|
||||
setPasswordErrorEscapeChar,
|
||||
setPasswordErrorLowEntropy,
|
||||
setPasswordErrorBreached
|
||||
});
|
||||
}}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
isRequired
|
||||
error={
|
||||
passwordErrorTooShort &&
|
||||
passwordErrorTooLong &&
|
||||
passwordErrorNoLetterChar &&
|
||||
passwordErrorNoNumOrSpecialChar &&
|
||||
passwordErrorRepeatedChar &&
|
||||
passwordErrorEscapeChar &&
|
||||
passwordErrorLowEntropy &&
|
||||
passwordErrorBreached
|
||||
}
|
||||
autoComplete="new-password"
|
||||
id="new-password"
|
||||
/>
|
||||
</div>
|
||||
{passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached ? (
|
||||
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooShort ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at least 14 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooLong ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at most 100 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoLetterChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at least 1 letter character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoNumOrSpecialChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
|
||||
} text-sm`}
|
||||
>
|
||||
at least 1 number or special character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorRepeatedChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at most 3 repeated, consecutive characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorEscapeChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
No escape characters allowed.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorLowEntropy ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
Password contains personal info.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorBreached ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
Password was found in a data breach.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2" />
|
||||
)}
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" onClick={() => setLoading(true)} size="lg" isLoading={loading}>
|
||||
Submit New Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center bg-bunker-800">
|
||||
{step === 1 && stepConfirmEmail}
|
||||
{step === 2 && stepInputBackupKey}
|
||||
{step === 3 && stepEnterNewPassword}
|
||||
{step === Steps.ConfirmEmail && (
|
||||
<ConfirmEmailStep
|
||||
onComplete={(verifyToken, userEncryptionVersion) => {
|
||||
setValue("verificationToken", verifyToken);
|
||||
setValue("userEncryptionVersion", userEncryptionVersion);
|
||||
|
||||
if (userEncryptionVersion === UserEncryptionVersion.V2) {
|
||||
setStep(Steps.EnterNewPassword);
|
||||
} else {
|
||||
setStep(Steps.InputBackupKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === Steps.InputBackupKey && (
|
||||
<InputBackupKeyStep
|
||||
verificationToken={verificationToken}
|
||||
onComplete={(key) => {
|
||||
setValue("privateKey", key);
|
||||
setStep(Steps.EnterNewPassword);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === Steps.EnterNewPassword && (
|
||||
<EnterPasswordStep
|
||||
verificationToken={verificationToken}
|
||||
privateKey={privateKey}
|
||||
encryptionVersion={encryptionVersion}
|
||||
onComplete={() => {
|
||||
navigate({ to: "/login" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useVerifyPasswordResetCode } from "@app/hooks/api";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
type Props = {
|
||||
onComplete: (verificationToken: string, encryptionVersion: UserEncryptionVersion) => void;
|
||||
};
|
||||
|
||||
export const ConfirmEmailStep = ({ onComplete }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
const { token, to: email } = search;
|
||||
|
||||
const {
|
||||
mutateAsync: verifyPasswordResetCodeMutateAsync,
|
||||
isPending: isVerifyPasswordResetLoading
|
||||
} = useVerifyPasswordResetCode();
|
||||
return (
|
||||
<div className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 py-6 drop-shadow-xl md:max-w-lg md:px-6">
|
||||
<p className="mb-8 flex justify-center bg-gradient-to-br from-sky-400 to-primary bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Confirm your email
|
||||
</p>
|
||||
<img
|
||||
src="/images/envelope.svg"
|
||||
style={{ height: "262px", width: "410px" }}
|
||||
alt="verify email"
|
||||
/>
|
||||
<div className="mx-auto mb-2 mt-4 flex max-h-24 max-w-md flex-col items-center justify-center px-4 text-lg md:p-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await verifyPasswordResetCodeMutateAsync({
|
||||
email,
|
||||
code: token
|
||||
});
|
||||
|
||||
onComplete(response.token, response.userEncryptionVersion);
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
navigate({ to: "/email-not-verified" });
|
||||
}
|
||||
}}
|
||||
isLoading={isVerifyPasswordResetLoading}
|
||||
size="lg"
|
||||
>
|
||||
Confirm Email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faX } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import { z } from "zod";
|
||||
|
||||
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useResetPassword, useResetPasswordV2 } from "@app/hooks/api";
|
||||
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
|
||||
|
||||
const formData = z.object({
|
||||
password: z.string(),
|
||||
passwordErrorTooShort: z.boolean().optional(),
|
||||
passwordErrorTooLong: z.boolean().optional(),
|
||||
passwordErrorNoLetterChar: z.boolean().optional(),
|
||||
passwordErrorNoNumOrSpecialChar: z.boolean().optional(),
|
||||
passwordErrorRepeatedChar: z.boolean().optional(),
|
||||
passwordErrorEscapeChar: z.boolean().optional(),
|
||||
passwordErrorLowEntropy: z.boolean().optional(),
|
||||
passwordErrorBreached: z.boolean()
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
type Props = {
|
||||
verificationToken: string;
|
||||
privateKey: string;
|
||||
encryptionVersion: UserEncryptionVersion;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export const EnterPasswordStep = ({
|
||||
verificationToken,
|
||||
encryptionVersion,
|
||||
privateKey,
|
||||
onComplete
|
||||
}: Props) => {
|
||||
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordResetPage.id });
|
||||
const { to: email } = search;
|
||||
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
const { mutateAsync: resetPassword, isPending: isLoading } = useResetPassword();
|
||||
const { mutateAsync: resetPasswordV2, isPending: isLoadingV2 } = useResetPasswordV2();
|
||||
|
||||
const passwordErrorTooShort = watch("passwordErrorTooShort");
|
||||
const passwordErrorTooLong = watch("passwordErrorTooLong");
|
||||
const passwordErrorNoLetterChar = watch("passwordErrorNoLetterChar");
|
||||
const passwordErrorNoNumOrSpecialChar = watch("passwordErrorNoNumOrSpecialChar");
|
||||
const passwordErrorRepeatedChar = watch("passwordErrorRepeatedChar");
|
||||
const passwordErrorEscapeChar = watch("passwordErrorEscapeChar");
|
||||
const passwordErrorLowEntropy = watch("passwordErrorLowEntropy");
|
||||
const passwordErrorBreached = watch("passwordErrorBreached");
|
||||
|
||||
const isPasswordError =
|
||||
passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached;
|
||||
|
||||
const handlePasswordCheck = async (checkPassword: string) => {
|
||||
const errorCheck = await passwordCheck({
|
||||
password: checkPassword,
|
||||
setPasswordErrorTooShort: (v) => setValue("passwordErrorTooShort", v),
|
||||
setPasswordErrorTooLong: (v) => setValue("passwordErrorTooLong", v),
|
||||
setPasswordErrorNoLetterChar: (v) => setValue("passwordErrorNoLetterChar", v),
|
||||
setPasswordErrorNoNumOrSpecialChar: (v) => setValue("passwordErrorNoNumOrSpecialChar", v),
|
||||
setPasswordErrorRepeatedChar: (v) => setValue("passwordErrorRepeatedChar", v),
|
||||
setPasswordErrorEscapeChar: (v) => setValue("passwordErrorEscapeChar", v),
|
||||
setPasswordErrorLowEntropy: (v) => setValue("passwordErrorLowEntropy", v),
|
||||
setPasswordErrorBreached: (v) => setValue("passwordErrorBreached", v)
|
||||
});
|
||||
|
||||
return errorCheck;
|
||||
};
|
||||
|
||||
const resetPasswordHandler = async (data: TFormData) => {
|
||||
const errorCheck = await handlePasswordCheck(data.password);
|
||||
|
||||
if (errorCheck) return;
|
||||
|
||||
if (encryptionVersion === UserEncryptionVersion.V2) {
|
||||
await resetPasswordV2({
|
||||
newPassword: data.password,
|
||||
verificationToken
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
client.init(
|
||||
{
|
||||
username: email,
|
||||
password: data.password
|
||||
},
|
||||
async () => {
|
||||
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password: data.password,
|
||||
salt: result.salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
|
||||
// create encrypted private key by encrypting the private
|
||||
// key with the symmetric key [key]
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: privateKey,
|
||||
secret: key
|
||||
});
|
||||
|
||||
// create the protected key by encrypting the symmetric key
|
||||
// [key] with the derived key
|
||||
const {
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag
|
||||
} = Aes256Gcm.encrypt({
|
||||
text: key.toString("hex"),
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
await resetPassword({
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier,
|
||||
verificationToken,
|
||||
password: data.password
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(resetPasswordHandler)}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto flex w-max justify-center text-2xl font-semibold text-bunker-100 md:text-3xl">
|
||||
Enter new password
|
||||
</p>
|
||||
<div className="mt-1 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max max-w-md justify-center text-sm text-gray-400">
|
||||
Make sure you save it somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
label="New Password"
|
||||
isRequired
|
||||
isError={isPasswordError}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handlePasswordCheck(e.target.value);
|
||||
}}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{passwordErrorTooShort ||
|
||||
passwordErrorTooLong ||
|
||||
passwordErrorNoLetterChar ||
|
||||
passwordErrorNoNumOrSpecialChar ||
|
||||
passwordErrorRepeatedChar ||
|
||||
passwordErrorEscapeChar ||
|
||||
passwordErrorLowEntropy ||
|
||||
passwordErrorBreached ? (
|
||||
<div className="mx-2 mb-2 mt-3 flex w-full max-w-md flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||
<div className="mb-1 text-sm text-gray-400">Password should contain:</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooShort ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at least 14 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorTooLong ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
at most 100 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoLetterChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at least 1 letter character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorNoNumOrSpecialChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
|
||||
} text-sm`}
|
||||
>
|
||||
at least 1 number or special character
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorRepeatedChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
at most 3 repeated, consecutive characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorEscapeChar ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
No escape characters allowed.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorLowEntropy ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
|
||||
>
|
||||
Password contains personal info.
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1 flex flex-row items-center justify-start">
|
||||
{passwordErrorBreached ? (
|
||||
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
|
||||
)}
|
||||
<div className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}>
|
||||
Password was found in a data breach.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2" />
|
||||
)}
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="secondary"
|
||||
isLoading={isSubmitting || isLoading || isLoadingV2}
|
||||
isDisabled={isSubmitting || isLoading || isLoadingV2}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { getBackupEncryptedPrivateKey } from "@app/hooks/api/auth/queries";
|
||||
|
||||
type Props = {
|
||||
verificationToken: string;
|
||||
onComplete: (privateKey: string) => void;
|
||||
};
|
||||
|
||||
const formData = z.object({
|
||||
backupKey: z.string()
|
||||
});
|
||||
type TFormData = z.infer<typeof formData>;
|
||||
|
||||
export const InputBackupKeyStep = ({ verificationToken, onComplete }: Props) => {
|
||||
const { control, handleSubmit, setError } = useForm<TFormData>({
|
||||
resolver: zodResolver(formData)
|
||||
});
|
||||
|
||||
const getEncryptedKeyHandler = async (data: z.infer<typeof formData>) => {
|
||||
try {
|
||||
const result = await getBackupEncryptedPrivateKey({ verificationToken });
|
||||
|
||||
const privateKey = Aes256Gcm.decrypt({
|
||||
ciphertext: result.encryptedPrivateKey,
|
||||
iv: result.iv,
|
||||
tag: result.tag,
|
||||
secret: data.backupKey
|
||||
});
|
||||
|
||||
onComplete(privateKey);
|
||||
// setStep(3);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("backupKey", { message: "Failed to decrypt private key" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(getEncryptedKeyHandler)}
|
||||
className="mx-1 my-32 flex w-full max-w-xs flex-col items-center rounded-xl bg-bunker px-4 pb-3 pt-6 drop-shadow-xl md:max-w-lg md:px-6"
|
||||
>
|
||||
<p className="mx-auto mb-4 flex w-max justify-center text-2xl font-semibold text-bunker-100">
|
||||
Enter your backup key
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-full px-4 text-center text-sm text-gray-400 sm:max-w-md">
|
||||
You can find it in your emergency kit. You had to download the emergency kit during
|
||||
signup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="backupKey"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Backup Key"
|
||||
>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="08af467b815ffa412f2c98cc3326acdb"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-20 w-full max-w-md flex-col items-center justify-center text-sm md:p-2">
|
||||
<div className="text-l m-8 mt-6 px-8 py-3 text-lg">
|
||||
<Button type="submit" colorSchema="secondary">
|
||||
Submit Backup Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import crypto from "crypto";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { faWarning, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
@@ -16,7 +16,6 @@ import InputField from "@app/components/basic/InputField";
|
||||
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button } from "@app/components/v2";
|
||||
@@ -54,8 +53,6 @@ export const SignupInvitePage = () => {
|
||||
const [lastNameError, setLastNameError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [, setBackupKeyError] = useState(false);
|
||||
const [, setBackupKeyIssued] = useState(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
@@ -205,7 +202,9 @@ export const SignupInvitePage = () => {
|
||||
|
||||
localStorage.setItem("orgData.id", orgId);
|
||||
|
||||
setStep(3);
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
};
|
||||
|
||||
await completeSignupFlow();
|
||||
@@ -367,44 +366,6 @@ export const SignupInvitePage = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Step 4 of the sign up process (download the emergency kit pdf)
|
||||
const step4 = (
|
||||
<div className="h-7/12 mx-1 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 pb-6 pt-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
|
||||
<p className="flex justify-center bg-gradient-to-br from-white to-mineshaft-300 bg-clip-text text-center text-4xl font-semibold text-transparent">
|
||||
Save your Emergency Kit
|
||||
</p>
|
||||
<div className="text-md mt-4 flex w-full max-w-md flex-col items-center justify-center rounded-md px-2 text-gray-400 md:mt-8">
|
||||
<div>
|
||||
If you get locked out of your account, your Emergency Kit is the only way to sign in.
|
||||
</div>
|
||||
<div className="mt-3">We recommend you download it and keep it somewhere safe.</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full max-w-xs flex-row items-center rounded-md bg-white/10 p-2 text-gray-400 md:max-w-md">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-4 text-4xl" />
|
||||
It contains your Secret Key which we cannot access or recover for you if you lose it.
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex max-h-24 max-w-max flex-col items-center justify-center px-2 py-3 text-lg md:px-4 md:py-5">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await issueBackupKey({
|
||||
email,
|
||||
password,
|
||||
personalName: `${firstName} ${lastName}`,
|
||||
setBackupKeyError,
|
||||
setBackupKeyIssued
|
||||
});
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Helmet>
|
||||
@@ -425,7 +386,8 @@ export const SignupInvitePage = () => {
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical Logo" />
|
||||
</div>
|
||||
</Link>
|
||||
{step === 1 ? stepConfirmEmail : step === 2 ? main : step4}
|
||||
{step === 1 && stepConfirmEmail}
|
||||
{step === 2 && main}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import CodeInputStep from "@app/components/auth/CodeInputStep";
|
||||
import DownloadBackupPDF from "@app/components/auth/DonwloadBackupPDFStep";
|
||||
import EnterEmailStep from "@app/components/auth/EnterEmailStep";
|
||||
import InitialSignupStep from "@app/components/auth/InitialSignupStep";
|
||||
import TeamInviteStep from "@app/components/auth/TeamInviteStep";
|
||||
@@ -72,7 +71,7 @@ export const SignUpPage = () => {
|
||||
incrementStep();
|
||||
}
|
||||
|
||||
if (!serverDetails?.emailConfigured && step === 5) {
|
||||
if (!serverDetails?.emailConfigured && step === 4) {
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
@@ -119,17 +118,6 @@ export const SignUpPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (registerStep === 4) {
|
||||
return (
|
||||
<DownloadBackupPDF
|
||||
incrementStep={incrementStep}
|
||||
email={email}
|
||||
password={password}
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serverDetails?.emailConfigured) {
|
||||
return <TeamInviteStep />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { jwtDecode } from "jwt-decode";
|
||||
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
|
||||
import { BackupPDFStep } from "./components/BackupPDFStep";
|
||||
import { EmailConfirmationStep } from "./components/EmailConfirmationStep";
|
||||
import { UserInfoSSOStep } from "./components/UserInfoSSOStep";
|
||||
|
||||
@@ -57,14 +56,9 @@ export const SignupSsoPage = () => {
|
||||
providerOrganizationName={organizationName}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setStep={setStep}
|
||||
providerAuthToken={token}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
interface DownloadBackupPDFStepProps {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the step of the signup flow where the user downloads the backup pdf
|
||||
* @param {object} obj
|
||||
* @param {function} obj.incrementStep - function that moves the user on to the next stage of signup
|
||||
* @param {string} obj.email - user's email
|
||||
* @param {string} obj.password - user's password
|
||||
* @param {string} obj.name - user's name
|
||||
* @returns
|
||||
*/
|
||||
export const BackupPDFStep = ({ email, password, name }: DownloadBackupPDFStepProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-36 flex h-full w-full flex-col items-center md:mb-16 md:px-6">
|
||||
<p className="flex justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-3 pt-1 text-2xl text-bunker-200" />
|
||||
{t("signup.step4-message")}
|
||||
</p>
|
||||
<div className="text-md mt-8 flex w-full max-w-md flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 pb-2 text-center text-bunker-300 md:min-w-[24rem] lg:w-1/6">
|
||||
<div className="m-2 mx-auto mt-4 flex w-full flex-row items-center rounded-md px-3 text-center text-bunker-300 md:mt-8 md:min-w-[23rem] lg:w-1/6">
|
||||
<span className="mb-2">
|
||||
{t("signup.step4-description1")} {t("signup.step4-description3")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx-auto mb-2 mt-2 flex w-full flex-col items-center justify-center px-3 text-center text-sm md:mb-4 md:mt-4 md:min-w-[20rem] md:max-w-md md:text-left lg:w-1/6">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await issueBackupKey({
|
||||
email,
|
||||
password,
|
||||
personalName: name,
|
||||
setBackupKeyError: () => {},
|
||||
setBackupKeyIssued: () => {}
|
||||
});
|
||||
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="h-12"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
>
|
||||
{" "}
|
||||
Download PDF{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BackupPDFStep } from "./BackupPDFStep";
|
||||
+6
-3
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import jsrp from "jsrp";
|
||||
import nacl from "tweetnacl";
|
||||
import { encodeBase64 } from "tweetnacl-util";
|
||||
@@ -17,12 +18,12 @@ import { useToggle } from "@app/hooks";
|
||||
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const client = new jsrp.client();
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
username: string;
|
||||
password: string;
|
||||
setPassword: (value: string) => void;
|
||||
@@ -50,7 +51,6 @@ export const UserInfoSSOStep = ({
|
||||
providerOrganizationName,
|
||||
password,
|
||||
setPassword,
|
||||
setStep,
|
||||
providerAuthToken
|
||||
}: Props) => {
|
||||
const [nameError, setNameError] = useState(false);
|
||||
@@ -63,6 +63,7 @@ export const UserInfoSSOStep = ({
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const randomPassword = crypto.randomBytes(32).toString("hex");
|
||||
@@ -202,7 +203,9 @@ export const UserInfoSSOStep = ({
|
||||
}
|
||||
|
||||
localStorage.setItem("orgData.id", orgId);
|
||||
setStep(2);
|
||||
navigate({
|
||||
to: `/organization/${ProjectType.SecretManager}/overview` as const
|
||||
});
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
console.error(error);
|
||||
|
||||
@@ -72,8 +72,9 @@ export const VerifyEmailPage = () => {
|
||||
Forgot your password?
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max justify-center text-sm text-gray-400">
|
||||
You will need your emergency kit. Enter your email to start account recovery.
|
||||
<p className="flex w-max justify-center text-center text-sm text-gray-400">
|
||||
Enter your email to start the password reset process. You will receive an email with
|
||||
instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex max-h-24 w-full items-center justify-center rounded-lg md:mt-0 md:max-h-28 md:p-2">
|
||||
@@ -102,8 +103,10 @@ export const VerifyEmailPage = () => {
|
||||
Look for an email in your inbox.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-row items-center justify-center md:mx-2 md:pb-4">
|
||||
<p className="flex w-max justify-center text-center text-sm text-gray-400">
|
||||
An email with instructions has been sent to {email}.
|
||||
<p className="w-max text-center text-sm text-gray-400">
|
||||
If the email is in our system, you will receive an email at{" "}
|
||||
<span className="italic">{email}</span> with instructions on how to reset your
|
||||
password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
|
||||
|
||||
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
|
||||
import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProjectOverviewChangeSection />
|
||||
<ProjectOverviewChangeSection showSlugField />
|
||||
<AuditLogsRetentionSection />
|
||||
<DeleteProjectSection />
|
||||
</div>
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
hoverText: string;
|
||||
notificationText: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
|
||||
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
if (isProjectIdCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProjectIdCopied.on();
|
||||
navigator.clipboard.writeText(value);
|
||||
|
||||
createNotification({
|
||||
text: notificationText,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
}, [isProjectIdCopied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{children}
|
||||
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
{hoverText}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
|
||||
@@ -1,2 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
|
||||
|
||||
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
|
||||
import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
|
||||
|
||||
export const ProjectGeneralTab = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProjectOverviewChangeSection />
|
||||
<ProjectOverviewChangeSection showSlugField />
|
||||
<AuditLogsRetentionSection />
|
||||
<DeleteProjectSection />
|
||||
</div>
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
hoverText: string;
|
||||
notificationText: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
|
||||
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
|
||||
|
||||
const copyToClipboard = useCallback(() => {
|
||||
if (isProjectIdCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProjectIdCopied.on();
|
||||
navigator.clipboard.writeText(value);
|
||||
|
||||
createNotification({
|
||||
text: notificationText,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearTimeout(timer);
|
||||
}, [isProjectIdCopied]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{children}
|
||||
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
{hoverText}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useUpdateProject } from "@app/hooks/api";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(256, "Description too long, max length is 256 characters")
|
||||
.optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProjectOverviewChangeSection = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { mutateAsync, isPending } = useUpdateProject();
|
||||
|
||||
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
|
||||
|
||||
useEffect(() => {
|
||||
if (currentWorkspace) {
|
||||
reset({
|
||||
name: currentWorkspace.name,
|
||||
description: currentWorkspace.description ?? ""
|
||||
});
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const onFormSubmit = async ({ name, description }: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
await mutateAsync({
|
||||
projectID: currentWorkspace.id,
|
||||
newProjectName: name,
|
||||
newProjectDescription: description
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated project overview",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update project overview",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="justify-betweens flex">
|
||||
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
|
||||
<div className="space-x-2">
|
||||
<CopyButton
|
||||
value={currentWorkspace?.slug || ""}
|
||||
hoverText="Click to project slug"
|
||||
notificationText="Copied project slug to clipboard"
|
||||
>
|
||||
Copy Project Slug
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
value={currentWorkspace?.id || ""}
|
||||
hoverText="Click to project ID"
|
||||
notificationText="Copied project ID to clipboard"
|
||||
>
|
||||
Copy Project ID
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
|
||||
<div className="flex w-full flex-row items-end gap-4">
|
||||
<div className="w-full max-w-md">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Project name"
|
||||
>
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-end gap-4">
|
||||
<div className="w-full max-w-md">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Project description"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="Project description"
|
||||
{...field}
|
||||
rows={3}
|
||||
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="description"
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Project}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
isDisabled={isPending || !isAllowed}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
|
||||
@@ -1,2 +1 @@
|
||||
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
|
||||
|
||||
@@ -28,7 +28,14 @@ type Props = {
|
||||
|
||||
const AUDIT_LOG_LIMIT = 15;
|
||||
|
||||
const TABLE_HEADERS = ["Timestamp", "Event", "Project", "Actor", "Source", "Metadata"] as const;
|
||||
const TABLE_HEADERS = [
|
||||
"Timestamp (MM/DD/YYYY)",
|
||||
"Event",
|
||||
"Project",
|
||||
"Actor",
|
||||
"Source",
|
||||
"Metadata"
|
||||
] as const;
|
||||
export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number];
|
||||
|
||||
export const LogsTable = ({
|
||||
|
||||
@@ -83,7 +83,7 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
|
||||
hours %= 12;
|
||||
hours = hours || 12; // the hour '0' should be '12'
|
||||
|
||||
const formattedDate = `${day}-${month}-${year} at ${hours}:${minutes} ${period}`;
|
||||
const formattedDate = `${month}-${day}-${year} at ${hours}:${minutes} ${period}`;
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -58,6 +58,7 @@ const schema = z.object({
|
||||
permissions: z
|
||||
.object({
|
||||
read: z.boolean(),
|
||||
readValue: z.boolean(),
|
||||
write: z.boolean()
|
||||
})
|
||||
.required()
|
||||
@@ -296,6 +297,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
name="permissions"
|
||||
defaultValue={{
|
||||
read: true,
|
||||
readValue: false,
|
||||
write: false
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
|
||||
+5
-2
@@ -45,8 +45,11 @@ export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }:
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
Conditions determine when a policy will be applied (always if no conditions are present).
|
||||
</p>
|
||||
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
|
||||
All conditions must evaluate to true for the policy to take effect.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
|
||||
+127
-104
@@ -35,12 +35,13 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
title,
|
||||
isDisabled
|
||||
}: Props<T>) => {
|
||||
const { control } = useFormContext<TFormSchema>();
|
||||
const { control, watch } = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${subject}`
|
||||
});
|
||||
const [isOpen, setIsOpen] = useToggle();
|
||||
// const [hideFullReadAccess, setHideFullReadAccess] = useState(false);
|
||||
|
||||
if (!items.fields.length) return <div />;
|
||||
|
||||
@@ -71,116 +72,138 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
|
||||
{items.fields.map((el, rootIndex) => (
|
||||
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return (
|
||||
{items.fields.map((el, rootIndex) => {
|
||||
let isFullReadAccessEnabled = false;
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
isFullReadAccessEnabled = watch(`permissions.${subject}.${rootIndex}.read` as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
key={`${el.id}-${label}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }, index) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
|
||||
if (
|
||||
subject === ProjectPermissionSub.Secrets &&
|
||||
value === "read" &&
|
||||
!isFullReadAccessEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={`${el.id}-${index + 1}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -39,8 +39,11 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
Conditions determine when a policy will be applied (always if no conditions are present).
|
||||
</p>
|
||||
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
|
||||
All conditions must evaluate to true for the policy to take effect.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
|
||||
+72
-6
@@ -1,5 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretSyncActions,
|
||||
TPermissionCondition,
|
||||
TPermissionConditionOperators
|
||||
@@ -22,6 +27,15 @@ const GeneralPolicyActionSchema = z.object({
|
||||
create: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretActions.DescribeAndReadValue]: z.boolean().optional(), // existing read, gives both describe and read value
|
||||
[ProjectPermissionSecretActions.DescribeSecret]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.ReadValue]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Delete]: z.boolean().optional(),
|
||||
[ProjectPermissionSecretActions.Create]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
@@ -114,7 +128,7 @@ export const projectRoleFormSchema = z.object({
|
||||
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
|
||||
permissions: z
|
||||
.object({
|
||||
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
|
||||
[ProjectPermissionSub.Secrets]: SecretPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
@@ -283,6 +297,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
const canDescribeAndReadValue = action.includes(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
);
|
||||
const canDescribe = action.includes(ProjectPermissionSecretActions.DescribeSecret);
|
||||
const canReadValue = action.includes(ProjectPermissionSecretActions.ReadValue);
|
||||
|
||||
const canEdit = action.includes(ProjectPermissionSecretActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionSecretActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionSecretActions.Create);
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
formVal[subject]!.push({
|
||||
describeSecret: canDescribe,
|
||||
read: canDescribeAndReadValue,
|
||||
readValue: canReadValue,
|
||||
create: canCreate,
|
||||
edit: canEdit,
|
||||
delete: canDelete,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// for other subjects
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
@@ -470,7 +511,7 @@ export type TProjectPermissionObject = {
|
||||
[K in ProjectPermissionSub]: {
|
||||
title: string;
|
||||
actions: {
|
||||
label: string;
|
||||
label: string | ReactNode;
|
||||
value: keyof Omit<
|
||||
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
|
||||
"conditions" | "inverted"
|
||||
@@ -483,10 +524,35 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.Secrets]: {
|
||||
title: "Secrets",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="opacity-60">
|
||||
Read <span className="text-xs opacity-80">(legacy)</span>
|
||||
</p>
|
||||
<Tooltip
|
||||
className="overflow-hidden whitespace-normal"
|
||||
content={
|
||||
<div>
|
||||
This is a legacy action and will be removed in the future.
|
||||
<br />
|
||||
<br /> You should instead use the{" "}
|
||||
<strong className="font-semibold">Describe Secret</strong> and{" "}
|
||||
<strong className="font-semibold">Read Value</strong> actions.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="mt-1 text-yellow-500" size="sm" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
value: ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
},
|
||||
{ label: "Describe Secret", value: ProjectPermissionSecretActions.DescribeSecret },
|
||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretFolders]: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user