Merge branch 'main' into org-name-constraint

This commit is contained in:
Scott Wilson
2025-03-12 17:07:12 -07:00
258 changed files with 9274 additions and 3566 deletions
+245
View File
@@ -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",
+3
View File
@@ -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;
};
@@ -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}
/>
</>
);
};
@@ -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";
@@ -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">
@@ -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
},
{
+22
View File
@@ -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>
);
+4 -2
View File
@@ -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)
+39
View File
@@ -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;
};
+2 -2
View File
@@ -1,10 +1,10 @@
export {
useAdminDeleteUser,
useAdminGrantServerAdminAccess,
useCreateAdminUser,
useUpdateAdminSlackConfig,
useUpdateServerConfig,
useUpdateServerEncryptionStrategy,
useAdminGrantServerAdminAccess
useUpdateServerEncryptionStrategy
} from "./mutation";
export {
useAdminGetUsers,
+28 -1
View File
@@ -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(),
+6
View File
@@ -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
View File
@@ -2,6 +2,8 @@ export {
useGetAuthToken,
useOauthTokenExchange,
useResetPassword,
useResetPasswordV2,
useResetUserPasswordV2,
useSelectOrganization,
useSendMfaToken,
useSendPasswordResetEmail,
+27 -1
View File
@@ -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;
+15
View File
@@ -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 })
});
};
+11
View File
@@ -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;
};
+1 -5
View File
@@ -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,
+19 -5
View File
@@ -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),
[]
+11
View File
@@ -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;
};
+22 -1
View File
@@ -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>>;
}[];
};
};
+3 -2
View File
@@ -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]);
@@ -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
)}
@@ -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)}
@@ -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>
);
};
+36
View File
@@ -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;
}
+4
View File
@@ -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");
};
+1
View File
@@ -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>
@@ -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";
@@ -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>
@@ -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>
@@ -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 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -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>
@@ -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,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;
};
@@ -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 } }) => {
@@ -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) => {
@@ -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>
@@ -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) => {
@@ -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