feat(rbac): added glob support in permission and revealed settings

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent 2ed079830a
commit a4ef829046
38 changed files with 2068 additions and 1831 deletions
+1
View File
@@ -19,6 +19,7 @@
"@sentry/tracing": "^7.48.0", "@sentry/tracing": "^7.48.0",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/libsodium-wrappers": "^0.7.10", "@types/libsodium-wrappers": "^0.7.10",
"@ucast/mongo2js": "^1.3.4",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"aws-sdk": "^2.1364.0", "aws-sdk": "^2.1364.0",
"axios": "^1.3.5", "axios": "^1.3.5",
+1
View File
@@ -10,6 +10,7 @@
"@sentry/tracing": "^7.48.0", "@sentry/tracing": "^7.48.0",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/libsodium-wrappers": "^0.7.10", "@types/libsodium-wrappers": "^0.7.10",
"@ucast/mongo2js": "^1.3.4",
"argon2": "^0.30.3", "argon2": "^0.30.3",
"aws-sdk": "^2.1364.0", "aws-sdk": "^2.1364.0",
"axios": "^1.3.5", "axios": "^1.3.5",
@@ -1,7 +1,7 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { validateRequest } from "../../../helpers/validation"; import { validateRequest } from "../../../helpers/validation";
import { Secret } from "../../../models"; import { Folder, Secret } from "../../../models";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
@@ -11,6 +11,7 @@ import { BadRequestError } from "../../../utils/errors";
import * as reqValidator from "../../../validation"; import * as reqValidator from "../../../validation";
import { SecretVersion } from "../../models"; import { SecretVersion } from "../../models";
import { EESecretService } from "../../services"; import { EESecretService } from "../../services";
import { getFolderWithPathFromId } from "../../../services/FolderService";
/** /**
* Return secret versions for secret with id [secretId] * Return secret versions for secret with id [secretId]
@@ -164,10 +165,6 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
ProjectPermissionSub.SecretRollback ProjectPermissionSub.SecretRollback
); );
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment: toBeUpdatedSec.environment })
);
// validate secret version // validate secret version
const oldSecretVersion = await SecretVersion.findOne({ const oldSecretVersion = await SecretVersion.findOne({
@@ -194,6 +191,15 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
keyEncoding keyEncoding
} = oldSecretVersion; } = oldSecretVersion;
let secretPath = "/";
const folders = await Folder.findOne({ workspace, environment });
if (folders)
secretPath = getFolderWithPathFromId(folders.nodes, folder || "root")?.folderPath || "/";
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment: toBeUpdatedSec.environment, secretPath })
);
// update secret // update secret
const secret = await Secret.findByIdAndUpdate( const secret = await Secret.findByIdAndUpdate(
secretId, secretId,
+27 -5
View File
@@ -3,11 +3,31 @@ import {
ForcedSubject, ForcedSubject,
MongoAbility, MongoAbility,
RawRuleOf, RawRuleOf,
buildMongoQueryMatcher,
createMongoAbility createMongoAbility
} from "@casl/ability"; } from "@casl/ability";
import { Membership } from "../models"; import { Membership } from "../models";
import { IRole } from "../models/role"; import { IRole } from "../models/role";
import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch";
const $glob: FieldInstruction<string> = {
type: "field",
validate(instruction, value) {
if (typeof value !== "string") {
throw new Error(`"${instruction.name}" expects value to be a string`);
}
}
};
const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
const secretPath = context.get(object, node.field);
const permissionSecretGlobPath = node.value;
return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false });
};
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
export enum ProjectPermissionActions { export enum ProjectPermissionActions {
Read = "read", Read = "read",
@@ -36,7 +56,7 @@ export enum ProjectPermissionSub {
type SubjectFields = { type SubjectFields = {
environment: string; environment: string;
secretPath?: string; secretPath: string;
}; };
export type ProjectPermissionSet = export type ProjectPermissionSet =
@@ -144,7 +164,7 @@ const buildAdminPermission = () => {
can(ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace);
can(ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace);
return build(); return build({ conditionsMatcher });
}; };
export const adminProjectPermissions = buildAdminPermission(); export const adminProjectPermissions = buildAdminPermission();
@@ -180,7 +200,7 @@ const buildMemberPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build(); return build({ conditionsMatcher });
}; };
export const memberProjectPermissions = buildMemberPermission(); export const memberProjectPermissions = buildMemberPermission();
@@ -203,7 +223,7 @@ const buildViewerPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build(); return build({ conditionsMatcher });
}; };
export const viewerProjectPermission = buildViewerPermission(); export const viewerProjectPermission = buildViewerPermission();
@@ -228,7 +248,9 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str
if (membership.role === "viewer") return { permission: viewerProjectPermission, membership }; if (membership.role === "viewer") return { permission: viewerProjectPermission, membership };
if (membership.role === "custom") { if (membership.role === "custom") {
const permission = createMongoAbility<ProjectPermissionSet>(membership.customRole.permissions); const permission = createMongoAbility<ProjectPermissionSet>(membership.customRole.permissions, {
conditionsMatcher
});
return { permission, membership }; return { permission, membership };
} }
+6 -3
View File
@@ -3,6 +3,7 @@ import { MembershipOrg } from "../models";
import { IRole } from "../models/role"; import { IRole } from "../models/role";
import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors";
import { ACCEPTED } from "../variables"; import { ACCEPTED } from "../variables";
import { conditionsMatcher } from "./ProjectRoleService";
export enum OrgPermissionActions { export enum OrgPermissionActions {
Read = "read", Read = "read",
@@ -74,7 +75,7 @@ const buildAdminPermission = () => {
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing);
return build(); return build({ conditionsMatcher });
}; };
export const adminPermissions = buildAdminPermission(); export const adminPermissions = buildAdminPermission();
@@ -92,7 +93,7 @@ const buildMemberPermission = () => {
can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount);
can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning);
return build(); return build({ conditionsMatcher });
}; };
export const memberPermissions = buildMemberPermission(); export const memberPermissions = buildMemberPermission();
@@ -119,7 +120,9 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => {
if (membership.role === "member") return { permission: memberPermissions, membership }; if (membership.role === "member") return { permission: memberPermissions, membership };
if (membership.role === "custom") { if (membership.role === "custom") {
const permission = createMongoAbility<OrgPermissionSet>(membership.customRole.permissions); const permission = createMongoAbility<OrgPermissionSet>(membership.customRole.permissions, {
conditionsMatcher
});
return { permission, membership }; return { permission, membership };
} }
+6 -2
View File
@@ -11,7 +11,9 @@ export const CreateRoleSchema = z.object({
.object({ .object({
subject: z.string().trim(), subject: z.string().trim(),
action: z.string().trim(), action: z.string().trim(),
conditions: z.record(z.union([z.string().trim(), z.number()])).optional() conditions: z
.record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })]))
.optional()
}) })
.array() .array()
}) })
@@ -31,7 +33,9 @@ export const UpdateRoleSchema = z.object({
.object({ .object({
subject: z.string().trim(), subject: z.string().trim(),
action: z.string().trim(), action: z.string().trim(),
conditions: z.record(z.union([z.string().trim(), z.number()])).optional() conditions: z
.record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })]))
.optional()
}) })
.array() .array()
.optional() .optional()
+2 -2
View File
@@ -189,7 +189,7 @@ export const BatchSecretsV2 = z.object({
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
folderId: z.string().trim().default("root"), folderId: z.string().trim().default("root"),
environment: z.string().trim(), environment: z.string().trim(),
secretPath: z.string().trim().optional(), secretPath: z.string().trim().default("/"),
requests: z requests: z
.discriminatedUnion("method", [ .discriminatedUnion("method", [
z.object({ z.object({
@@ -328,7 +328,7 @@ export const CreateSecretV3 = z.object({
secretCommentCiphertext: z.string().trim().optional(), secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(),
metadata: z.record(z.string()).optional(), metadata: z.record(z.string()).optional()
}), }),
params: z.object({ params: z.object({
secretName: z.string().trim() secretName: z.string().trim()
+15
View File
@@ -41,6 +41,7 @@
"@stripe/stripe-js": "^1.46.0", "@stripe/stripe-js": "^1.46.0",
"@tanstack/react-query": "^4.23.0", "@tanstack/react-query": "^4.23.0",
"@types/argon2-browser": "^1.18.1", "@types/argon2-browser": "^1.18.1",
"@ucast/mongo2js": "^1.3.4",
"add": "^2.0.6", "add": "^2.0.6",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -65,6 +66,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"next": "^12.3.4", "next": "^12.3.4",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.58.0", "posthog-js": "^1.58.0",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
@@ -106,6 +108,7 @@
"@tailwindcss/typography": "^0.5.4", "@tailwindcss/typography": "^0.5.4",
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
@@ -8391,6 +8394,12 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
}, },
"node_modules/@types/picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz",
"integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==",
"dev": true
},
"node_modules/@types/pretty-hrtime": { "node_modules/@types/pretty-hrtime": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
@@ -29414,6 +29423,12 @@
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
}, },
"@types/picomatch": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz",
"integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==",
"dev": true
},
"@types/pretty-hrtime": { "@types/pretty-hrtime": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz",
+3
View File
@@ -49,6 +49,7 @@
"@stripe/stripe-js": "^1.46.0", "@stripe/stripe-js": "^1.46.0",
"@tanstack/react-query": "^4.23.0", "@tanstack/react-query": "^4.23.0",
"@types/argon2-browser": "^1.18.1", "@types/argon2-browser": "^1.18.1",
"@ucast/mongo2js": "^1.3.4",
"add": "^2.0.6", "add": "^2.0.6",
"argon2-browser": "^1.18.0", "argon2-browser": "^1.18.0",
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -73,6 +74,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"next": "^12.3.4", "next": "^12.3.4",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"picomatch": "^2.3.1",
"posthog-js": "^1.58.0", "posthog-js": "^1.58.0",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"react": "^17.0.2", "react": "^17.0.2",
@@ -114,6 +116,7 @@
"@tailwindcss/typography": "^0.5.4", "@tailwindcss/typography": "^0.5.4",
"@types/jsrp": "^0.2.4", "@types/jsrp": "^0.2.4",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/picomatch": "^2.3.0",
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/sanitize-html": "^2.9.0", "@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
@@ -1,78 +1,92 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Checkbox, PopoverContent } from "@app/components/v2"; import { Button, Checkbox, PopoverContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { WsTag } from "../../hooks/api/tags/types"; import { WsTag } from "../../hooks/api/tags/types";
import { ProjectPermissionCan } from "../permissions";
interface Props { interface Props {
wsTags: WsTag[] | undefined; wsTags: WsTag[] | undefined;
secKey: string; secKey: string;
selectedTagIds: Record<string, boolean>; selectedTagIds: Record<string, boolean>;
handleSelectTag: (wsTag: WsTag) => void; handleSelectTag: (wsTag: WsTag) => void;
handleTagOnMouseEnter: (wsTag: WsTag) => void; handleTagOnMouseEnter: (wsTag: WsTag) => void;
handleTagOnMouseLeave: () => void; handleTagOnMouseLeave: () => void;
checkIfTagIsVisible: (wsTag: WsTag) => boolean; checkIfTagIsVisible: (wsTag: WsTag) => boolean;
handleOnCreateTagOpen: () => void handleOnCreateTagOpen: () => void;
} }
const AddTagPopoverContent = ({ const AddTagPopoverContent = ({
wsTags, wsTags,
secKey, secKey,
selectedTagIds, selectedTagIds,
handleSelectTag, handleSelectTag,
handleTagOnMouseEnter, handleTagOnMouseEnter,
handleTagOnMouseLeave, handleTagOnMouseLeave,
checkIfTagIsVisible, checkIfTagIsVisible,
handleOnCreateTagOpen handleOnCreateTagOpen
}: Props) => { }: Props) => {
return ( return (
<PopoverContent <PopoverContent
side="left" side="left"
className="relative max-h-96 w-auto min-w-[200px] p-2 overflow-y-auto overflow-x-hidden border border-mineshaft-600 bg-mineshaft-800 text-bunker-200" className="relative max-h-96 w-auto min-w-[200px] p-2 overflow-y-auto overflow-x-hidden border border-mineshaft-600 bg-mineshaft-800 text-bunker-200"
hideCloseBtn hideCloseBtn
> >
<div className=" text-center text-sm font-medium text-bunker-200"> <div className=" text-center text-sm font-medium text-bunker-200">
Add tags to {secKey || "this secret"} Add tags to {secKey || "this secret"}
</div>
<div className="absolute left-0 w-full border-mineshaft-600 border-t mt-2" />
<div className="flex flex-col space-y-1.5">
{wsTags?.map((wsTag: WsTag) => (
<div
key={`tag-${wsTag._id}`}
className="mt-4 h-[32px] relative flex items-center justify-start hover:border-mineshaft-600 hover:border hover:bg-mineshaft-700 p-2 rounded-md hover:text-bunker-200 bg-none"
onClick={() => handleSelectTag(wsTag)}
onMouseEnter={() => handleTagOnMouseEnter(wsTag)}
onMouseLeave={() => handleTagOnMouseLeave()}
tabIndex={0}
role="button"
onKeyDown={() => {}}
>
{(checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && (
<Checkbox
id="autoCapitalization"
isChecked={selectedTagIds?.[wsTag.slug]}
className="absolute top-[50%] translate-y-[-50%] left-[10px] "
checkIndicatorBg={`${
!selectedTagIds?.[wsTag.slug] ? "text-transparent" : "text-mineshaft-800"
}`}
/>
)}
<div className="ml-7 flex items-center gap-3">
<div
className="w-[10px] h-[10px] rounded-full"
style={{ background: wsTag?.tagColor ? wsTag.tagColor : "#bec2c8" }}
>
{" "}
</div>
<span>{wsTag.slug}</span>
</div> </div>
<div className="absolute left-0 w-full border-mineshaft-600 border-t mt-2" /> </div>
<div className="flex flex-col space-y-1.5"> ))}
{wsTags?.map((wsTag: WsTag) => ( <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Tags}>
<div key={`tag-${wsTag._id}`} className="mt-4 h-[32px] relative flex items-center justify-start hover:border-mineshaft-600 hover:border hover:bg-mineshaft-700 p-2 rounded-md hover:text-bunker-200 bg-none" {(isAllowed) => (
onClick={() => handleSelectTag(wsTag)} <Button
onMouseEnter={() => handleTagOnMouseEnter(wsTag)} onClick={() => handleOnCreateTagOpen()}
onMouseLeave={() => handleTagOnMouseLeave()} isDisabled={!isAllowed}
tabIndex={0} role="button" size="xs"
onKeyDown={() => { }}> className="mt-2"
{ leftIcon={<FontAwesomeIcon icon={faPlus} className="ml-1 mr-2" />}
>
Add new tag
</Button>
)}
</ProjectPermissionCan>
</div>
</PopoverContent>
);
};
(checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && <Checkbox export default AddTagPopoverContent;
id="autoCapitalization"
isChecked={selectedTagIds?.[wsTag.slug]}
className="absolute top-[50%] translate-y-[-50%] left-[10px] "
checkIndicatorBg={`${!selectedTagIds?.[wsTag.slug] ? "text-transparent" : "text-mineshaft-800"}`}
/>
}
<div className="ml-7 flex items-center gap-3">
<div className="w-[10px] h-[10px] rounded-full" style={{ background: wsTag?.tagColor ? wsTag.tagColor : "#bec2c8" }}> </div>
<span >
{wsTag.slug}
</span>
</div>
</div>
))}
<div
className="h-[32px] relative flex items-center cursor-pointer justify-start border-mineshaft-600 border bg-mineshaft-700 p-2 rounded-md hover:text-bunker-200 bg-none"
onClick={() => handleOnCreateTagOpen()}
tabIndex={0} role="button"
onKeyDown={() => { }}>
<FontAwesomeIcon icon={faPlus} className="ml-1 mr-2" />
<span> Add new tag</span>
</div>
</div>
</PopoverContent>
)
}
export default AddTagPopoverContent
@@ -0,0 +1,40 @@
import { ReactNode } from "react";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
type Props = {
containerClassName?: string;
className?: string;
children?: ReactNode;
};
export const PermissionDeniedBanner = ({ containerClassName, className, children }: Props) => {
return (
<div
className={twMerge(
"container h-full mx-auto flex justify-center items-center",
containerClassName
)}
>
<div
className={twMerge(
"rounded-md bg-mineshaft-800 text-bunker-300 p-16 flex space-x-12 items-end",
className
)}
>
<div>
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="text-4xl font-medium mb-2">Permission Denied</div>
{children || (
<div className="text-sm">
You do not have permission. <br /> Kindly contact your organization administrator
</div>
)}
</div>
</div>
</div>
);
};
@@ -11,7 +11,11 @@ type Props = {
// so when permission is allowed same tooltip will be reused to show helpertext // so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean; renderTooltip?: boolean;
allowedLabel?: string; allowedLabel?: string;
} & BoundCanProps<TProjectPermission>; // BUG(akhilmhdh): As a workaround for now i put any but this should be TProjectPermission
// For some reason when i put TProjectPermission in a wrapper component it just wont work causes a weird ts error
// tried a lot combinations
// REF: https://github.com/stalniy/casl/blob/ac081a34f56366a7eaaed05d21689d27041ef005/packages/casl-react/src/factory.ts#L15
} & BoundCanProps<any>;
export const ProjectPermissionCan: FunctionComponent<Props> = ({ export const ProjectPermissionCan: FunctionComponent<Props> = ({
label = "Permission Denied. Kindly contact your project admin", label = "Permission Denied. Kindly contact your project admin",
@@ -22,7 +26,6 @@ export const ProjectPermissionCan: FunctionComponent<Props> = ({
...props ...props
}) => { }) => {
const permission = useProjectPermission(); const permission = useProjectPermission();
return ( return (
<Can {...props} passThrough={passThrough} ability={props?.ability || permission}> <Can {...props} passThrough={passThrough} ability={props?.ability || permission}>
{(isAllowed, ability) => { {(isAllowed, ability) => {
@@ -1,2 +1,3 @@
export { OrgPermissionCan } from "./OrgPermissionCan"; export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
export { ProjectPermissionCan } from "./ProjectPermissionCan"; export { ProjectPermissionCan } from "./ProjectPermissionCan";
@@ -27,7 +27,7 @@ export enum ProjectPermissionSub {
type SubjectFields = { type SubjectFields = {
environment: string; environment: string;
secretPath?: string; secretPath: string;
}; };
export type ProjectPermissionSet = export type ProjectPermissionSet =
+27 -2
View File
@@ -1,6 +1,8 @@
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { buildMongoQueryMatcher, createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, unpackRules } from "@casl/ability/extra"; import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types"; import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types";
@@ -13,6 +15,29 @@ import {
TRole TRole
} from "./types"; } from "./types";
const $glob: FieldInstruction<string> = {
type: "field",
validate(instruction, value) {
if (typeof value !== "string") {
throw new Error(`"${instruction.name}" expects value to be a string`);
}
}
};
const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
const secretPath = context.get(object, node.field);
const permissionSecretGlobPath = node.value;
if (!secretPath) return false;
// console.log(
// secretPath,
// picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }),
// permissionSecretGlobPath
// );
return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false });
};
const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
export const roleQueryKeys = { export const roleQueryKeys = {
getRoles: ({ orgId, workspaceId }: TGetRolesDTO) => ["roles", { orgId, workspaceId }] as const, getRoles: ({ orgId, workspaceId }: TGetRolesDTO) => ["roles", { orgId, workspaceId }] as const,
getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) =>
@@ -57,7 +82,7 @@ export const useGetUserOrgPermissions = ({ orgId }: TGetUserOrgPermissionsDTO) =
enabled: Boolean(orgId), enabled: Boolean(orgId),
select: (data) => { select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(data); const rule = unpackRules<RawRuleOf<MongoAbility<OrgPermissionSet>>>(data);
const ability = createMongoAbility<OrgPermissionSet>(rule); const ability = createMongoAbility<OrgPermissionSet>(rule, { conditionsMatcher });
return ability; return ability;
} }
}); });
+18 -8
View File
@@ -739,16 +739,26 @@ export const AppLayout = ({ children }: LayoutProps) => {
<Controller <Controller
control={control} control={control}
name="addMembers" name="addMembers"
defaultValue defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => ( render={({ field: { onBlur, value, onChange } }) => (
<Checkbox <OrgPermissionCan
id="add-project-layout" I={OrgPermissionActions.Read}
isChecked={value} a={OrgPermissionSubjects.Member}
onCheckedChange={onChange}
onBlur={onBlur}
> >
Add all members of my organization to this project {(isAllowed) => (
</Checkbox> <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>
+18 -8
View File
@@ -838,16 +838,26 @@ const OrganizationPage = withPermission(
<Controller <Controller
control={control} control={control}
name="addMembers" name="addMembers"
defaultValue defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => ( render={({ field: { onBlur, value, onChange } }) => (
<Checkbox <OrgPermissionCan
id="add-project-layout" I={OrgPermissionActions.Read}
isChecked={value} a={OrgPermissionSubjects.Member}
onCheckedChange={onChange}
onBlur={onBlur}
> >
Add all members of my organization to this project {(isAllowed) => (
</Checkbox> <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>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import { memo } from "react"; import { memo } from "react";
import { subject } from "@casl/ability";
import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -9,6 +10,8 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
type Props = { type Props = {
folders?: Array<{ id: string; name: string }>; folders?: Array<{ id: string; name: string }>;
search?: string; search?: string;
environment: string;
secretPath: string;
onFolderUpdate: (folderId: string, name: string) => void; onFolderUpdate: (folderId: string, name: string) => void;
onFolderDelete: (folderId: string, name: string) => void; onFolderDelete: (folderId: string, name: string) => void;
onFolderOpen: (folderId: string) => void; onFolderOpen: (folderId: string) => void;
@@ -20,7 +23,9 @@ export const FolderSection = memo(
onFolderDelete: handleFolderDelete, onFolderDelete: handleFolderDelete,
onFolderOpen: handleFolderOpen, onFolderOpen: handleFolderOpen,
search = "", search = "",
folders = [] folders = [],
environment,
secretPath
}: Props) => { }: Props) => {
return ( return (
<> <>
@@ -51,7 +56,7 @@ export const FolderSection = memo(
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all"> <div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Folders} a={subject(ProjectPermissionSub.Folders, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100"> <div className="opacity-0 group-hover:opacity-100">
@@ -72,7 +77,7 @@ export const FolderSection = memo(
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Folders} a={subject(ProjectPermissionSub.Folders, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100"> <div className="opacity-0 group-hover:opacity-100">
@@ -1,4 +1,5 @@
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -23,6 +24,8 @@ import { GenRandomNumber } from "./GenRandomNumber";
type Props = { type Props = {
isDrawerOpen: boolean; isDrawerOpen: boolean;
environment: string;
secretPath: string;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
index: number; index: number;
isReadOnly?: boolean; isReadOnly?: boolean;
@@ -41,7 +44,9 @@ export const SecretDetailDrawer = ({
isReadOnly, isReadOnly,
onSecretDelete, onSecretDelete,
onSave, onSave,
onEnvCompare onEnvCompare,
environment,
secretPath
}: Props): JSX.Element => { }: Props): JSX.Element => {
const [canRevealSecVal, setCanRevealSecVal] = useToggle(); const [canRevealSecVal, setCanRevealSecVal] = useToggle();
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle(); const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
@@ -89,7 +94,7 @@ export const SecretDetailDrawer = ({
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Secrets} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly || !isAllowed}> <Button isFullWidth onClick={onSave} isDisabled={isReadOnly || !isAllowed}>
@@ -98,8 +103,8 @@ export const SecretDetailDrawer = ({
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Secrets} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -1,6 +1,7 @@
import { ChangeEvent, DragEvent, useEffect, useState } from "react"; import { ChangeEvent, DragEvent, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons"; import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { import {
faClone, faClone,
@@ -78,6 +79,8 @@ type Props = {
environments?: { name: string; slug: string }[]; environments?: { name: string; slug: string }[];
workspaceId: string; workspaceId: string;
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
environment: string;
secretPath: string;
}; };
export const SecretDropzone = ({ export const SecretDropzone = ({
@@ -86,7 +89,9 @@ export const SecretDropzone = ({
onAddNewSecret, onAddNewSecret,
environments = [], environments = [],
workspaceId, workspaceId,
decryptFileKey decryptFileKey,
environment,
secretPath
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle(); const [isDragActive, setDragActive] = useToggle();
@@ -109,16 +114,16 @@ export const SecretDropzone = ({
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
}); });
const secretPath = watch("secretPath"); const envCopySecPath = watch("secretPath");
const selectedEnvSlug = watch("environment"); const selectedEnvSlug = watch("environment");
const debouncedSecretPath = useDebounce(secretPath); const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId, workspaceId,
env: selectedEnvSlug, env: selectedEnvSlug,
secretPath: debouncedSecretPath, secretPath: debouncedEnvCopySecretPath,
isPaused: isPaused:
!(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedEnvCopySecretPath)) &&
!popUp.importSecEnv.isOpen, !popUp.importSecEnv.isOpen,
decryptFileKey decryptFileKey
}); });
@@ -126,7 +131,7 @@ export const SecretDropzone = ({
useEffect(() => { useEffect(() => {
setValue("secrets", {}); setValue("secrets", {});
setSearchFilter(""); setSearchFilter("");
}, [debouncedSecretPath]); }, [debouncedEnvCopySecretPath]);
const handleDrag = (e: DragEvent) => { const handleDrag = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -238,7 +243,7 @@ export const SecretDropzone = ({
</div> </div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<input <input
@@ -271,16 +276,22 @@ export const SecretDropzone = ({
}} }}
> >
<ModalTrigger asChild> <ModalTrigger asChild>
<ProjectPermissionCan <div>
I={ProjectPermissionActions.Create} <ProjectPermissionCan
a={ProjectPermissionSub.Secrets} I={ProjectPermissionActions.Create}
> a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
{(isAllowed) => ( >
<Button isDisabled={!isAllowed} variant="star" size={isSmaller ? "xs" : "sm"}> {(isAllowed) => (
Copy Secrets From An Environment <Button
</Button> isDisabled={!isAllowed}
)} variant="star"
</ProjectPermissionCan> size={isSmaller ? "xs" : "sm"}
>
Copy Secrets From An Environment
</Button>
)}
</ProjectPermissionCan>
</div>
</ModalTrigger> </ModalTrigger>
<ModalContent <ModalContent
className="max-w-2xl" className="max-w-2xl"
@@ -416,7 +427,7 @@ export const SecretDropzone = ({
{!isSmaller && ( {!isSmaller && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button variant="star" onClick={onAddNewSecret} isDisabled={!isAllowed}> <Button variant="star" onClick={onAddNewSecret} isDisabled={!isAllowed}>
@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { import {
faFileImport, faFileImport,
@@ -11,11 +12,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2"; import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
type Props = { type Props = {
onDelete: (environment: string, secretPath: string) => void; onDelete: (environment: string, secretPath: string) => void;
environment: string;
secretPath: string;
importedEnv: string; importedEnv: string;
importedSecPath: string; importedSecPath: string;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
@@ -40,7 +43,9 @@ export const SecretImportItem = ({
importedSecPath, importedSecPath,
onDelete, onDelete,
importedSecrets = [], importedSecrets = [],
searchTerm = "" searchTerm = "",
secretPath,
environment
}: Props) => { }: Props) => {
const [isExpanded, setIsExpanded] = useToggle(); const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
@@ -114,7 +119,7 @@ export const SecretImportItem = ({
</div> </div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretImports} a={subject(ProjectPermissionSub.SecretImports, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100"> <div className="opacity-0 group-hover:opacity-100">
@@ -59,13 +59,23 @@ export const computeImportedSecretRows = (
type Props = { type Props = {
secrets?: DecryptedSecret[]; secrets?: DecryptedSecret[];
importedSecrets?: TImportedSecrets; importedSecrets?: TImportedSecrets;
environment: string;
secretPath: string;
onSecretImportDelete: (env: string, secPath: string) => void; onSecretImportDelete: (env: string, secPath: string) => void;
items: { id: string; environment: string; secretPath: string }[]; items: { id: string; environment: string; secretPath: string }[];
searchTerm: string; searchTerm: string;
}; };
export const SecretImportSection = memo( export const SecretImportSection = memo(
({ secrets = [], importedSecrets = [], onSecretImportDelete, items = [], searchTerm = "" }: Props) => { ({
secrets = [],
environment,
secretPath,
importedSecrets = [],
onSecretImportDelete,
items = [],
searchTerm = ""
}: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
@@ -82,6 +92,8 @@ export const SecretImportSection = memo(
secrets, secrets,
environments environments
)} )}
secretPath={secretPath}
environment={environment}
onDelete={onSecretImportDelete} onDelete={onSecretImportDelete}
importedSecPath={impSecPath} importedSecPath={impSecPath}
searchTerm={searchTerm} searchTerm={searchTerm}
@@ -8,6 +8,7 @@ import {
UseFormSetValue, UseFormSetValue,
useWatch useWatch
} from "react-hook-form"; } from "react-hook-form";
import { subject } from "@casl/ability";
import { import {
faCheck, faCheck,
faCodeBranch, faCodeBranch,
@@ -22,31 +23,34 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "cva"; import { cx } from "cva";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
// TODO:(akhilmhdh): Refactor this
import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
FormControl,
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
IconButton, IconButton,
Input, Input,
Popover, Popover,
PopoverContent,
PopoverTrigger, PopoverTrigger,
SecretInput, SecretInput,
Tag, Tag,
TextArea,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
import AddTagPopoverContent from "../../../../components/AddTagPopoverContent/AddTagPopoverContent";
import { FormData, SecretActionType } from "../../DashboardPage.utils"; import { FormData, SecretActionType } from "../../DashboardPage.utils";
type Props = { type Props = {
index: number; index: number;
environment: string;
secretPath: string;
// backend generated unique id // backend generated unique id
secUniqId?: string; secUniqId?: string;
// permission and external state's that decided to hide or show // permission and external state's that decided to hide or show
@@ -74,6 +78,8 @@ type Props = {
export const SecretInputRow = memo( export const SecretInputRow = memo(
({ ({
index, index,
secretPath,
environment,
isSecretValueHidden, isSecretValueHidden,
onRowExpand, onRowExpand,
isReadOnly, isReadOnly,
@@ -84,7 +90,7 @@ export const SecretInputRow = memo(
onSecretDelete, onSecretDelete,
searchTerm, searchTerm,
control, control,
// register, register,
setValue, setValue,
isKeyError, isKeyError,
keyError, keyError,
@@ -222,7 +228,6 @@ export const SecretInputRow = memo(
<td className="flex h-10 w-10 items-center justify-center border-none px-4"> <td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div> <div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
</td> </td>
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue=""
@@ -361,16 +366,24 @@ export const SecretInputRow = memo(
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div className="w-0 group-hover:w-6 data-[state=open]:w-6"> <div className="w-0 group-hover:w-6 data-[state=open]:w-6">
<Tooltip content="Add tags"> <ProjectPermissionCan
<IconButton renderTooltip
variant="plain" allowedLabel="Add Tags"
size="md" I={ProjectPermissionActions.Edit}
ariaLabel="add-tag" a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
className="py-[0.42rem]" >
> {(isAllowed) => (
<FontAwesomeIcon icon={faTags} /> <IconButton
</IconButton> isDisabled={!isAllowed}
</Tooltip> variant="plain"
size="md"
ariaLabel="add-tags"
className="py-[0.42rem]"
>
<FontAwesomeIcon icon={faTags} />
</IconButton>
)}
</ProjectPermissionCan>
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<AddTagPopoverContent <AddTagPopoverContent
@@ -390,55 +403,76 @@ export const SecretInputRow = memo(
<div className="flex h-8 flex-row items-center pr-2"> <div className="flex h-8 flex-row items-center pr-2">
{!isAddOnly && ( {!isAddOnly && (
<div> <div>
<Tooltip content="Override with a personal value"> <ProjectPermissionCan
<IconButton renderTooltip
variant="plain" allowedLabel="Override with a personal value"
className={twMerge( I={ProjectPermissionActions.Edit}
"mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7", a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
isOverridden && "ml-1 w-7 text-primary" >
)} {(isAllowed) => (
onClick={onSecretOverride} <IconButton
size="md" variant="plain"
isDisabled={isRollbackMode || isReadOnly} className={twMerge(
ariaLabel="info" "mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7",
> isOverridden && "ml-1 w-7 text-primary"
<div className="flex items-center space-x-1"> )}
<FontAwesomeIcon icon={faCodeBranch} className="text-base" /> onClick={onSecretOverride}
</div> size="md"
</IconButton> isDisabled={isRollbackMode || isReadOnly || !isAllowed}
</Tooltip> ariaLabel="info"
>
<div className="flex items-center space-x-1">
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
</div>
</IconButton>
)}
</ProjectPermissionCan>
</div> </div>
)} )}
<Tooltip content="Comment"> <div className="mt-0.5 overflow-hidden ">
<div className="mt-0.5 overflow-hidden "> <Popover>
<Popover> <PopoverTrigger asChild>
<PopoverTrigger asChild> <div>
<IconButton <ProjectPermissionCan
className={twMerge( renderTooltip
"w-7 overflow-hidden p-0", allowedLabel="Comment"
"w-0 group-hover:w-7 data-[state=open]:w-7", I={ProjectPermissionActions.Edit}
hasComment ? "w-7 text-primary" : "group-hover:w-7" a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
)}
variant="plain"
size="md"
ariaLabel="add-tag"
> >
<FontAwesomeIcon icon={faComment} /> {(isAllowed) => (
</IconButton> <IconButton
</PopoverTrigger> className={twMerge(
<AddTagPopoverContent "w-7 overflow-hidden p-0",
wsTags={wsTags} "w-0 group-hover:w-7 data-[state=open]:w-7",
secKey={secKey || "this secret"} hasComment ? "w-7 text-primary" : "group-hover:w-7"
selectedTagIds={selectedTagIds} )}
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)} isDisabled={!isAllowed}
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)} variant="plain"
handleTagOnMouseLeave={() => handleTagOnMouseLeave()} size="md"
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)} ariaLabel="add-comment"
handleOnCreateTagOpen={() => onCreateTagOpen()} >
/> <FontAwesomeIcon icon={faComment} />
</Popover> </IconButton>
</div> )}
</Tooltip> </ProjectPermissionCan>
</div>
</PopoverTrigger>
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
className="border border-mineshaft-600 text-sm"
{...register(`secrets.${index}.comment`)}
rows={8}
cols={30}
/>
</FormControl>
</PopoverContent>
</Popover>
</div>
</div> </div>
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all"> <div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
<div className="flex h-8 items-center space-x-2.5"> <div className="flex h-8 items-center space-x-2.5">
@@ -457,29 +491,29 @@ export const SecretInputRow = memo(
</Tooltip> </Tooltip>
</div> </div>
)} )}
<ProjectPermissionCan <div className="opacity-0 group-hover:opacity-100">
I={ProjectPermissionActions.Delete} <ProjectPermissionCan
a={ProjectPermissionSub.Secrets} I={ProjectPermissionActions.Delete}
> a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
{(isAllowed) => ( renderTooltip
<div className="opacity-0 group-hover:opacity-100"> allowedLabel="Delete"
<Tooltip content="Delete"> >
<IconButton {(isAllowed) => (
size="lg" <IconButton
variant="plain" size="lg"
colorSchema="danger" variant="plain"
ariaLabel="delete" colorSchema="danger"
isDisabled={isReadOnly || isRollbackMode || !isAllowed} ariaLabel="delete"
onClick={() => { isDisabled={isReadOnly || isRollbackMode || !isAllowed}
onSecretDelete(index, secKey, secId, idOverride); onClick={() => {
}} onSecretDelete(index, secKey, secId, idOverride);
> }}
<FontAwesomeIcon icon={faXmark} /> >
</IconButton> <FontAwesomeIcon icon={faXmark} />
</Tooltip> </IconButton>
</div> )}
)} </ProjectPermissionCan>
</ProjectPermissionCan> </div>
</div> </div>
</div> </div>
</td> </td>
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
import { import {
Checkbox, Checkbox,
FormControl,
Input, Input,
Select, Select,
SelectItem, SelectItem,
@@ -68,7 +69,7 @@ export const MultiEnvProjectPermission = ({
const handlePermissionChange = (val: Permission) => { const handlePermissionChange = (val: Permission) => {
switch (val) { switch (val) {
case Permission.NoAccess: case Permission.NoAccess:
setValue(`permissions.${formName}`, {}, { shouldDirty: true }); setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
break; break;
case Permission.FullAccess: case Permission.FullAccess:
setValue( setValue(
@@ -151,7 +152,14 @@ export const MultiEnvProjectPermission = ({
name={`permissions.${formName}.${slug}.secretPath`} name={`permissions.${formName}.${slug}.secretPath`}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Input {...field} className="w-full overflow-ellipsis" /> /* eslint-disable-next-line no-template-curly-in-string */
<FormControl helperText="Ex pattern: /, /**, /{folder1,folder2}">
<Input
{...field}
className="w-full overflow-ellipsis"
placeholder="Glob patterns are supported"
/>
</FormControl>
)} )}
/> />
</Td> </Td>
@@ -133,6 +133,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
const handleRoleUpdate = async (el: TFormSchema) => { const handleRoleUpdate = async (el: TFormSchema) => {
if (!role?._id) return; if (!role?._id) return;
console.log(el);
try { try {
await updateRole({ await updateRole({
orgId, orgId,
@@ -73,7 +73,7 @@ const multiEnvApi2Form = (
} }
const secretEnv = permission?.conditions?.environment || "all"; const secretEnv = permission?.conditions?.environment || "all";
const secretPath = permission?.conditions?.secretPath; const secretPath = permission?.conditions?.secretPath?.$glob;
// initialize // initialize
if (formVal && !formVal?.[secretEnv]) { if (formVal && !formVal?.[secretEnv]) {
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
@@ -109,6 +109,8 @@ const multiEnvForm2Api = (
formVal: Record<string, { secretPath?: string } & { [key: string]: boolean }>, formVal: Record<string, { secretPath?: string } & { [key: string]: boolean }>,
subject: (typeof MULTI_ENV_KEY)[number] subject: (typeof MULTI_ENV_KEY)[number]
) => { ) => {
if (!formVal) return;
const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]);
// if any of them is set in all push it without any condition // if any of them is set in all push it without any condition
PERMISSION_ACTIONS.forEach((action) => { PERMISSION_ACTIONS.forEach((action) => {
@@ -130,7 +132,8 @@ const multiEnvForm2Api = (
// if not full access for an action // if not full access for an action
if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
const conditions: Record<string, unknown> = { environment: slug }; const conditions: Record<string, unknown> = { environment: slug };
if (formVal[slug]?.secretPath) conditions.secretPath = formVal?.[slug]?.secretPath; if (formVal[slug]?.secretPath)
conditions.secretPath = { $glob: formVal?.[slug]?.secretPath };
permissions.push({ action, subject, conditions }); permissions.push({ action, subject, conditions });
} }
@@ -141,17 +144,22 @@ const multiEnvForm2Api = (
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TProjectPermission[] = []; const permissions: TProjectPermission[] = [];
MULTI_ENV_KEY.forEach((formName) => {
multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(formVal?.[formName] || {})), formName);
});
// other than workspace everything else follows same // other than workspace everything else follows same
// if in future there is a different follow the above on how workspace is done // if in future there is a different follow the above on how workspace is done
Object.entries(formVal || {}).forEach(([rule, actions]) => { Object.entries(formVal || {}).forEach(([rule, actions]) => {
Object.entries(actions).forEach(([action, isAllowed]) => { if (MULTI_ENV_KEY.includes(rule as (typeof MULTI_ENV_KEY)[number])) {
if (isAllowed) { multiEnvForm2Api(
permissions.push({ subject: rule, action }); permissions,
} JSON.parse(JSON.stringify(actions || {})),
}); rule as (typeof MULTI_ENV_KEY)[number]
);
} else {
Object.entries(actions).forEach(([action, isAllowed]) => {
if (isAllowed) {
permissions.push({ subject: rule, action });
}
});
}
}); });
return permissions; return permissions;
}; };
@@ -13,6 +13,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { PermissionDeniedBanner } from "@app/components/permissions";
import { import {
Button, Button,
EmptyState, EmptyState,
@@ -36,7 +37,6 @@ import {
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { import {
useCreateSecretV3, useCreateSecretV3,
useDeleteSecretV3, useDeleteSecretV3,
@@ -50,7 +50,7 @@ import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
const SecretOverview = () => { export const SecretOverviewPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const router = useRouter(); const router = useRouter();
@@ -91,11 +91,20 @@ const SecretOverview = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]); }, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = const userAvailableEnvs =
currentWorkspace?.environments?.filter(({ slug }) => currentWorkspace?.environments?.filter(
permission.can( ({ slug }) =>
ProjectPermissionActions.Read, permission.can(
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath: secretPath || "/" }) ProjectPermissionActions.Read,
) subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath })
) ||
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Folders, { environment: slug, secretPath })
) ||
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment: slug, secretPath })
)
) || []; ) || [];
const { const {
@@ -371,18 +380,23 @@ const SecretOverview = () => {
onClick={handleFolderClick} onClick={handleFolderClick}
/> />
))} ))}
{filteredSecretNames.map((key, index) => ( {userAvailableEnvs?.length > 0 ? (
<SecretOverviewTableRow filteredSecretNames.map((key, index) => (
onSecretCreate={handleSecretCreate} <SecretOverviewTableRow
onSecretDelete={handleSecretDelete} secretPath={secretPath}
onSecretUpdate={handleSecretUpdate} onSecretCreate={handleSecretCreate}
key={`overview-${key}-${index + 1}`} onSecretDelete={handleSecretDelete}
environments={userAvailableEnvs} onSecretUpdate={handleSecretUpdate}
secretKey={key} key={`overview-${key}-${index + 1}`}
getSecretByKey={getSecretByKey} environments={userAvailableEnvs}
expandableColWidth={expandableTableWidth} secretKey={key}
/> getSecretByKey={getSecretByKey}
))} expandableColWidth={expandableTableWidth}
/>
))
) : (
<PermissionDeniedBanner />
)}
</TBody> </TBody>
<TFoot> <TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800"> <Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -414,8 +428,3 @@ const SecretOverview = () => {
</div> </div>
); );
}; };
export const SecretOverviewPage = withProjectPermission(SecretOverview, {
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Secrets
});
@@ -1,4 +1,5 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ type Props = {
isCreatable?: boolean; isCreatable?: boolean;
isVisible?: boolean; isVisible?: boolean;
environment: string; environment: string;
secretPath: string;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>; onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (env: string, key: string, value: string) => Promise<void>; onSecretUpdate: (env: string, key: string, value: string) => Promise<void>;
onSecretDelete: (env: string, key: string) => Promise<void>; onSecretDelete: (env: string, key: string) => Promise<void>;
@@ -27,6 +29,7 @@ export const SecretEditRow = ({
onSecretCreate, onSecretCreate,
onSecretDelete, onSecretDelete,
environment, environment,
secretPath,
isVisible isVisible
}: Props) => { }: Props) => {
const { const {
@@ -95,7 +98,7 @@ export const SecretEditRow = ({
<> <>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<div> <div>
@@ -18,6 +18,7 @@ import { SecretEditRow } from "./SecretEditRow";
type Props = { type Props = {
secretKey: string; secretKey: string;
secretPath: string;
environments: { name: string; slug: string }[]; environments: { name: string; slug: string }[];
expandableColWidth: number; expandableColWidth: number;
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
@@ -29,6 +30,7 @@ type Props = {
export const SecretOverviewTableRow = ({ export const SecretOverviewTableRow = ({
secretKey, secretKey,
environments = [], environments = [],
secretPath,
getSecretByKey, getSecretByKey,
onSecretUpdate, onSecretUpdate,
onSecretCreate, onSecretCreate,
@@ -73,9 +75,11 @@ export const SecretOverviewTableRow = ({
> >
<div className="h-full w-full border-r border-mineshaft-600 py-[0.85rem] px-5"> <div className="h-full w-full border-r border-mineshaft-600 py-[0.85rem] px-5">
<div className="flex justify-center"> <div className="flex justify-center">
{!isSecretEmpty && <Tooltip content={isSecretPresent ? "Present secret" : "Missing secret"}> {!isSecretEmpty && (
<FontAwesomeIcon icon={isSecretPresent ? faCheck : faXmark} /> <Tooltip content={isSecretPresent ? "Present secret" : "Missing secret"}>
</Tooltip>} <FontAwesomeIcon icon={isSecretPresent ? faCheck : faXmark} />
</Tooltip>
)}
{isSecretEmpty && ( {isSecretEmpty && (
<Tooltip content="Empty value"> <Tooltip content="Empty value">
<FontAwesomeIcon icon={faCircle} /> <FontAwesomeIcon icon={faCircle} />
@@ -141,6 +145,7 @@ export const SecretOverviewTableRow = ({
</td> </td>
<td className="col-span-2 h-8 w-full"> <td className="col-span-2 h-8 w-full">
<SecretEditRow <SecretEditRow
secretPath={secretPath}
isVisible={isSecretVisible} isVisible={isSecretVisible}
secretName={secretKey} secretName={secretKey}
defaultValue={secret?.value} defaultValue={secret?.value}
@@ -2,53 +2,49 @@ import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan, PermissionDeniedBanner } from "@app/components/permissions";
import { Button } from "@app/components/v2"; import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"; import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable"; import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export const OrgIncidentContactsSection = withPermission( export const OrgIncidentContactsSection = () => {
() => { const { t } = useTranslation();
const { t } = useTranslation(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addContact"
"addContact" ] as const);
] as const); const permission = useOrgPermission();
return ( return (
<div className="p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-4"> <div className="flex justify-between mb-4">
<p className="min-w-max text-xl font-semibold"> <p className="min-w-max text-xl font-semibold">{t("section.incident.incident-contacts")}</p>
{t("section.incident.incident-contacts")} <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.IncidentAccount}>
</p> {(isAllowed) => (
<OrgPermissionCan <Button
I={OrgPermissionActions.Create} colorSchema="secondary"
a={OrgPermissionSubjects.IncidentAccount} type="submit"
> isDisabled={!isAllowed}
{(isAllowed) => ( leftIcon={<FontAwesomeIcon icon={faPlus} />}
<Button onClick={() => handlePopUpOpen("addContact")}
colorSchema="secondary" >
type="submit" Add contact
isDisabled={!isAllowed} </Button>
leftIcon={<FontAwesomeIcon icon={faPlus} />} )}
onClick={() => handlePopUpOpen("addContact")} </OrgPermissionCan>
>
Add contact
</Button>
)}
</OrgPermissionCan>
</div>
<OrgIncidentContactsTable />
<AddOrgIncidentContactModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div> </div>
); {permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount) ? (
}, <OrgIncidentContactsTable />
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.IncidentAccount } ) : (
); <PermissionDeniedBanner />
)}
<AddOrgIncidentContactModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</div>
);
};
@@ -7,7 +7,6 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { withPermission } from "@app/hoc";
import { useRenameOrg } from "@app/hooks/api"; import { useRenameOrg } from "@app/hooks/api";
const formSchema = yup.object({ const formSchema = yup.object({
@@ -16,77 +15,70 @@ const formSchema = yup.object({
type FormData = yup.InferType<typeof formSchema>; type FormData = yup.InferType<typeof formSchema>;
export const OrgNameChangeSection = withPermission( export const OrgNameChangeSection = (): JSX.Element => {
(): JSX.Element => { const { currentOrg } = useOrganization();
const { currentOrg } = useOrganization(); const { createNotification } = useNotificationContext();
const { createNotification } = useNotificationContext(); const { handleSubmit, control, reset } = useForm<FormData>({
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: yupResolver(formSchema)
resolver: yupResolver(formSchema) });
}); const { mutateAsync, isLoading } = useRenameOrg();
const { mutateAsync, isLoading } = useRenameOrg();
useEffect(() => { useEffect(() => {
if (currentOrg) { if (currentOrg) {
reset({ name: currentOrg.name }); reset({ name: currentOrg.name });
} }
}, [currentOrg]); }, [currentOrg]);
const onFormSubmit = async ({ name }: FormData) => { const onFormSubmit = async ({ name }: FormData) => {
try { try {
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
if (name === "") return; if (name === "") return;
await mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); await mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
createNotification({ createNotification({
text: "Successfully renamed organization", text: "Successfully renamed organization",
type: "success" type: "success"
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
createNotification({ createNotification({
text: "Failed to rename organization", text: "Failed to rename organization",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<form <form
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600" className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"
> >
<p className="text-xl font-semibold text-mineshaft-100 mb-4">Organization name</p> <p className="text-xl font-semibold text-mineshaft-100 mb-4">Organization name</p>
<div className="mb-2 max-w-md"> <div className="mb-2 max-w-md">
<Controller <Controller
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Acme Corp" {...field} /> <Input placeholder="Acme Corp" {...field} />
</FormControl> </FormControl>
)}
control={control}
name="name"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
>
Save
</Button>
)} )}
</OrgPermissionCan> control={control}
</form> name="name"
); />
}, </div>
{ <OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
action: OrgPermissionActions.Read, {(isAllowed) => (
subject: OrgPermissionSubjects.Settings, <Button
containerClassName: "mb-4" isLoading={isLoading}
} isDisabled={!isAllowed}
); colorSchema="primary"
variant="outline_bg"
type="submit"
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};
@@ -3,8 +3,6 @@ import { useTranslation } from "react-i18next";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab";
@@ -16,50 +14,47 @@ const tabs = [
{ name: "Webhooks", key: "tab-project-webhooks" } { name: "Webhooks", key: "tab-project-webhooks" }
]; ];
export const ProjectSettingsPage = withProjectPermission( export const ProjectSettingsPage = () => {
() => { const { t } = useTranslation();
const { t } = useTranslation(); return (
return ( <div className="flex w-full justify-center bg-bunker-800 px-6 text-white">
<div className="flex w-full justify-center bg-bunker-800 px-6 text-white"> <div className="w-full max-w-screen-lg">
<div className="w-full max-w-screen-lg"> <div className="relative right-5 ml-4">
<div className="relative right-5 ml-4"> <NavHeader pageName={t("settings.project.title")} isProjectRelated />
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tab.Group>
<Tab.List className="mb-4 w-full border-b-2 border-mineshaft-800">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${
selected ? "border-b border-white text-white" : "text-mineshaft-400"
}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div> </div>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tab.Group>
<Tab.List className="mb-4 w-full border-b-2 border-mineshaft-800">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${
selected ? "border-b border-white text-white" : "text-mineshaft-400"
}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div> </div>
); </div>
}, );
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } };
);
@@ -4,61 +4,57 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { Checkbox } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useToggleAutoCapitalization } from "@app/hooks/api"; import { useToggleAutoCapitalization } from "@app/hooks/api";
export const AutoCapitalizationSection = withProjectPermission( export const AutoCapitalizationSection = () => {
() => { const { t } = useTranslation();
const { t } = useTranslation(); const { createNotification } = useNotificationContext();
const { createNotification } = useNotificationContext(); const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspace(); const { mutateAsync } = useToggleAutoCapitalization();
const { mutateAsync } = useToggleAutoCapitalization();
const handleToggleCapitalizationToggle = async (state: boolean) => { const handleToggleCapitalizationToggle = async (state: boolean) => {
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
await mutateAsync({ await mutateAsync({
workspaceID: currentWorkspace._id, workspaceID: currentWorkspace._id,
state state
}); });
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
createNotification({ createNotification({
text, text,
type: "success" type: "success"
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to update auto capitalization", text: "Failed to update auto capitalization",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p> <p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => ( {(isAllowed) => (
<div className="w-max"> <div className="w-max">
<Checkbox <Checkbox
className="data-[state=checked]:bg-primary" className="data-[state=checked]:bg-primary"
id="autoCapitalization" id="autoCapitalization"
isDisabled={!isAllowed} isDisabled={!isAllowed}
isChecked={currentWorkspace?.autoCapitalization ?? false} isChecked={currentWorkspace?.autoCapitalization ?? false}
onCheckedChange={(state) => { onCheckedChange={(state) => {
handleToggleCapitalizationToggle(state as boolean); handleToggleCapitalizationToggle(state as boolean);
}} }}
> >
{t("settings.project.auto-capitalization-description")} {t("settings.project.auto-capitalization-description")}
</Checkbox> </Checkbox>
</div> </div>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
); );
}, };
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings }
);
@@ -5,113 +5,106 @@ import {
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { Checkbox } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api";
export const E2EESection = withProjectPermission( export const E2EESection = () => {
() => { const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspace(); const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? "");
const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? ""); const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus();
const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus(); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
/** /**
* Activate bot for project by performing the following steps: * Activate bot for project by performing the following steps:
* 1. Get the (encrypted) project key * 1. Get the (encrypted) project key
* 2. Decrypt project key with user's private key * 2. Decrypt project key with user's private key
* 3. Encrypt project key with bot's public key * 3. Encrypt project key with bot's public key
* 4. Send encrypted project key to backend and set bot status to active * 4. Send encrypted project key to backend and set bot status to active
*/ */
const toggleBotActivate = async () => { const toggleBotActivate = async () => {
let botKey; let botKey;
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
if (bot && wsKey) { if (bot && wsKey) {
// case: there is a bot // case: there is a bot
if (!bot.isActive) { if (!bot.isActive) {
// bot is not active -> activate bot // bot is not active -> activate bot
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) { if (!PRIVATE_KEY) {
throw new Error("Private Key missing"); throw new Error("Private Key missing");
}
const WORKSPACE_KEY = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: WORKSPACE_KEY,
publicKey: bot.publicKey,
privateKey: PRIVATE_KEY
});
botKey = {
encryptedKey: ciphertext,
nonce
};
await updateBotActiveStatus({
workspaceId: currentWorkspace._id,
botKey,
isActive: true,
botId: bot._id
});
} else {
// bot is active -> deactivate bot
await updateBotActiveStatus({
isActive: false,
botId: bot._id,
workspaceId: currentWorkspace._id
});
} }
}
} catch (err) {
console.error(err);
}
};
return bot ? ( const WORKSPACE_KEY = decryptAssymmetric({
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> ciphertext: wsKey.encryptedKey,
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p> nonce: wsKey.nonce,
<p className="text-gray-400 mb-8"> publicKey: wsKey.sender.publicKey,
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to privateKey: PRIVATE_KEY
cloud providers as well as HTTP calls to get secrets back raw but enables the server to });
read/decrypt your secret values.
</p> const { ciphertext, nonce } = encryptAssymmetric({
<p className="text-gray-400 mb-8"> plaintext: WORKSPACE_KEY,
Note that, even with E2EE disabled, your secrets are always encrypted at rest. publicKey: bot.publicKey,
</p> privateKey: PRIVATE_KEY
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}> });
{(isAllowed) => (
<div className="w-max"> botKey = {
<Checkbox encryptedKey: ciphertext,
className="data-[state=checked]:bg-primary" nonce
id="autoCapitalization" };
isChecked={!bot.isActive}
isDisabled={!isAllowed} await updateBotActiveStatus({
onCheckedChange={async () => { workspaceId: currentWorkspace._id,
await toggleBotActivate(); botKey,
}} isActive: true,
> botId: bot._id
End-to-end encryption enabled });
</Checkbox> } else {
</div> // bot is active -> deactivate bot
)} await updateBotActiveStatus({
</ProjectPermissionCan> isActive: false,
</div> botId: bot._id,
) : ( workspaceId: currentWorkspace._id
<div /> });
); }
}, }
{ } catch (err) {
action: ProjectPermissionActions.Read, console.error(err);
subject: ProjectPermissionSub.Settings }
} };
);
return bot ? (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
<p className="text-gray-400 mb-8">
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to
cloud providers as well as HTTP calls to get secrets back raw but enables the server to
read/decrypt your secret values.
</p>
<p className="text-gray-400 mb-8">
Note that, even with E2EE disabled, your secrets are always encrypted at rest.
</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<Checkbox
className="data-[state=checked]:bg-primary"
id="autoCapitalization"
isChecked={!bot.isActive}
isDisabled={!isAllowed}
onCheckedChange={async () => {
await toggleBotActivate();
}}
>
End-to-end encryption enabled
</Checkbox>
</div>
)}
</ProjectPermissionCan>
</div>
) : (
<div />
);
};
@@ -2,15 +2,15 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions"; import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission,
useSubscription, useSubscription,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteWsEnvironment } from "@app/hooks/api"; import { useDeleteWsEnvironment } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
@@ -18,111 +18,113 @@ import { AddEnvironmentModal } from "./AddEnvironmentModal";
import { EnvironmentTable } from "./EnvironmentTable"; import { EnvironmentTable } from "./EnvironmentTable";
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal"; import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
export const EnvironmentSection = withProjectPermission( export const EnvironmentSection = () => {
() => { const { createNotification } = useNotificationContext();
const { createNotification } = useNotificationContext(); const { subscription } = useSubscription();
const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspace(); const permision = useProjectPermission();
const deleteWsEnvironment = useDeleteWsEnvironment(); const deleteWsEnvironment = useDeleteWsEnvironment();
const isMoreEnvironmentsAllowed = const isMoreEnvironmentsAllowed =
subscription?.environmentLimit && currentWorkspace?.environments subscription?.environmentLimit && currentWorkspace?.environments
? currentWorkspace.environments.length < subscription.environmentLimit ? currentWorkspace.environments.length < subscription.environmentLimit
: true; : true;
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"createEnv", "createEnv",
"updateEnv", "updateEnv",
"deleteEnv", "deleteEnv",
"upgradePlan" "upgradePlan"
] as const); ] as const);
const onEnvDeleteSubmit = async (environmentSlug: string) => { const onEnvDeleteSubmit = async (environmentSlug: string) => {
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
await deleteWsEnvironment.mutateAsync({ await deleteWsEnvironment.mutateAsync({
workspaceID: currentWorkspace._id, workspaceID: currentWorkspace._id,
environmentSlug environmentSlug
}); });
createNotification({ createNotification({
text: "Successfully deleted environment", text: "Successfully deleted environment",
type: "success" type: "success"
}); });
handlePopUpClose("deleteEnv"); handlePopUpClose("deleteEnv");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete environment", text: "Failed to delete environment",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">Environments</p> <p className="text-xl font-semibold text-mineshaft-100">Environments</p>
<div> <div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Environments} a={ProjectPermissionSub.Environments}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
colorSchema="secondary" colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (isMoreEnvironmentsAllowed) { if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createEnv"); handlePopUpOpen("createEnv");
} else { } else {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
} }
}} }}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create environment Create environment
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div>
</div> </div>
<p className="text-gray-400 mb-8">
Choose which environments will show up in your dashboard like development, staging,
production
</p>
<EnvironmentTable handlePopUpOpen={handlePopUpOpen} />
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteEnv", isOpen)}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""}
onDeleteApproved={() =>
onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div> </div>
); <p className="text-gray-400 mb-8">
}, Choose which environments will show up in your dashboard like development, staging,
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Environments } production
); </p>
{permision.can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments) ? (
<EnvironmentTable handlePopUpOpen={handlePopUpOpen} />
) : (
<PermissionDeniedBanner />
)}
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteEnv", isOpen)}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""}
onDeleteApproved={() =>
onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div>
);
};
@@ -5,7 +5,6 @@ import {
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2"; import { Button } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { import {
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceIndexStatus, useGetWorkspaceIndexStatus,
@@ -16,73 +15,70 @@ import {
// TODO: add check so that this only shows up if user is // TODO: add check so that this only shows up if user is
// an admin in the workspace // an admin in the workspace
export const ProjectIndexSecretsSection = withProjectPermission( export const ProjectIndexSecretsSection = () => {
() => { const { currentWorkspace } = useWorkspace();
const { currentWorkspace } = useWorkspace(); const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus( currentWorkspace?._id ?? ""
currentWorkspace?._id ?? "" );
); const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? ""); const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const onEnableBlindIndices = async () => { const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return; if (!encryptedSecrets) return;
if (!latestFileKey) return; if (!latestFileKey) return;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey, ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce, nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey, publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
}); });
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { return {
const secretName = decryptSymmetric({ secretName,
ciphertext: encryptedSecret.secretKeyCiphertext, _id: encryptedSecret._id
iv: encryptedSecret.secretKeyIV, };
tag: encryptedSecret.secretKeyTag, });
key
});
return { await nameWorkspaceSecrets.mutateAsync({
secretName, workspaceId: currentWorkspace._id,
_id: encryptedSecret._id secretsToUpdate
}; });
}); };
await nameWorkspaceSecrets.mutateAsync({ return !isBlindIndexedLoading && !isBlindIndexed ? (
workspaceId: currentWorkspace._id, <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
secretsToUpdate <p className="mb-3 text-xl font-semibold">Blind Indices</p>
}); <p className="text-gray-400 mb-8">
}; Your project, created before the introduction of blind indexing, contains unindexed secrets.
To access individual secrets by name through the SDK and public API, please enable blind
return !isBlindIndexedLoading && !isBlindIndexed ? ( indexing.
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> </p>
<p className="mb-3 text-xl font-semibold">Blind Indices</p> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
<p className="text-gray-400 mb-8"> {(isAllowed) => (
Your project, created before the introduction of blind indexing, contains unindexed <Button
secrets. To access individual secrets by name through the SDK and public API, please onClick={onEnableBlindIndices}
enable blind indexing. isDisabled={!isAllowed}
</p> color="mineshaft"
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}> size="sm"
{(isAllowed) => ( type="submit"
<Button >
onClick={onEnableBlindIndices} Enable Blind Indexing
isDisabled={!isAllowed} </Button>
color="mineshaft" )}
size="sm" </ProjectPermissionCan>
type="submit" </div>
> ) : (
Enable Blind Indexing <div />
</Button> );
)} };
</ProjectPermissionCan>
</div>
) : (
<div />
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings }
);
@@ -2,10 +2,9 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions"; import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2"; import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteWsTag } from "@app/hooks/api"; import { useDeleteWsTag } from "@app/hooks/api";
@@ -14,80 +13,82 @@ import { SecretTagsTable } from "./SecretTagsTable";
type DeleteModalData = { name: string; id: string }; type DeleteModalData = { name: string; id: string };
export const SecretTagsSection = withProjectPermission( export const SecretTagsSection = (): JSX.Element => {
(): JSX.Element => { const { createNotification } = useNotificationContext();
const { createNotification } = useNotificationContext(); const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ "CreateSecretTag",
"CreateSecretTag", "deleteTagConfirmation"
"deleteTagConfirmation" ] as const);
] as const); const permission = useProjectPermission();
const deleteWsTag = useDeleteWsTag(); const deleteWsTag = useDeleteWsTag();
const onDeleteApproved = async () => { const onDeleteApproved = async () => {
try { try {
await deleteWsTag.mutateAsync({ await deleteWsTag.mutateAsync({
tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id
}); });
createNotification({ createNotification({
text: "Successfully deleted tag", text: "Successfully deleted tag",
type: "success" type: "success"
}); });
handlePopUpClose("deleteTagConfirmation"); handlePopUpClose("deleteTagConfirmation");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete the tag", text: "Failed to delete the tag",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="mb-3 text-xl font-semibold">Secret Tags</p> <p className="mb-3 text-xl font-semibold">Secret Tags</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Tags}> <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Tags}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
colorSchema="secondary" colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
console.log("x"); console.log("x");
handlePopUpOpen("CreateSecretTag"); handlePopUpOpen("CreateSecretTag");
console.log("x2"); console.log("x2");
}} }}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create tag Create tag
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div>
<p className="text-gray-400 mb-8">
Every secret can be assigned to one or more tags. Here you can add and remove tags for the
current project.
</p>
<SecretTagsTable handlePopUpOpen={handlePopUpOpen} />
<AddSecretTagModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name || " "
} api key?`}
onChange={(isOpen) => handlePopUpToggle("deleteTagConfirmation", isOpen)}
deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteTagConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div> </div>
); <p className="text-gray-400 mb-8">
}, Every secret can be assigned to one or more tags. Here you can add and remove tags for the
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Tags } current project.
); </p>
{permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? (
<SecretTagsTable handlePopUpOpen={handlePopUpOpen} />
) : (
<PermissionDeniedBanner />
)}
<AddSecretTagModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name || " "
} api key?`}
onChange={(isOpen) => handlePopUpToggle("deleteTagConfirmation", isOpen)}
deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteTagConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
};